OXYGENHUB
# a268d4df52bf oxygenhub / assets / hub.js
39.7 KiB · JavaScript · last changed Live pages L2: GET /live?topics= and the hub.js client 20 days ago source blame history raw
1
/* Progressive enhancement only: every page works without this file. */
2
(function () {
3
  "use strict";
4
  /* The band's active tab: the longest link that prefixes the path. */
5
  var p = location.pathname, best = null;
6
  document.querySelectorAll(".band a").forEach(function (a) {
7
    var h = a.getAttribute("href");
8
    if (h !== "/" && (p === h || p.indexOf(h + "/") === 0) && (!best || h.length > best.getAttribute("href").length)) best = a;
9
  });
10
  if (best) best.classList.add("on");
11
12
  /* Off-the-shelf editors, loaded only where a page asks for them:
13
     CodeMirror 6 over textarea.code, EasyMDE over textarea.md. */
14
  function load(src, css) {
15
    return new Promise(function (ok) {
16
      if (css) { var l = document.createElement("link"); l.rel = "stylesheet"; l.href = css; document.head.appendChild(l); }
17
      var s = document.createElement("script"); s.src = src; s.onload = ok; document.head.appendChild(s);
18
    });
19
  }
20
  if (document.querySelector("textarea.code")) load("/static/vendor/editor.js");
21
  var mds = document.querySelectorAll("textarea.md");
22
  /* The Markdown editor in GitLab's manner (oxygenhub#193): a Preview tab,
23
     then icon buttons, fullscreen at the far end, and a footer that says
24
     Markdown is what it takes. Still a textarea underneath. */
25
  if (mds.length) load("/static/vendor/easymde.min.js", "/static/vendor/easymde.min.css").then(function () {
26
    var E = window.EasyMDE;
27
    function svg(d) { return '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>'; }
28
    var I = {
29
      bold: svg('<path d="M4.5 2.5h4.25a2.75 2.75 0 0 1 0 5.5H4.5zM4.5 8h5a2.75 2.75 0 0 1 0 5.5h-5z"/>'),
30
      italic: svg('<path d="M6.5 2.5h6M3.5 13.5h6M10 2.5l-4 11"/>'),
31
      strike: svg('<path d="M2.5 8h11M11 4.5c-.5-1.2-1.7-2-3.2-2-1.9 0-3.3 1-3.3 2.6 0 .9.4 1.5 1.2 2M5 11.5c.5 1.2 1.8 2 3.3 2 2 0 3.4-1.1 3.4-2.7"/>'),
32
      heading: svg('<path d="M3.5 2.5v11M12.5 2.5v11M3.5 8h9"/>'),
33
      quote: svg('<path d="M2.5 3.5h11M5.5 8h8M5.5 12.5h8M2.5 7v6"/>'),
34
      code: svg('<path d="M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5"/>'),
35
      link: svg('<path d="M7 9a3 3 0 0 0 4.2.3l2-2a3 3 0 0 0-4.2-4.2l-1 1M9 7a3 3 0 0 0-4.2-.3l-2 2a3 3 0 0 0 4.2 4.2l1-1"/>'),
36
      ul: svg('<path d="M6 4h7.5M6 8h7.5M6 12h7.5"/><circle cx="2.75" cy="4" r=".75" fill="currentColor"/><circle cx="2.75" cy="8" r=".75" fill="currentColor"/><circle cx="2.75" cy="12" r=".75" fill="currentColor"/>'),
37
      ol: svg('<path d="M6.5 4h7M6.5 8h7M6.5 12h7M2 2.5h1v3M2 7.5h1.5L2 9.5h1.5M2 10.5h1.5v3H2M2 12h1.5"/>'),
38
      task: svg('<rect x="2" y="2.5" width="4" height="4" rx=".75"/><path d="m2.75 11 1 1 1.75-2M8.5 4.5h5M8.5 11.5h5"/>'),
39
      table: svg('<rect x="2" y="2.5" width="12" height="11" rx="1"/><path d="M2 6.5h12M2 10h12M6.5 6.5v7"/>'),
40
      full: svg('<path d="M2.5 6V2.5H6M10 2.5h3.5V6M13.5 10v3.5H10M6 13.5H2.5V10"/>')
41
    };
42
    function b(name, action, title, icon, text) { return { name: name, action: action, title: title, icon: icon ? '<span class="mde-ico">' + icon + '</span>' : undefined, text: text, className: "mde-btn" + (text ? " mde-text" : "") }; }
43
    function task(ed) {
44
      var cm = ed.codemirror, from = cm.getCursor("from"), to = cm.getCursor("to");
45
      for (var l = from.line; l <= to.line; l++) {
46
        var line = cm.getLine(l), m = line.match(/^(\s*)[-*] \[[ xX]\] /);
47
        if (m) cm.replaceRange(m[1], { line: l, ch: 0 }, { line: l, ch: m[0].length });
48
        else cm.replaceRange("- [ ] ", { line: l, ch: (line.match(/^\s*/) || [""])[0].length });
49
      }
50
      cm.focus();
51
    }
52
    var toolbar = [b("preview", E.togglePreview, "Preview", null, "Preview"), "|",
53
      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), "|",
54
      b("quote", E.toggleBlockquote, "Quote", I.quote), b("code", E.toggleCodeBlock, "Code", I.code), b("link", E.drawLink, "Link (Ctrl+K)", I.link), "|",
55
      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), "|",
56
      b("fullscreen", E.toggleFullScreen, "Fullscreen", I.full)];
57
    mds.forEach(function (t) {
58
      t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar,
59
        placeholder: t.getAttribute("placeholder") || "Write in Markdown…", minHeight: "120px", sideBySideFullscreen: false });
60
      var c = t._mde.element.parentNode.querySelector(".EasyMDEContainer") || t.nextElementSibling;
61
      if (c && c.classList.contains("EasyMDEContainer")) {
62
        var f = document.createElement("div");
63
        f.className = "mde-foot small mute";
64
        f.innerHTML = '<span>Markdown supported</span><span class="mde-md" title="Markdown" aria-hidden="true">M↓</span>';
65
        c.appendChild(f);
66
      }
67
    });
