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