OXYGENHUB
◆ v0.5.1 oxygenhub / assets / hub.js
39.0 KiB · JavaScript · last changed UI polish: keys, backups, wiki, editor, bookmarks, summaries, backdrops 21 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
            // Sections (oxygenflow#31): ::group::TITLE opens one, ::endgroup::
386
            // (or the next ::group::) closes it; the open one takes the lines.
387
            var open = log.querySelector("details[data-open-group]");
388
            var secs = function (ms) { return Math.floor(ms / 1000) + "." + Math.floor(ms % 1000 / 100) + "s"; };
389
            var close = function (t) {
390
              if (!open) return;
391
              open.removeAttribute("data-open-group"); open.open = false;
392
              var took = document.createElement("span"); took.className = "took"; took.textContent = " " + secs(t - Number(open.getAttribute("data-from")));
393
              open.querySelector("summary").appendChild(took); open = null;
394
            };
395
            j.lines.forEach(function (l) {
396
              var tt = document.createElement("span"); tt.className = "t"; tt.textContent = secs(l.t);
397
              if (l.text.indexOf("::group::") === 0) {
398
                close(l.t);
399
                open = document.createElement("details"); open.className = "log-group"; open.open = true;
400
                open.setAttribute("data-open-group", ""); open.setAttribute("data-from", l.t);
401
                var sum = document.createElement("summary"); sum.className = "ln";
402
                sum.appendChild(tt); sum.appendChild(document.createTextNode(l.text.slice(9).trim()));
403
                open.appendChild(sum); log.appendChild(open);
404
              } else if (l.text.trim() === "::endgroup::") {
405
                close(l.t);
406
              } else {
407
                var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
408
                ln.appendChild(tt); ln.appendChild(document.createTextNode(l.text));
409
                var into = open || log;
410
                into.appendChild(ln); into.appendChild(document.createTextNode("\n"));
411
              }
412
              log.setAttribute("data-seq", l.seq);
413
            });
414
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
415
          });
416
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
417
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
418
        })
419
        .catch(function () { setTimeout(tick, 5000); });
420
    };
421
    setTimeout(tick, 1500);
422
  }
423
424
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
425
  var pending = null;
426
  document.addEventListener("keydown", function (e) {
427
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
428
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
429
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
430
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
431
    var repo = document.body.getAttribute("data-repo");
432
    if (pending === "g" && repo) {
433
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
434
      if (to !== undefined) location.href = repo + to;
435
      pending = null; return;
436
    }
437
    pending = e.key === "g" ? "g" : null;
438
  });
439
440
  // Live conflict resolution (hub spec §6.10): who else is resolving this
441
  // conflict, their choices region by region (the last one wins), and the
442
  // resolution when someone commits it.
443
  var live = document.querySelector("[data-resolve-live]");
444
  if (live && window.EventSource && window.fetch) {
445
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
446
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
447
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
448
    var applying = false;
449
    var fieldsOf = function (region) {
450
      return region === "keep"
451
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
452
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
453
    };
454
    var apply = function (region, c) {
455
      var f = fieldsOf(region);
456
      applying = true;
457
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
458
      if (f.text && c.choice === "text") f.text.value = c.text;
459
      applying = false;
460
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
461
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
462
    };
463
    var send = function (region, choice, text) {
464
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
465
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
466
    };
467
    var timers = {};
468
    live.addEventListener("change", function (ev) {
469
      var t = ev.target;
470
      if (applying || !t.name || t.type !== "radio") return;
471
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
472
      var ta = live.querySelector('textarea[name="t' + region + '"]');
473
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
474
    });
475
    live.addEventListener("input", function (ev) {
476
      var t = ev.target;
477
      if (applying || t.tagName !== "TEXTAREA") return;
478
      var region = t.name.slice(1);
479
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
480
      clearTimeout(timers[region]);
481
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
482
    });
483
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
484
    var showPresent = function (names) {
485
      present.hidden = names.length < 2;
486
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
487
    };
488
    es.addEventListener("snapshot", function (e) {
489
      var d = JSON.parse(e.data);
490
      showPresent(d.present);
491
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
492
      if (d.keep) apply("keep", d.keep);
493
    });
494
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
495
    es.addEventListener("choice", function (e) {
496
      var d = JSON.parse(e.data);
497
      if (d.client !== client) apply(d.region, d.choice);
498
    });
499
    es.addEventListener("landed", function (e) {
500
      var d = JSON.parse(e.data);
501
      es.close();
502
      landed.hidden = false;
503
      landed.textContent = "";
504
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
505
      var a = document.createElement("a");
506
      a.href = d.url;
507
      a.textContent = "see the change";
508
      landed.appendChild(a);
509
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
510
    });
511
  }