68
    /* An editor shown later (a :target edit form) lays itself out again. */
69
    window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); });
70
  });
71
72
  /* Mermaid diagrams (```mermaid blocks in Markdown): drawn by the hub's own
73
     copy of Mermaid, loaded only on pages that have one, in strict mode (no
74
     scripts or HTML from the diagram), in the page's theme. A diagram that
75
     does not parse stays as its source, with Mermaid's reason under it. */
76
  var diagrams = document.querySelectorAll("pre > code.language-mermaid");
77
  if (diagrams.length) load("/static/vendor/mermaid.min.js").then(function () {
78
    var m = window.mermaid, dark = document.documentElement.dataset.theme === "dark";
79
    m.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: dark ? "dark" : "default" });
80
    diagrams.forEach(function (code, i) {
81
      var pre = code.parentNode, source = code.textContent;
82
      m.render("mermaid-" + i, source).then(function (r) {
83
        var fig = document.createElement("figure");
84
        fig.className = "mermaid-diagram";
85
        fig.innerHTML = r.svg;
86
        fig.setAttribute("aria-label", "Diagram");
87
        pre.replaceWith(fig);
88
      }).catch(function (e) {
89
        var note = document.createElement("p");
90
        note.className = "small warn";
91
        note.textContent = "This diagram could not be drawn: " + ((e && e.message) || e);
92
        pre.after(note);
93
      });
94
    });
95
  });
96
97
  /* Report frames (#127): a report's page says how tall it is, and the
98
     frame grows to fit; the frame is told the theme, at start and on a
99
     change. Only messages from the frame itself are taken. */
100
  var frames = document.querySelectorAll("iframe.rep-frame");
101
  var themeToFrames = function () {
102
    frames.forEach(function (f) { if (f.contentWindow) f.contentWindow.postMessage({ oxygenhubTheme: document.documentElement.dataset.theme || "dark" }, "*"); });
103
  };
104
  if (frames.length) window.addEventListener("message", function (e) {
105
    frames.forEach(function (f) {
106
      if (e.source !== f.contentWindow) return;
107
      var r = e.data && e.data.oxygenhubReport;
108
      if (!r) return;
109
      if (r.ready) themeToFrames();
110
      if (typeof r.height === "number" && r.height > 0) {
111
        f.style.height = Math.min(Math.max(r.height, 120), 40000) + "px";
112
        f.classList.add("sized");
113
      }
114
    });
115
  });
