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