512
513
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
514
  // another to plan a squash or a rebase. The hub only writes the plan;
515
  // the author's client carries it out and signs it.
516
  var plan = document.querySelector("[data-plan]");
517
  if (plan && window.fetch) {
518
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
519
    plan.querySelectorAll("[data-change]").forEach(function (a) {
520
      a.style.cursor = "grab";
521
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
522
      a.addEventListener("pointermove", function () { if (from) moved = true; });
523
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
524
    });
525
    document.addEventListener("pointerup", function (ev) {
526
      var start = from;
527
      from = null;
528
      if (!start) return;
529
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
530
      if (!onto || onto.getAttribute("data-change") === start) return;
531
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
532
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
533
        .then(function (r) { return r.json(); })
534
        .then(function (d) {
535
          panel.hidden = false;
536
          panel.textContent = "";
537
          if (d.error) { panel.textContent = d.error; return; }
538
          d.options.forEach(function (o) {
539
            var p = document.createElement("p");
540
            p.appendChild(document.createTextNode(o.summary + " "));
541
            var a = document.createElement("a");
542
            a.href = o.vscode;
543
            a.textContent = "Apply in VS Code";
544
            p.appendChild(a);
545
            var code = document.createElement("pre");
546
            code.className = "snippet";
547
            code.textContent = o.command;
548
            panel.appendChild(p);
549
            panel.appendChild(code);
550
          });
551
        });
552
    });
553
  }
554
555
  /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click
556
     outside an open menu closes it. */
557
  document.querySelectorAll("[data-menu-filter]").forEach(function (input) {
558
    var items = input.closest(".menu-pop").querySelectorAll(".menu-item");
559
    input.addEventListener("input", function () {
560
      var q = input.value.trim().toLowerCase();
561
      items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; });
562
    });
563
  });
564
  document.querySelectorAll("details.menu").forEach(function (d) {
565
    d.addEventListener("toggle", function () {
566
      var f = d.querySelector("[data-menu-filter]");
567
      if (d.open && f) f.focus();
568
    });
569
  });
570
  document.addEventListener("click", function (ev) {
571
    document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; });
572
  });
573
574
  /* ··· menus: copy a link to a part of the page, or some text. */
575
  function copied(btn, text) {
576
    if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () {
577
      var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200);
578
    });
579
    var d = btn.closest("details"); if (d) d.open = false;
580
  }
581
  document.querySelectorAll("[data-copy-link]").forEach(function (b) {
582
    b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); });
583
  });
584
  document.querySelectorAll("[data-copy-text]").forEach(function (b) {
585
    b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); });
586
  });
587
  document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) {
588
    a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; });
589
  });
590
591
  /* Settings pages: a form posts and the page comes back from the top.
592
     Remember which section's form was sent, and on the same page's next
593
     load scroll back to it, bringing the notice along. */
594
  document.addEventListener("submit", function (e) {
595
    var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null;
596
    if (!sec || !sec.id) return;
597
    try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ }
598
  });
599
  try {
600
    var back = sessionStorage.getItem("oxy-return");
601
    if (back && back.indexOf(location.pathname + "#") === 0) {
602
      sessionStorage.removeItem("oxy-return");
603
      var target = document.getElementById(back.split("#")[1]);
604
      if (target) {
605
        var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error");
606
        if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target);
607
        target.scrollIntoView({ block: "start" });
608
      }
609
    }
610
  } catch (err) { /* no storage: nothing to restore */ }
611
612
  /* Working (#142): on a repository whose store is remote, a land or a
613
     page can take seconds. A pressed button says what it is doing, with
614
     the CLI's spinner, and ignores a second press; a navigation that takes
615
     more than a moment spins the Oxygen mark's rings, in the header and
616
     beside the pointer. A quick page shows none of it. */
617
  var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
618
  var waitTimer = 0, spinTimer = 0, pointer = null, mark = null;
619
  document.addEventListener("pointermove", function (e) {
620
    pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null;
621
    if (mark && pointer) place();
622
  }, { passive: true });