116
117
  /* Report frames ask for runs (#127, RP4): report.run() is the run on
118
     screen, report.history(dataset) that file of data/ across the runs,
119
     oldest first. Answered only to the frame itself, from the files of
120
     this report's own runs. */
121
  if (frames.length) window.addEventListener("message", function (e) {
122
    frames.forEach(function (f) {
123
      if (e.source !== f.contentWindow) return;
124
      var ask = e.data && e.data.oxygenhubAsk;
125
      if (!ask) return;
126
      var runs = []; try { runs = JSON.parse(f.dataset.reportRuns || "[]"); } catch (_) {}
127
      var current = Number(f.dataset.reportCurrent);
128
      var reply = function (body) { f.contentWindow.postMessage({ oxygenhubAnswer: { n: ask.n, value: body.value, error: body.error } }, "*"); };
129
      var meta = function (r) { return { run: r.run, bookmark: r.bookmark, created_ms: r.created_ms, summary: r.summary, current: r.id === current }; };
130
      if (ask.what === "run") {
131
        var r = runs.filter(function (x) { return x.id === current; })[0];
132
        reply({ value: r ? meta(r) : null });
133
      } else if (ask.what === "history" && typeof ask.dataset === "string" && /^[A-Za-z0-9._+-]+(\/[A-Za-z0-9._+-]+)*$/.test(ask.dataset)) {
134
        Promise.all(runs.map(function (r) {
135
          return fetch(r.files + "/data/" + ask.dataset).then(function (res) { return res.ok ? res.text() : null; }).catch(function () { return null; })
136
            .then(function (text) { var m = meta(r); m.text = text; return m; });
137
        })).then(function (all) { reply({ value: all.filter(function (x) { return x.text !== null; }) }); });
138
      } else {
139
        reply({ error: "ask for run, or history with a file name under data/" });
140
      }
141
    });
142
  });
143
144
  /* The report timeline (#127, RP4): arrow keys step through the runs,
145
     Play steps on by itself at the chosen speed, carried in the URL. */
146
  var tl = document.querySelector("[data-timeline]");
147
  if (tl) {
148
    var stops = Array.prototype.slice.call(tl.querySelectorAll("[data-tl-stop]"));
149
    var at = stops.findIndex(function (a) { return a.classList.contains("on"); });
150
    var go = function (i, play) {
151
      if (i < 0 || i >= stops.length) return;
152
      var u = new URL(stops[i].href, location.href);
153
      if (play) u.searchParams.set("play", play); else u.searchParams.delete("play");
154
      location.href = u.toString();
155
    };
156
    document.addEventListener("keydown", function (e) {
157
      if (e.target.closest("input, select, textarea, [contenteditable]")) return;
158
      if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1, tl.dataset.play); }
159
      if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1, tl.dataset.play); }
160
    });
161
    var speed = tl.querySelector("[data-tl-speed]"), button = tl.querySelector("[data-tl-play]");
162
    if (button) button.addEventListener("click", function () {
163
      if (tl.dataset.play) { go(at, null); return; }
164
      go(at >= stops.length - 1 ? 0 : at + 1, speed ? speed.value : "2");
165
    });
166
    if (speed) speed.addEventListener("change", function () { if (tl.dataset.play) go(at, speed.value); });
167
    if (tl.dataset.play) {
168
      var next = at + 1;
169
      if (next < stops.length) setTimeout(function () { go(next, tl.dataset.play); }, Number(tl.dataset.play) * 1000);
170
      else setTimeout(function () { go(at, null); }, Number(tl.dataset.play) * 1000);
171
    }
172
  }
173
174
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
175
  var toggle = document.querySelector("[data-theme-toggle]");