623
  function place() {
624
    if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); }
625
    else mark.classList.add("corner");
626
  }
627
  function waiting() {
628
    clearTimeout(waitTimer);
629
    waitTimer = setTimeout(function () {
630
      if (!mark) {
631
        var logo = document.querySelector("header .brand svg");
632
        mark = document.createElement("div");
633
        mark.className = "oxy-wait-mark";
634
        mark.setAttribute("role", "status");
635
        mark.setAttribute("aria-label", "Working");
636
        if (logo) mark.appendChild(logo.cloneNode(true));
637
        document.body.appendChild(mark);
638
      }
639
      place();
640
      document.documentElement.classList.add("oxy-wait");
641
    }, 300);
642
  }
643
  function busy(button, form) {
644
    if (!button) return;
645
    var label = button.getAttribute("data-busy") || button.textContent.trim();
646
    button.dataset.idle = button.innerHTML;
647
    button.style.minWidth = button.offsetWidth + "px";
648
    button.textContent = label + " ";
649
    var glyph = document.createElement("span");
650
    glyph.className = "oxy-spin";
651
    glyph.setAttribute("aria-hidden", "true");
652
    glyph.textContent = SPIN[0];
653
    button.appendChild(glyph);
654
    button.setAttribute("aria-busy", "true");
655
    var i = 0;
656
    clearInterval(spinTimer);
657
    spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80);
658
    /* Disabled only after the form's data is gathered, so the button's
659
       own name and value still go with it. */
660
    setTimeout(function () { button.disabled = true; }, 0);
661
    form.dataset.sending = "1";
662
  }
663
  document.addEventListener("submit", function (e) {
664
    var form = e.target;
665
    if (form.dataset.sending) { e.preventDefault(); return; }
666
    if (e.defaultPrevented || (form.target && form.target !== "_self")) return;
667
    if ((form.getAttribute("method") || "get").toLowerCase() === "post") {
668
      busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form);
669
    }
670
    waiting();
671
  });
672
  document.addEventListener("click", function (e) {
673
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
674
    var a = e.target.closest ? e.target.closest("a[href]") : null;
675
    if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
676
    var url = new URL(a.href, location.href);
677
    if (url.origin !== location.origin) return;
678
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
679
    waiting();
680
  });
681
  /* Back to a page the browser kept: it is not working any more. */
682
  window.addEventListener("pageshow", function (e) {
683
    if (!e.persisted) return;
684
    clearTimeout(waitTimer);
685
    clearInterval(spinTimer);
686
    document.documentElement.classList.remove("oxy-wait");
687
    document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) {
688
      b.innerHTML = b.dataset.idle;
689
      b.disabled = false;
690
      b.removeAttribute("aria-busy");
691
      b.style.minWidth = "";
692
      if (b.form) delete b.form.dataset.sending;
693
    });
694
  });
695
696
  /* AI assistants (#109): a provider fills the name, endpoint and API;
697
     List models asks the endpoint what it offers and fills the model
698
     field's suggestions. Without this script, both are typed by hand. */
699
  document.querySelectorAll("[data-preset-select]").forEach(function (sel) {
700
    var form = sel.form;
701
    function apply() {
702
      var o = sel.options[sel.selectedIndex];
703
      if (!o || !o.value) return;
704
      var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]");
705
      name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api;
706
    }
707
    sel.addEventListener("change", apply);
708
    apply();
709
  });
710
  document.querySelectorAll("[data-list-models]").forEach(function (b) {
711
    b.addEventListener("click", function () {
712
      var form = b.form, input = document.getElementById(b.dataset.for);
713
      var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']");
714
      var body = new URLSearchParams();
715
      body.set("_csrf", b.dataset.csrf);
716
      ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); });
717
      if (b.dataset.id) body.set("id", b.dataset.id);
718
      if (note) note.textContent = "Asking the provider…";
719
      fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" })
720
        .then(function (r) { return r.json(); })
721
        .then(function (j) {
722
          if (j.models) {
723
            list.innerHTML = "";
724
            j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); });
725
            if (note) note.textContent = j.models.length + " models: pick one in the Model field.";
726
            input.focus();
727
          } else if (note) {
728
            note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name.";
729
          }
730
        })
731
        .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; });
732
    });
733
  });
734
})();