176
  if (toggle) {
177
    var root = document.documentElement, order = ["light", "dark", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" };
178
    var show = function () {
179
      var p = root.dataset.themePref || "light";
180
      toggle.title = "Theme: " + p + " (click to change)";
181
      toggle.setAttribute("aria-label", toggle.title);
182
      toggle.firstElementChild.textContent = glyph[p];
183
    };
184
    show();
185
    toggle.addEventListener("click", function () {
186
      var p = order[(order.indexOf(root.dataset.themePref || "light") + 1) % order.length];
187
      document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax";
188
      root.dataset.themePref = p;
189
      root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
190
      show();
191
      themeToFrames();
192
    });
193
  }
194
195
  /* Diffs: unified or split (remembered in the diff cookie), collapse all. */
196
  var diffs = document.querySelectorAll("table.code.diff");
197
  if (diffs.length) {
198
    var pane = function (tr, col, side) {
199
      var n = document.createElement("td"), t = document.createElement("td");
200
      n.className = "n"; t.className = "t" + (side === "right" ? " mid" : "");
201
      if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; }
202
      var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx";
203
      if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; }
204
      n.textContent = tr.cells[col].textContent;
205
      t.innerHTML = tr.cells[3].innerHTML;
206
      // Line comments belong to the projected (right) side only.
207
      if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); });
208
      return [n, t];
209
    };
210
    var split = function (table) {
211
      var s = document.createElement("table");
212
      s.className = "code diff split";
213
      s.innerHTML = '<colgroup><col class="n"><col><col class="n"><col></colgroup>';
214
      var rows = [].slice.call(table.rows), i = 0;
215
      var add = function (l, r) {
216
        var tr = s.insertRow();
217
        pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); });
218
      };
219
      while (i < rows.length) {
220
        var r = rows[i];
221
        if (r.classList.contains("del") || r.classList.contains("add")) {
222
          var dels = [], adds = [];
223
          while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]);
224
          while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]);
225
          for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]);
226
          continue;
227
        }
228
        if (r.classList.contains("gap") || r.querySelector("td.thread")) {
229
          var tr = s.insertRow(), td = tr.insertCell();
230
          tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className;
231
          td.innerHTML = r.cells[r.cells.length - 1].innerHTML;
232
        } else {
233
          add(r, r);
234
        }
235
        i++;
236
      }
237
      return s;
238
    };
239
    var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified";
240
    var apply = function (mode) {
241
      diffs.forEach(function (t) {
242
        if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); }
243
        t.hidden = mode === "split";
244
        if (t._split) t._split.hidden = mode !== "split";
245
      });
246
      document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); });
247
    };
248
    document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; });
249
    document.querySelectorAll("[data-diff-mode]").forEach(function (a) {
250
      a.addEventListener("click", function (ev) {
251
        ev.preventDefault();
252
        var mode = a.getAttribute("data-diff-mode");
253
        document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax";
254
        apply(mode);
255
      });
256
    });
257
    document.querySelectorAll("[data-diff-collapse]").forEach(function (a) {
258
      a.addEventListener("click", function (ev) {
259
        ev.preventDefault();
260
        var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; });
261
        files.forEach(function (d) { d.open = !anyOpen; });
262
        a.textContent = anyOpen ? "expand all" : "collapse all";
263
      });
264
    });
265
    apply(cookieMode);
266
  }
267
268
  /* Settings pages: mark the section in view in the list of sections. */
269
  var navLinks = document.querySelectorAll(".section-nav a[href^='#']");
270
  if (navLinks.length && "IntersectionObserver" in window) {
271
    var byId = {};
272
    navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
273
    var io = new IntersectionObserver(function (entries) {
274
      entries.forEach(function (e) {
275
        if (e.isIntersecting && byId[e.target.id]) {
276
          navLinks.forEach(function (a) { a.classList.remove("on"); });
277
          byId[e.target.id].classList.add("on");
278
        }
279
      });
280
    }, { rootMargin: "0px 0px -70% 0px" });
281
    Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); });
282
  }
283
284
  /* Copy buttons. */
285
  document.querySelectorAll("[data-copy]").forEach(function (b) {
286
    b.addEventListener("click", function () {
287
      navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
288
        var t = b.textContent; b.textContent = "copied"; setTimeout(function () { b.textContent = t; }, 1200);
289
      });
290
    });
291
  });
292
293
  /* Tree rows: the last change per entry, fetched after the page paints. */
294
  var tree = document.querySelector("table.tree[data-last]");
295
  if (tree) {
296
    var paths = [].map.call(tree.querySelectorAll("tr[data-path]"), function (r) { return r.getAttribute("data-path"); });
297
    if (paths.length) {
298
      fetch(tree.getAttribute("data-last") + "?paths=" + encodeURIComponent(paths.join(",")), { credentials: "same-origin" })
299
        .then(function (r) { return r.json(); })
300
        .then(function (rows) {
301
          rows.forEach(function (c) {
302
            var tr = tree.querySelector('tr[data-path="' + CSS.escape(c.path) + '"]');
303
            if (!tr) return;
304
            var a = tr.querySelector("td.last a"), t = tr.querySelector("td.age");
305
            if (a) { a.textContent = c.title; a.href = tree.getAttribute("data-change") + c.hash; }
306
            if (t) { t.textContent = c.ago; t.title = c.when; }
307
          });
308
        }).catch(function () {});
309
    }
310
  }
311
312
  /* Line anchors: #L12 or #L12-L20 highlight a range and clicking a number selects. */
313
  function mark() {
314
    var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/);
315
    document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); });
316
    if (!m) { showBar(0, 0); return; }
317
    var a = +m[1], b = +(m[2] || m[1]);
318
    for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
319
      var row = document.getElementById("L" + i);
320
      if (row) row.classList.add("hl");
321
    }
322
    var first = document.getElementById("L" + Math.min(a, b));
323
    if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; }
324
    showBar(Math.min(a, b), Math.max(a, b));
325
  }
326
  /* The bar for a selected range: copy its text, or a permalink to it. */
327
  var bar = document.querySelector(".linebar");
328
  function showBar(a, b) {
329
    if (!bar) return;
330
    bar.hidden = !a;
331
    if (!a) return;
332
    bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b;
333
    bar.setAttribute("data-range", a + "-" + b);
334
  }
335
  function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); }
336
  if (bar) {
337
    bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) {
338
      var r = bar.getAttribute("data-range").split("-"), out = [];
339
      for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); }
340
      navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); });
341
    });
342
    var pl = bar.querySelector("[data-copy-permalink]");
343
    if (pl) pl.addEventListener("click", function () {
344
      navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); });
345
    });
346
  }
347
  function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; }
348
  window.addEventListener("hashchange", mark);
349
  mark();
350
  document.querySelectorAll("table.code td.n").forEach(function (td) {
351
    td.addEventListener("click", function (e) {
352
      var n = td.parentNode.id;
353
      if (e.shiftKey && location.hash.match(/^#L(\d+)/)) location.hash = location.hash.replace(/^#(L\d+).*/, "$1-" + n);
354
      else location.hash = n;
355
    });
356
  });
357
358
  /* Flow run page: statuses and new log lines while the run is live. */
359
  var liveUrl = document.body.getAttribute("data-flow-live");
360
  if (liveUrl) {
361
    var tick = function () {
362
      var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) {
363
        return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq");
364
      }).join(",");
365
      fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } })
366
        .then(function (r) { return r.json(); })
367
        .then(function (s) {
368
          s.jobs.forEach(function (j) {
369
            var cls = "st-" + j.status;
370
            var node = document.querySelector("[data-job-node='" + j.id + "']");
371
            if (node) {
372
              node.setAttribute("class", "node " + cls);
373
              node.querySelector("[data-glyph]").textContent = j.glyph;
374
              node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration;
375
            }
376
            var d = document.querySelector("details[data-job='" + j.id + "']");
377
            if (!d) return;
378
            d.className = "panel flow-job " + cls;
379
            var g = d.querySelector("summary [data-glyph]");
380
            g.className = "flow-glyph " + cls; g.textContent = j.glyph;
381
            d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration;
382
            if (j.status === "running" || j.status === "failed") d.open = true;
383
            var log = d.querySelector("[data-log]");
384
            var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
385
            j.lines.forEach(function (l) {
386
              var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
387
              var t = document.createElement("span"); t.className = "t"; t.textContent = (l.t / 1000).toFixed(1) + "s";
388
              ln.appendChild(t); ln.appendChild(document.createTextNode(l.text));
389
              log.appendChild(ln); log.appendChild(document.createTextNode("\n"));
390
              log.setAttribute("data-seq", l.seq);
391
            });
392
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
393
          });
394
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
395
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
396
        })
397
        .catch(function () { setTimeout(tick, 5000); });
398
    };
399
    setTimeout(tick, 1500);
400
  }
401
402
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
403
  var pending = null;
404
  document.addEventListener("keydown", function (e) {
405
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
406
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
407
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
408
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
409
    var repo = document.body.getAttribute("data-repo");
410
    if (pending === "g" && repo) {
411
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
412
      if (to !== undefined) location.href = repo + to;
413
      pending = null; return;
414
    }
415
    pending = e.key === "g" ? "g" : null;
416
  });
417
418
  // Live conflict resolution (hub spec §6.10): who else is resolving this
419
  // conflict, their choices region by region (the last one wins), and the
420
  // resolution when someone commits it.
421
  var live = document.querySelector("[data-resolve-live]");
422
  if (live && window.EventSource && window.fetch) {
423
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
424
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
425
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
426
    var applying = false;
427
    var fieldsOf = function (region) {
428
      return region === "keep"
429
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
430
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
431
    };
432
    var apply = function (region, c) {
433
      var f = fieldsOf(region);
434
      applying = true;
435
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
436
      if (f.text && c.choice === "text") f.text.value = c.text;
437
      applying = false;
438
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
439
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
440
    };
441
    var send = function (region, choice, text) {
442
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
443
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
444
    };
445
    var timers = {};
446
    live.addEventListener("change", function (ev) {
447
      var t = ev.target;
448
      if (applying || !t.name || t.type !== "radio") return;
449
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
450
      var ta = live.querySelector('textarea[name="t' + region + '"]');
451
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
452
    });
453
    live.addEventListener("input", function (ev) {
454
      var t = ev.target;
455
      if (applying || t.tagName !== "TEXTAREA") return;
456
      var region = t.name.slice(1);
457
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
458
      clearTimeout(timers[region]);
459
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
460
    });
461
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
462
    var showPresent = function (names) {
463
      present.hidden = names.length < 2;
464
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
465
    };
466
    es.addEventListener("snapshot", function (e) {
467
      var d = JSON.parse(e.data);
468
      showPresent(d.present);
469
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
470
      if (d.keep) apply("keep", d.keep);
471
    });
472
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
473
    es.addEventListener("choice", function (e) {
474
      var d = JSON.parse(e.data);
475
      if (d.client !== client) apply(d.region, d.choice);
476
    });
477
    es.addEventListener("landed", function (e) {
478
      var d = JSON.parse(e.data);
479
      es.close();
480
      landed.hidden = false;
481
      landed.textContent = "";
482
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
483
      var a = document.createElement("a");
484
      a.href = d.url;
485
      a.textContent = "see the change";
486
      landed.appendChild(a);
487
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
488
    });
489
  }
490
491
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
492
  // another to plan a squash or a rebase. The hub only writes the plan;
493
  // the author's client carries it out and signs it.
494
  var plan = document.querySelector("[data-plan]");
495
  if (plan && window.fetch) {
496
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
497
    plan.querySelectorAll("[data-change]").forEach(function (a) {
498
      a.style.cursor = "grab";
499
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
500
      a.addEventListener("pointermove", function () { if (from) moved = true; });
501
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
502
    });
503
    document.addEventListener("pointerup", function (ev) {
504
      var start = from;
505
      from = null;
506
      if (!start) return;
507
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
508
      if (!onto || onto.getAttribute("data-change") === start) return;
509
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
510
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
511
        .then(function (r) { return r.json(); })
512
        .then(function (d) {
513
          panel.hidden = false;
514
          panel.textContent = "";
515
          if (d.error) { panel.textContent = d.error; return; }
516
          d.options.forEach(function (o) {
517
            var p = document.createElement("p");
518
            p.appendChild(document.createTextNode(o.summary + " "));
519
            var a = document.createElement("a");
520
            a.href = o.vscode;
521
            a.textContent = "Apply in VS Code";
522
            p.appendChild(a);
523
            var code = document.createElement("pre");
524
            code.className = "snippet";
525
            code.textContent = o.command;
526
            panel.appendChild(p);
527
            panel.appendChild(code);
528
          });
529
        });
530
    });
531
  }
532
533
  /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click
534
     outside an open menu closes it. */
535
  document.querySelectorAll("[data-menu-filter]").forEach(function (input) {
536
    var items = input.closest(".menu-pop").querySelectorAll(".menu-item");
537
    input.addEventListener("input", function () {
538
      var q = input.value.trim().toLowerCase();
539
      items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; });
540
    });
541
  });
542
  document.querySelectorAll("details.menu").forEach(function (d) {
543
    d.addEventListener("toggle", function () {
544
      var f = d.querySelector("[data-menu-filter]");
545
      if (d.open && f) f.focus();
546
    });
547
  });
548
  document.addEventListener("click", function (ev) {
549
    document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; });
550
  });
551
552
  /* ··· menus: copy a link to a part of the page, or some text. */
553
  function copied(btn, text) {
554
    if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () {
555
      var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200);
556
    });
557
    var d = btn.closest("details"); if (d) d.open = false;
558
  }
559
  document.querySelectorAll("[data-copy-link]").forEach(function (b) {
560
    b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); });
561
  });
562
  document.querySelectorAll("[data-copy-text]").forEach(function (b) {
563
    b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); });
564
  });
565
  document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) {
566
    a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; });
567
  });
568
569
  /* Settings pages: a form posts and the page comes back from the top.
570
     Remember which section's form was sent, and on the same page's next
571
     load scroll back to it, bringing the notice along. */
572
  document.addEventListener("submit", function (e) {
573
    var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null;
574
    if (!sec || !sec.id) return;
575
    try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ }
576
  });
577
  try {
578
    var back = sessionStorage.getItem("oxy-return");
579
    if (back && back.indexOf(location.pathname + "#") === 0) {
580
      sessionStorage.removeItem("oxy-return");
581
      var target = document.getElementById(back.split("#")[1]);
582
      if (target) {
583
        var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error");
584
        if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target);
585
        target.scrollIntoView({ block: "start" });
586
      }
587
    }
588
  } catch (err) { /* no storage: nothing to restore */ }
589
590
  /* Working (#142): on a repository whose store is remote, a land or a
591
     page can take seconds. A pressed button says what it is doing, with
592
     the CLI's spinner, and ignores a second press; a navigation that takes
593
     more than a moment spins the Oxygen mark's rings, in the header and
594
     beside the pointer. A quick page shows none of it. */
595
  var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
596
  var waitTimer = 0, spinTimer = 0, pointer = null, mark = null;
597
  document.addEventListener("pointermove", function (e) {
598
    pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null;
599
    if (mark && pointer) place();
600
  }, { passive: true });
601
  function place() {
602
    if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); }
603
    else mark.classList.add("corner");
604
  }
605
  function waiting() {
606
    clearTimeout(waitTimer);
607
    waitTimer = setTimeout(function () {
608
      if (!mark) {
609
        var logo = document.querySelector("header .brand svg");
610
        mark = document.createElement("div");
611
        mark.className = "oxy-wait-mark";
612
        mark.setAttribute("role", "status");
613
        mark.setAttribute("aria-label", "Working");
614
        if (logo) mark.appendChild(logo.cloneNode(true));
615
        document.body.appendChild(mark);
616
      }
617
      place();
618
      document.documentElement.classList.add("oxy-wait");
619
    }, 300);
620
  }
621
  function busy(button, form) {
622
    if (!button) return;
623
    var label = button.getAttribute("data-busy") || button.textContent.trim();
624
    button.dataset.idle = button.innerHTML;
625
    button.style.minWidth = button.offsetWidth + "px";
626
    button.textContent = label + " ";
627
    var glyph = document.createElement("span");
628
    glyph.className = "oxy-spin";
629
    glyph.setAttribute("aria-hidden", "true");
630
    glyph.textContent = SPIN[0];
631
    button.appendChild(glyph);
632
    button.setAttribute("aria-busy", "true");
633
    var i = 0;
634
    clearInterval(spinTimer);
635
    spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80);
636
    /* Disabled only after the form's data is gathered, so the button's
637
       own name and value still go with it. */
638
    setTimeout(function () { button.disabled = true; }, 0);
639
    form.dataset.sending = "1";
640
  }
641
  document.addEventListener("submit", function (e) {
642
    var form = e.target;
643
    if (form.dataset.sending) { e.preventDefault(); return; }
644
    if (e.defaultPrevented || (form.target && form.target !== "_self")) return;
645
    if ((form.getAttribute("method") || "get").toLowerCase() === "post") {
646
      busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form);
647
    }
648
    waiting();
649
  });
650
  document.addEventListener("click", function (e) {
651
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
652
    var a = e.target.closest ? e.target.closest("a[href]") : null;
653
    if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
654
    var url = new URL(a.href, location.href);
655
    if (url.origin !== location.origin) return;
656
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
657
    waiting();
658
  });
659
  /* Back to a page the browser kept: it is not working any more. */
660
  window.addEventListener("pageshow", function (e) {
661
    if (!e.persisted) return;
662
    clearTimeout(waitTimer);
663
    clearInterval(spinTimer);
664
    document.documentElement.classList.remove("oxy-wait");
665
    document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) {
666
      b.innerHTML = b.dataset.idle;
667
      b.disabled = false;
668
      b.removeAttribute("aria-busy");
669
      b.style.minWidth = "";
670
      if (b.form) delete b.form.dataset.sending;
671
    });
672
  });
673
674
  /* AI assistants (#109): a provider fills the name, endpoint and API;
675
     List models asks the endpoint what it offers and fills the model
676
     field's suggestions. Without this script, both are typed by hand. */
677
  document.querySelectorAll("[data-preset-select]").forEach(function (sel) {
678
    var form = sel.form;
679
    function apply() {
680
      var o = sel.options[sel.selectedIndex];
681
      if (!o || !o.value) return;
682
      var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]");
683
      name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api;
684
    }
685
    sel.addEventListener("change", apply);
686
    apply();
687
  });
688
  document.querySelectorAll("[data-list-models]").forEach(function (b) {
689
    b.addEventListener("click", function () {
690
      var form = b.form, input = document.getElementById(b.dataset.for);
691
      var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']");
692
      var body = new URLSearchParams();
693
      body.set("_csrf", b.dataset.csrf);
694
      ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); });
695
      if (b.dataset.id) body.set("id", b.dataset.id);
696
      if (note) note.textContent = "Asking the provider…";
697
      fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" })
698
        .then(function (r) { return r.json(); })
699
        .then(function (j) {
700
          if (j.models) {
701
            list.innerHTML = "";
702
            j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); });
703
            if (note) note.textContent = j.models.length + " models: pick one in the Model field.";
704
            input.focus();
705
          } else if (note) {
706
            note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name.";
707
          }
708
        })
709
        .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; });
710
    });
711
  });
712
})();
713
714
// Live pages (docs/live.md, oxygenhub#204): elements marked
715
// data-live="<topic>" data-fragment="<name>" are fetched again from this
716
// page's own route (?fragment=<name>) when the hub says their topic
717
// changed, and swapped in. Someone typing inside one is not interrupted:
718
// it is refreshed when they leave it. Without JavaScript or a stream the
719
// page is as it was; a reload shows everything.
720
(function () {
721
  var marked = document.querySelectorAll("[data-live][data-fragment]");
722
  if (!marked.length || !window.EventSource || !window.fetch) return;
723
  var topics = {};
724
  Array.prototype.forEach.call(marked, function (el) { topics[el.getAttribute("data-live")] = true; });
725
  var busy = {};
726
  var typingIn = function (el) {
727
    var a = document.activeElement;
728
    return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value;
729
  };
730
  var refresh = function (el) {
731
    var name = el.getAttribute("data-fragment");
732
    if (busy[name]) return;
733
    if (typingIn(el)) {
734
      el.addEventListener("focusout", function again() {
735
        el.removeEventListener("focusout", again);
736
        setTimeout(function () { refresh(el); }, 0);
737
      });
738
      return;
739
    }
740
    busy[name] = true;
741
    var url = new URL(location.href);
742
    url.searchParams.set("fragment", name);
743
    fetch(url.toString(), { credentials: "same-origin", headers: { "X-Fragment": name } })
744
      .then(function (r) { return r.ok ? r.text() : null; })
745
      .then(function (html) {
746
        busy[name] = false;
747
        if (html !== null && !typingIn(el)) el.innerHTML = html;
748
      })
749
      .catch(function () { busy[name] = false; });
750
  };
751
  var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(",")));
752
  es.addEventListener("change", function (e) {
753
    var d;
754
    try { d = JSON.parse(e.data); } catch (err) { return; }
755
    Array.prototype.forEach.call(marked, function (el) {
756
      if (el.getAttribute("data-live") === d.topic) refresh(el);
757
    });
758
  });
759
  // Missed more than the hub keeps: everything marked is fetched again.
760
  es.addEventListener("reload", function () {
761
    Array.prototype.forEach.call(marked, refresh);
762
  });
763
})();