OXYGENHUB
# 306aec1839aa oxygenhub / assets / hub.js
52.4 KiB · JavaScript · last changed A phone as the second factor: Keystore keys approve sign-ins by the number the browser shows, and the sudo step asks a stale session again before sensitive changes (closes #356) 4 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
  /* The repository bar's menus (oxygenhub#254) are <details>, open without
13
     this file; here one open menu closes the others, and a click outside or
14
     Escape closes it. */
15
  var menus = document.querySelectorAll("[data-tab-menu]");
16
  function closeMenus(except) {
17
    menus.forEach(function (m) { if (m !== except) m.open = false; });
18
  }
19
  menus.forEach(function (m) {
20
    m.addEventListener("toggle", function () { if (m.open) closeMenus(m); });
21
  });
22
  if (menus.length) {
23
    document.addEventListener("click", function (ev) {
24
      if (!ev.target.closest || !ev.target.closest("[data-tab-menu]")) closeMenus(null);
25
    });
26
    document.addEventListener("keydown", function (ev) {
27
      if (ev.key !== "Escape") return;
28
      var open = document.querySelector("[data-tab-menu][open]");
29
      if (open) { open.open = false; open.querySelector("summary").focus(); }
30
    });
31
  }
32
33
  /* Off-the-shelf editors, loaded only where a page asks for them:
34
     CodeMirror 6 over textarea.code, EasyMDE over textarea.md. */
35
  function load(src, css) {
36
    return new Promise(function (ok) {
37
      if (css) { var l = document.createElement("link"); l.rel = "stylesheet"; l.href = css; document.head.appendChild(l); }
38
      var s = document.createElement("script"); s.src = src; s.onload = ok; document.head.appendChild(s);
39
    });
40
  }
41
  if (document.querySelector("textarea.code")) load("/static/vendor/editor.js");
42
  var mds = document.querySelectorAll("textarea.md");
43
  /* The Markdown editor in GitLab's manner (oxygenhub#193): a Preview tab,
44
     then icon buttons, fullscreen at the far end, and a footer that says
45
     Markdown is what it takes. Still a textarea underneath. */
46
  if (mds.length) load("/static/vendor/easymde.min.js", "/static/vendor/easymde.min.css").then(function () {
47
    var E = window.EasyMDE;
48
    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>'; }
49
    var I = {
50
      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"/>'),
51
      italic: svg('<path d="M6.5 2.5h6M3.5 13.5h6M10 2.5l-4 11"/>'),
52
      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"/>'),
53
      heading: svg('<path d="M3.5 2.5v11M12.5 2.5v11M3.5 8h9"/>'),
54
      quote: svg('<path d="M2.5 3.5h11M5.5 8h8M5.5 12.5h8M2.5 7v6"/>'),
55
      code: svg('<path d="M5.5 4.5 2 8l3.5 3.5M10.5 4.5 14 8l-3.5 3.5"/>'),
56
      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"/>'),
57
      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"/>'),
58
      ol: svg('<path d="M6.5 4h7M6.5 8h7M6.5 12h7M2 2.5h1v3M2 7.5h1.5L2 9.5h1.5M2 10.5h1.5v3H2M2 12h1.5"/>'),
59
      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"/>'),
60
      table: svg('<rect x="2" y="2.5" width="12" height="11" rx="1"/><path d="M2 6.5h12M2 10h12M6.5 6.5v7"/>'),
61
      full: svg('<path d="M2.5 6V2.5H6M10 2.5h3.5V6M13.5 10v3.5H10M6 13.5H2.5V10"/>')
62
    };
63
    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" : "") }; }
64
    function task(ed) {
65
      var cm = ed.codemirror, from = cm.getCursor("from"), to = cm.getCursor("to");
66
      for (var l = from.line; l <= to.line; l++) {
67
        var line = cm.getLine(l), m = line.match(/^(\s*)[-*] \[[ xX]\] /);
68
        if (m) cm.replaceRange(m[1], { line: l, ch: 0 }, { line: l, ch: m[0].length });
69
        else cm.replaceRange("- [ ] ", { line: l, ch: (line.match(/^\s*/) || [""])[0].length });
70
      }
71
      cm.focus();
72
    }
73
    var toolbar = [b("preview", E.togglePreview, "Preview", null, "Preview"), "|",
74
      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), "|",
75
      b("quote", E.toggleBlockquote, "Quote", I.quote), b("code", E.toggleCodeBlock, "Code", I.code), b("link", E.drawLink, "Link (Ctrl+K)", I.link), "|",
76
      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), "|",
77
      b("fullscreen", E.toggleFullScreen, "Fullscreen", I.full)];
78
    mds.forEach(function (t) {
79
      t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar,
80
        placeholder: t.getAttribute("placeholder") || "Write in Markdown…", minHeight: "120px", sideBySideFullscreen: false });
81
      var c = t._mde.element.parentNode.querySelector(".EasyMDEContainer") || t.nextElementSibling;
82
      if (c && c.classList.contains("EasyMDEContainer")) {
83
        var f = document.createElement("div");
84
        f.className = "mde-foot small mute";
85
        f.innerHTML = '<span>Markdown supported</span><span class="mde-md" title="Markdown" aria-hidden="true">M↓</span>';
86
        c.appendChild(f);
87
      }
88
    });
89
    /* An editor shown later (a :target edit form) lays itself out again. */
90
    window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); });
91
  });
92
93
  /* Mermaid diagrams (```mermaid blocks in Markdown): drawn by the hub's own
94
     copy of Mermaid, loaded only on pages that have one, in strict mode (no
95
     scripts or HTML from the diagram), in the page's theme. A diagram that
96
     does not parse stays as its source, with Mermaid's reason under it. */
97
  var diagrams = document.querySelectorAll("pre > code.language-mermaid");
98
  if (diagrams.length) load("/static/vendor/mermaid.min.js").then(function () {
99
    var m = window.mermaid, dark = document.documentElement.dataset.theme === "dark";
100
    m.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: dark ? "dark" : "default" });
101
    diagrams.forEach(function (code, i) {
102
      var pre = code.parentNode, source = code.textContent;
103
      m.render("mermaid-" + i, source).then(function (r) {
104
        var fig = document.createElement("figure");
105
        fig.className = "mermaid-diagram";
106
        fig.innerHTML = r.svg;
107
        fig.setAttribute("aria-label", "Diagram");
108
        pre.replaceWith(fig);
109
      }).catch(function (e) {
110
        var note = document.createElement("p");
111
        note.className = "small warn";
112
        note.textContent = "This diagram could not be drawn: " + ((e && e.message) || e);
113
        pre.after(note);
114
      });
115
    });
116
  });
117
118
  /* Report frames (#127): a report's page says how tall it is, and the
119
     frame grows to fit; the frame is told the theme, at start and on a
120
     change. Only messages from the frame itself are taken. */
121
  var frames = document.querySelectorAll("iframe.rep-frame");
122
  var themeToFrames = function () {
123
    frames.forEach(function (f) { if (f.contentWindow) f.contentWindow.postMessage({ oxygenhubTheme: document.documentElement.dataset.theme || "dark" }, "*"); });
124
  };
125
  if (frames.length) window.addEventListener("message", function (e) {
126
    frames.forEach(function (f) {
127
      if (e.source !== f.contentWindow) return;
128
      var r = e.data && e.data.oxygenhubReport;
129
      if (!r) return;
130
      if (r.ready) themeToFrames();
131
      if (typeof r.height === "number" && r.height > 0) {
132
        f.style.height = Math.min(Math.max(r.height, 120), 40000) + "px";
133
        f.classList.add("sized");
134
      }
135
    });
136
  });
137
138
  /* Report frames ask for runs (#127, RP4): report.run() is the run on
139
     screen, report.history(dataset) that file of data/ across the runs,
140
     oldest first. Answered only to the frame itself, from the files of
141
     this report's own runs. */
142
  if (frames.length) window.addEventListener("message", function (e) {
143
    frames.forEach(function (f) {
144
      if (e.source !== f.contentWindow) return;
145
      var ask = e.data && e.data.oxygenhubAsk;
146
      if (!ask) return;
147
      var runs = []; try { runs = JSON.parse(f.dataset.reportRuns || "[]"); } catch (_) {}
148
      var current = Number(f.dataset.reportCurrent);
149
      var reply = function (body) { f.contentWindow.postMessage({ oxygenhubAnswer: { n: ask.n, value: body.value, error: body.error } }, "*"); };
150
      var meta = function (r) { return { run: r.run, bookmark: r.bookmark, created_ms: r.created_ms, summary: r.summary, current: r.id === current }; };
151
      if (ask.what === "run") {
152
        var r = runs.filter(function (x) { return x.id === current; })[0];
153
        reply({ value: r ? meta(r) : null });
154
      } else if (ask.what === "history" && typeof ask.dataset === "string" && /^[A-Za-z0-9._+-]+(\/[A-Za-z0-9._+-]+)*$/.test(ask.dataset)) {
155
        Promise.all(runs.map(function (r) {
156
          return fetch(r.files + "/data/" + ask.dataset).then(function (res) { return res.ok ? res.text() : null; }).catch(function () { return null; })
157
            .then(function (text) { var m = meta(r); m.text = text; return m; });
158
        })).then(function (all) { reply({ value: all.filter(function (x) { return x.text !== null; }) }); });
159
      } else {
160
        reply({ error: "ask for run, or history with a file name under data/" });
161
      }
162
    });
163
  });
164
165
  /* The report timeline (#127, RP4): arrow keys step through the runs,
166
     Play steps on by itself at the chosen speed, carried in the URL. */
167
  var tl = document.querySelector("[data-timeline]");
168
  if (tl) {
169
    var stops = Array.prototype.slice.call(tl.querySelectorAll("[data-tl-stop]"));
170
    var at = stops.findIndex(function (a) { return a.classList.contains("on"); });
171
    var go = function (i, play) {
172
      if (i < 0 || i >= stops.length) return;
173
      var u = new URL(stops[i].href, location.href);
174
      if (play) u.searchParams.set("play", play); else u.searchParams.delete("play");
175
      location.href = u.toString();
176
    };
177
    document.addEventListener("keydown", function (e) {
178
      if (e.target.closest("input, select, textarea, [contenteditable]")) return;
179
      if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1, tl.dataset.play); }
180
      if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1, tl.dataset.play); }
181
    });
182
    var speed = tl.querySelector("[data-tl-speed]"), button = tl.querySelector("[data-tl-play]");
183
    if (button) button.addEventListener("click", function () {
184
      if (tl.dataset.play) { go(at, null); return; }
185
      go(at >= stops.length - 1 ? 0 : at + 1, speed ? speed.value : "2");
186
    });
187
    if (speed) speed.addEventListener("change", function () { if (tl.dataset.play) go(at, speed.value); });
188
    if (tl.dataset.play) {
189
      var next = at + 1;
190
      if (next < stops.length) setTimeout(function () { go(next, tl.dataset.play); }, Number(tl.dataset.play) * 1000);
191
      else setTimeout(function () { go(at, null); }, Number(tl.dataset.play) * 1000);
192
    }
193
  }
194
195
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
196
  var toggle = document.querySelector("[data-theme-toggle]");
197
  if (toggle) {
198
    var root = document.documentElement, order = ["light", "dark", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" };
199
    var show = function () {
200
      var p = root.dataset.themePref || "light";
201
      toggle.title = "Theme: " + p + " (click to change)";
202
      toggle.setAttribute("aria-label", toggle.title);
203
      toggle.firstElementChild.textContent = glyph[p];
204
    };
205
    show();
206
    toggle.addEventListener("click", function () {
207
      var p = order[(order.indexOf(root.dataset.themePref || "light") + 1) % order.length];
208
      document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax";
209
      root.dataset.themePref = p;
210
      root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
211
      show();
212
      themeToFrames();
213
    });
214
  }
215
216
  /* Diffs: unified or split (remembered in the diff cookie), collapse all. */
217
  var diffs = document.querySelectorAll("table.code.diff");
218
  if (diffs.length) {
219
    var pane = function (tr, col, side) {
220
      var n = document.createElement("td"), t = document.createElement("td");
221
      n.className = "n"; t.className = "t" + (side === "right" ? " mid" : "");
222
      if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; }
223
      var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx";
224
      if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; }
225
      n.textContent = tr.cells[col].textContent;
226
      t.innerHTML = tr.cells[3].innerHTML;
227
      // Line comments belong to the projected (right) side only.
228
      if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); });
229
      return [n, t];
230
    };
231
    var split = function (table) {
232
      var s = document.createElement("table");
233
      s.className = "code diff split";
234
      s.innerHTML = '<colgroup><col class="n"><col><col class="n"><col></colgroup>';
235
      var rows = [].slice.call(table.rows), i = 0;
236
      var add = function (l, r) {
237
        var tr = s.insertRow();
238
        pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); });
239
      };
240
      while (i < rows.length) {
241
        var r = rows[i];
242
        if (r.classList.contains("del") || r.classList.contains("add")) {
243
          var dels = [], adds = [];
244
          while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]);
245
          while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]);
246
          for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]);
247
          continue;
248
        }
249
        if (r.classList.contains("gap") || r.querySelector("td.thread")) {
250
          var tr = s.insertRow(), td = tr.insertCell();
251
          tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className;
252
          td.innerHTML = r.cells[r.cells.length - 1].innerHTML;
253
        } else {
254
          add(r, r);
255
        }
256
        i++;
257
      }
258
      return s;
259
    };
260
    var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified";
261
    var apply = function (mode) {
262
      diffs.forEach(function (t) {
263
        if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); }
264
        t.hidden = mode === "split";
265
        if (t._split) t._split.hidden = mode !== "split";
266
      });
267
      document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); });
268
    };
269
    document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; });
270
    document.querySelectorAll("[data-diff-mode]").forEach(function (a) {
271
      a.addEventListener("click", function (ev) {
272
        ev.preventDefault();
273
        var mode = a.getAttribute("data-diff-mode");
274
        document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax";
275
        apply(mode);
276
      });
277
    });
278
    document.querySelectorAll("[data-diff-collapse]").forEach(function (a) {
279
      a.addEventListener("click", function (ev) {
280
        ev.preventDefault();
281
        var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; });
282
        files.forEach(function (d) { d.open = !anyOpen; });
283
        a.textContent = anyOpen ? "expand all" : "collapse all";
284
      });
285
    });
286
    apply(cookieMode);
287
  }
288
289
  /* Settings pages: mark the section in view in the list of sections. */
290
  var navLinks = document.querySelectorAll(".section-nav a[href^='#']");
291
  if (navLinks.length && "IntersectionObserver" in window) {
292
    var byId = {};
293
    navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
294
    var io = new IntersectionObserver(function (entries) {
295
      entries.forEach(function (e) {
296
        if (e.isIntersecting && byId[e.target.id]) {
297
          navLinks.forEach(function (a) { a.classList.remove("on"); });
298
          byId[e.target.id].classList.add("on");
299
        }
300
      });
301
    }, { rootMargin: "0px 0px -70% 0px" });
302
    Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); });
303
  }
304
305
  /* Copy buttons. A glyph button (#338) says "Copy to clipboard", then
306
     shows a tick and "Copied!" for two seconds; a worded one says so. */
307
  document.querySelectorAll("[data-copy]").forEach(function (b) {
308
    var glyph = b.classList.contains("copy-btn"), timer;
309
    if (glyph) { b.setAttribute("data-tip", "Copy to clipboard"); b.title = ""; }
310
    b.addEventListener("click", function () {
311
      navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
312
        clearTimeout(timer);
313
        if (glyph) {
314
          b.classList.add("copied"); b.setAttribute("data-tip", "Copied!"); b.setAttribute("aria-label", "Copied!");
315
          timer = setTimeout(function () { b.classList.remove("copied"); b.setAttribute("data-tip", "Copy to clipboard"); b.setAttribute("aria-label", "Copy to clipboard"); }, 2000);
316
        } else {
317
          var t = b.getAttribute("data-label") || b.textContent; b.setAttribute("data-label", t);
318
          b.textContent = "copied"; timer = setTimeout(function () { b.textContent = t; }, 1200);
319
        }
320
      });
321
    });
322
  });
323
324
  /* Tree rows: the last change per entry, fetched after the page paints. */
325
  var tree = document.querySelector("table.tree[data-last]");
326
  if (tree) {
327
    var paths = [].map.call(tree.querySelectorAll("tr[data-path]"), function (r) { return r.getAttribute("data-path"); });
328
    if (paths.length) {
329
      fetch(tree.getAttribute("data-last") + "?paths=" + encodeURIComponent(paths.join(",")), { credentials: "same-origin" })
330
        .then(function (r) { return r.json(); })
331
        .then(function (rows) {
332
          rows.forEach(function (c) {
333
            var tr = tree.querySelector('tr[data-path="' + CSS.escape(c.path) + '"]');
334
            if (!tr) return;
335
            var a = tr.querySelector("td.last a"), t = tr.querySelector("td.age");
336
            if (a) { a.textContent = c.title; a.href = tree.getAttribute("data-change") + c.hash; }
337
            if (t) { t.textContent = c.ago; t.title = c.when; }
338
          });
339
        }).catch(function () {});
340
    }
341
  }
342
343
  /* Line anchors: #L12 or #L12-L20 highlight a range and clicking a number selects. */
344
  function mark() {
345
    var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/);
346
    document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); });
347
    if (!m) { showBar(0, 0); return; }
348
    var a = +m[1], b = +(m[2] || m[1]);
349
    for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
350
      var row = document.getElementById("L" + i);
351
      if (row) row.classList.add("hl");
352
    }
353
    var first = document.getElementById("L" + Math.min(a, b));
354
    if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; }
355
    showBar(Math.min(a, b), Math.max(a, b));
356
  }
357
  /* The bar for a selected range: copy its text, or a permalink to it. */
358
  var bar = document.querySelector(".linebar");
359
  function showBar(a, b) {
360
    if (!bar) return;
361
    bar.hidden = !a;
362
    if (!a) return;
363
    bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b;
364
    bar.setAttribute("data-range", a + "-" + b);
365
  }
366
  function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); }
367
  if (bar) {
368
    bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) {
369
      var r = bar.getAttribute("data-range").split("-"), out = [];
370
      for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); }
371
      navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); });
372
    });
373
    var pl = bar.querySelector("[data-copy-permalink]");
374
    if (pl) pl.addEventListener("click", function () {
375
      navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); });
376
    });
377
  }
378
  function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; }
379
  window.addEventListener("hashchange", mark);
380
  mark();
381
  document.querySelectorAll("table.code td.n").forEach(function (td) {
382
    td.addEventListener("click", function (e) {
383
      var n = td.parentNode.id;
384
      if (e.shiftKey && location.hash.match(/^#L(\d+)/)) location.hash = location.hash.replace(/^#(L\d+).*/, "$1-" + n);
385
      else location.hash = n;
386
    });
387
  });
388
389
  /* Flow run page: statuses and new log lines while the run is live. */
390
  var liveUrl = document.body.getAttribute("data-flow-live");
391
  if (liveUrl) {
392
    var tick = function () {
393
      var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) {
394
        return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq");
395
      }).join(",");
396
      fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } })
397
        .then(function (r) { return r.json(); })
398
        .then(function (s) {
399
          s.jobs.forEach(function (j) {
400
            var cls = "st-" + j.status;
401
            var node = document.querySelector("[data-job-node='" + j.id + "']");
402
            if (node) {
403
              node.setAttribute("class", "node " + cls);
404
              node.querySelector("[data-glyph]").textContent = j.glyph;
405
              node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration;
406
            }
407
            var d = document.querySelector("details[data-job='" + j.id + "']");
408
            if (!d) return;
409
            d.className = "panel flow-job " + cls;
410
            var g = d.querySelector("summary [data-glyph]");
411
            g.className = "flow-glyph " + cls; g.textContent = j.glyph;
412
            d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration;
413
            if (j.status === "running" || j.status === "failed") d.open = true;
414
            // Once it has left the queue, it no longer waits (#282).
415
            if (j.status !== "queued") [].forEach.call(d.querySelectorAll("[data-queued-note]"), function (p) { p.remove(); });
416
            var log = d.querySelector("[data-log]");
417
            var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
418
            j.lines.forEach(function (l) {
419
              var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
420
              var t = document.createElement("span"); t.className = "t"; t.textContent = (l.t / 1000).toFixed(1) + "s";
421
              ln.appendChild(t); ln.appendChild(document.createTextNode(l.text));
422
              log.appendChild(ln); log.appendChild(document.createTextNode("\n"));
423
              log.setAttribute("data-seq", l.seq);
424
            });
425
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
426
          });
427
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
428
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
429
        })
430
        .catch(function () { setTimeout(tick, 5000); });
431
    };
432
    setTimeout(tick, 1500);
433
  }
434
435
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
436
  var pending = null;
437
  document.addEventListener("keydown", function (e) {
438
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
439
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
440
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
441
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
442
    var repo = document.body.getAttribute("data-repo");
443
    if (pending === "g" && repo) {
444
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
445
      if (to !== undefined) location.href = repo + to;
446
      pending = null; return;
447
    }
448
    pending = e.key === "g" ? "g" : null;
449
  });
450
451
  // Live conflict resolution (hub spec §6.10): who else is resolving this
452
  // conflict, their choices region by region (the last one wins), and the
453
  // resolution when someone commits it.
454
  var live = document.querySelector("[data-resolve-live]");
455
  if (live && window.EventSource && window.fetch) {
456
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
457
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
458
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
459
    var applying = false;
460
    var fieldsOf = function (region) {
461
      return region === "keep"
462
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
463
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
464
    };
465
    var apply = function (region, c) {
466
      var f = fieldsOf(region);
467
      applying = true;
468
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
469
      if (f.text && c.choice === "text") f.text.value = c.text;
470
      applying = false;
471
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
472
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
473
    };
474
    var send = function (region, choice, text) {
475
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
476
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
477
    };
478
    var timers = {};
479
    live.addEventListener("change", function (ev) {
480
      var t = ev.target;
481
      if (applying || !t.name || t.type !== "radio") return;
482
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
483
      var ta = live.querySelector('textarea[name="t' + region + '"]');
484
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
485
    });
486
    live.addEventListener("input", function (ev) {
487
      var t = ev.target;
488
      if (applying || t.tagName !== "TEXTAREA") return;
489
      var region = t.name.slice(1);
490
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
491
      clearTimeout(timers[region]);
492
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
493
    });
494
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
495
    var showPresent = function (names) {
496
      present.hidden = names.length < 2;
497
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
498
    };
499
    es.addEventListener("snapshot", function (e) {
500
      var d = JSON.parse(e.data);
501
      showPresent(d.present);
502
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
503
      if (d.keep) apply("keep", d.keep);
504
    });
505
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
506
    es.addEventListener("choice", function (e) {
507
      var d = JSON.parse(e.data);
508
      if (d.client !== client) apply(d.region, d.choice);
509
    });
510
    es.addEventListener("landed", function (e) {
511
      var d = JSON.parse(e.data);
512
      es.close();
513
      landed.hidden = false;
514
      landed.textContent = "";
515
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
516
      var a = document.createElement("a");
517
      a.href = d.url;
518
      a.textContent = "see the change";
519
      landed.appendChild(a);
520
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
521
    });
522
  }
523
524
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
525
  // another to plan a squash or a rebase. The hub only writes the plan;
526
  // the author's client carries it out and signs it.
527
  var plan = document.querySelector("[data-plan]");
528
  if (plan && window.fetch) {
529
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
530
    plan.querySelectorAll("[data-change]").forEach(function (a) {
531
      a.style.cursor = "grab";
532
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
533
      a.addEventListener("pointermove", function () { if (from) moved = true; });
534
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
535
    });
536
    document.addEventListener("pointerup", function (ev) {
537
      var start = from;
538
      from = null;
539
      if (!start) return;
540
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
541
      if (!onto || onto.getAttribute("data-change") === start) return;
542
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
543
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
544
        .then(function (r) { return r.json(); })
545
        .then(function (d) {
546
          panel.hidden = false;
547
          panel.textContent = "";
548
          if (d.error) { panel.textContent = d.error; return; }
549
          d.options.forEach(function (o) {
550
            var p = document.createElement("p");
551
            p.appendChild(document.createTextNode(o.summary + " "));
552
            var a = document.createElement("a");
553
            a.href = o.vscode;
554
            a.textContent = "Apply in VS Code";
555
            p.appendChild(a);
556
            var code = document.createElement("pre");
557
            code.className = "snippet";
558
            code.textContent = o.command;
559
            panel.appendChild(p);
560
            panel.appendChild(code);
561
          });
562
        });
563
    });
564
  }
565
566
  /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click
567
     outside an open menu closes it. */
568
  document.querySelectorAll("[data-menu-filter]").forEach(function (input) {
569
    var items = input.closest(".menu-pop").querySelectorAll(".menu-item");
570
    input.addEventListener("input", function () {
571
      var q = input.value.trim().toLowerCase();
572
      items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; });
573
    });
574
  });
575
  document.querySelectorAll("details.menu").forEach(function (d) {
576
    d.addEventListener("toggle", function () {
577
      var f = d.querySelector("[data-menu-filter]");
578
      if (d.open && f) f.focus();
579
    });
580
  });
581
  document.addEventListener("click", function (ev) {
582
    document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; });
583
  });
584
585
  /* ··· menus: copy a link to a part of the page, or some text. */
586
  function copied(btn, text) {
587
    if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () {
588
      var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200);
589
    });
590
    var d = btn.closest("details"); if (d) d.open = false;
591
  }
592
  document.querySelectorAll("[data-copy-link]").forEach(function (b) {
593
    b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); });
594
  });
595
  document.querySelectorAll("[data-copy-text]").forEach(function (b) {
596
    b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); });
597
  });
598
  document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) {
599
    a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; });
600
  });
601
602
  /* Settings pages: a form posts and the page comes back from the top.
603
     Remember which section's form was sent, and on the same page's next
604
     load scroll back to it, bringing the notice along. */
605
  document.addEventListener("submit", function (e) {
606
    var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null;
607
    if (!sec || !sec.id) return;
608
    try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ }
609
  });
610
  try {
611
    var back = sessionStorage.getItem("oxy-return");
612
    if (back && back.indexOf(location.pathname + "#") === 0) {
613
      sessionStorage.removeItem("oxy-return");
614
      var target = document.getElementById(back.split("#")[1]);
615
      if (target) {
616
        var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error");
617
        if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target);
618
        target.scrollIntoView({ block: "start" });
619
      }
620
    }
621
  } catch (err) { /* no storage: nothing to restore */ }
622
623
  /* Working (#142): on a repository whose store is remote, a land or a
624
     page can take seconds. A pressed button says what it is doing, with
625
     the CLI's spinner, and ignores a second press; a navigation that takes
626
     more than a moment spins the Oxygen mark's rings, in the header and
627
     beside the pointer. A quick page shows none of it. */
628
  var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
629
  var waitTimer = 0, spinTimer = 0, pointer = null, mark = null;
630
  document.addEventListener("pointermove", function (e) {
631
    pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null;
632
    if (mark && pointer) place();
633
  }, { passive: true });
634
  function place() {
635
    if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); }
636
    else mark.classList.add("corner");
637
  }
638
  function waiting() {
639
    clearTimeout(waitTimer);
640
    waitTimer = setTimeout(function () {
641
      if (!mark) {
642
        var logo = document.querySelector("header .brand svg");
643
        mark = document.createElement("div");
644
        mark.className = "oxy-wait-mark";
645
        mark.setAttribute("role", "status");
646
        mark.setAttribute("aria-label", "Working");
647
        if (logo) mark.appendChild(logo.cloneNode(true));
648
        document.body.appendChild(mark);
649
      }
650
      place();
651
      document.documentElement.classList.add("oxy-wait");
652
    }, 300);
653
  }
654
  function busy(button, form) {
655
    if (!button) return;
656
    var label = button.getAttribute("data-busy") || button.textContent.trim();
657
    button.dataset.idle = button.innerHTML;
658
    button.style.minWidth = button.offsetWidth + "px";
659
    button.textContent = label + " ";
660
    var glyph = document.createElement("span");
661
    glyph.className = "oxy-spin";
662
    glyph.setAttribute("aria-hidden", "true");
663
    glyph.textContent = SPIN[0];
664
    button.appendChild(glyph);
665
    button.setAttribute("aria-busy", "true");
666
    var i = 0;
667
    clearInterval(spinTimer);
668
    spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80);
669
    /* Disabled only after the form's data is gathered, so the button's
670
       own name and value still go with it. */
671
    setTimeout(function () { button.disabled = true; }, 0);
672
    form.dataset.sending = "1";
673
  }
674
  document.addEventListener("submit", function (e) {
675
    var form = e.target;
676
    if (form.dataset.sending) { e.preventDefault(); return; }
677
    if (e.defaultPrevented || (form.target && form.target !== "_self")) return;
678
    if ((form.getAttribute("method") || "get").toLowerCase() === "post") {
679
      busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form);
680
    }
681
    waiting();
682
  });
683
  document.addEventListener("click", function (e) {
684
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
685
    var a = e.target.closest ? e.target.closest("a[href]") : null;
686
    if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
687
    var url = new URL(a.href, location.href);
688
    if (url.origin !== location.origin) return;
689
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
690
    waiting();
691
  });
692
  /* Back to a page the browser kept: it is not working any more. */
693
  window.addEventListener("pageshow", function (e) {
694
    if (!e.persisted) return;
695
    clearTimeout(waitTimer);
696
    clearInterval(spinTimer);
697
    document.documentElement.classList.remove("oxy-wait");
698
    document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) {
699
      b.innerHTML = b.dataset.idle;
700
      b.disabled = false;
701
      b.removeAttribute("aria-busy");
702
      b.style.minWidth = "";
703
      if (b.form) delete b.form.dataset.sending;
704
    });
705
  });
706
707
  /* AI assistants (#109): a provider fills the name, endpoint and API;
708
     List models asks the endpoint what it offers and fills the model
709
     field's suggestions. Without this script, both are typed by hand. */
710
  document.querySelectorAll("[data-preset-select]").forEach(function (sel) {
711
    var form = sel.form;
712
    function apply() {
713
      var o = sel.options[sel.selectedIndex];
714
      if (!o || !o.value) return;
715
      var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]");
716
      name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api;
717
    }
718
    sel.addEventListener("change", apply);
719
    apply();
720
  });
721
  document.querySelectorAll("[data-list-models]").forEach(function (b) {
722
    b.addEventListener("click", function () {
723
      var form = b.form, input = document.getElementById(b.dataset.for);
724
      var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']");
725
      var body = new URLSearchParams();
726
      body.set("_csrf", b.dataset.csrf);
727
      ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); });
728
      if (b.dataset.id) body.set("id", b.dataset.id);
729
      if (note) note.textContent = "Asking the provider…";
730
      fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" })
731
        .then(function (r) { return r.json(); })
732
        .then(function (j) {
733
          if (j.models) {
734
            list.innerHTML = "";
735
            j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); });
736
            if (note) note.textContent = j.models.length + " models: pick one in the Model field.";
737
            input.focus();
738
          } else if (note) {
739
            note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name.";
740
          }
741
        })
742
        .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; });
743
    });
744
  });
745
})();
746
747
// Live pages (docs/live.md, oxygenhub#204): elements marked
748
// data-live="<topic>" data-fragment="<name>" are fetched again from this
749
// page's own route (?fragment=<name>) when the hub says their topic
750
// changed, and swapped in. Someone typing inside one is not interrupted:
751
// it is refreshed when they leave it. Without JavaScript or a stream the
752
// page is as it was; a reload shows everything.
753
(function () {
754
  var marked = document.querySelectorAll("[data-live][data-fragment]");
755
  if (!marked.length || !window.EventSource || !window.fetch) return;
756
  var topics = {};
757
  // data-live may name several topics, separated by spaces.
758
  var topicsOf = function (el) { return el.getAttribute("data-live").split(/\s+/).filter(Boolean); };
759
  Array.prototype.forEach.call(marked, function (el) { topicsOf(el).forEach(function (t) { topics[t] = true; }); });
760
  var busy = {};
761
  var typingIn = function (el) {
762
    var a = document.activeElement;
763
    return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value;
764
  };
765
  var refresh = function (el) {
766
    var name = el.getAttribute("data-fragment");
767
    if (busy[name]) return;
768
    if (typingIn(el)) {
769
      el.addEventListener("focusout", function again() {
770
        el.removeEventListener("focusout", again);
771
        setTimeout(function () { refresh(el); }, 0);
772
      });
773
      return;
774
    }
775
    busy[name] = true;
776
    var url = new URL(location.href);
777
    url.searchParams.set("fragment", name);
778
    fetch(url.toString(), { credentials: "same-origin", headers: { "X-Fragment": name } })
779
      .then(function (r) { return r.ok ? r.text() : null; })
780
      .then(function (html) {
781
        busy[name] = false;
782
        if (html !== null && !typingIn(el)) el.innerHTML = html;
783
      })
784
      .catch(function () { busy[name] = false; });
785
  };
786
  var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(",")));
787
  es.addEventListener("change", function (e) {
788
    var d;
789
    try { d = JSON.parse(e.data); } catch (err) { return; }
790
    Array.prototype.forEach.call(marked, function (el) {
791
      if (topicsOf(el).indexOf(d.topic) >= 0) refresh(el);
792
    });
793
  });
794
  // Missed more than the hub keeps: everything marked is fetched again.
795
  es.addEventListener("reload", function () {
796
    Array.prototype.forEach.call(marked, refresh);
797
  });
798
})();
799
800
/* Insights (oxygenhub#257): a part marked data-refresh="SECONDS"
801
   data-fragment="NAME" is fetched again on that interval while the page is
802
   visible, and swapped in place; never while someone is using a control in
803
   it. Without this file the page is a snapshot; a reload updates it. */
804
(function () {
805
  "use strict";
806
  var parts = document.querySelectorAll("[data-refresh][data-fragment]");
807
  if (!parts.length || !window.fetch || !window.DOMParser) return;
808
  var every = Math.max(10, parseInt(parts[0].getAttribute("data-refresh"), 10) || 30) * 1000;
809
  var busy = false;
810
  var inUse = function (el) {
811
    var a = document.activeElement;
812
    return a && el.contains(a) && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName);
813
  };
814
  var tick = function () {
815
    if (busy || document.hidden) return;
816
    busy = true;
817
    fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } })
818
      .then(function (r) { return r.ok ? r.text() : null; })
819
      .then(function (html) {
820
        busy = false;
821
        if (html === null) return;
822
        var doc = new DOMParser().parseFromString(html, "text/html");
823
        Array.prototype.forEach.call(parts, function (el) {
824
          if (inUse(el)) return;
825
          var fresh = doc.querySelector('[data-fragment="' + el.getAttribute("data-fragment") + '"]');
826
          if (fresh) el.innerHTML = fresh.innerHTML;
827
        });
828
      })
829
      .catch(function () { busy = false; });
830
  };
831
  setInterval(tick, every);
832
  document.addEventListener("visibilitychange", function () { if (!document.hidden) tick(); });
833
})();
834
835
/* Drafting with an assistant (oxygenhub#248): the draft is streamed into
836
   the description as it is written, so a long one is neither cut off by a
837
   proxy nor waited for in silence. Without this file the button posts the
838
   form and the page comes back with the draft. */
839
(function () {
840
  "use strict";
841
  if (!window.fetch || !window.TextDecoder || !window.URLSearchParams) return;
842
  document.querySelectorAll("button.agent-btn[data-stream]").forEach(function (btn) {
843
    btn.addEventListener("click", function (e) {
844
      var form = btn.form, area = form && form.querySelector('textarea[name="body"]');
845
      if (!form || !area) return;
846
      e.preventDefault();
847
      function get() { return area._mde ? area._mde.value() : area.value; }
848
      function show(t) { if (area._mde) area._mde.value(t); else area.value = t; }
849
      // A new proposal's draft begins with its title line (#316): kept out
850
      // of the description, and put in the title unless the person wrote one.
851
      var titleField = form.querySelector('input[name="title"]');
852
      function untitled(t) { return t.replace(/^\s*title:[^\n]*(\n\s*|$)/i, ""); }
853
      var before = get(), text = "";
854
      var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area;
855
      var note = form.querySelector(".draft-note");
856
      if (!note) { note = document.createElement("p"); note.className = "draft-note small mute"; box.parentNode.insertBefore(note, box.nextSibling); }
857
      note.textContent = btn.value + " is drafting the description…";
858
      var buttons = form.querySelectorAll("button.agent-btn");
859
      buttons.forEach(function (b) { b.disabled = true; });
860
      var data = new URLSearchParams(new FormData(form));
861
      data.set("assistant", btn.value);
862
      var finished = false;
863
      function end(message, keep) { finished = true; if (!keep) show(before); note.textContent = message; }
864
      fetch(btn.dataset.stream, { method: "POST", body: data, credentials: "same-origin" })
865
        .then(function (r) {
866
          if (!r.ok || !r.body) throw new Error("the hub answered " + r.status);
867
          var reader = r.body.getReader(), dec = new TextDecoder(), buf = "";
868
          function pump() {
869
            return reader.read().then(function (res) {
870
              if (res.done) { if (!finished) end(btn.value + " stopped before finishing.", text !== ""); return; }
871
              buf += dec.decode(res.value, { stream: true });
872
              var i;
873
              while ((i = buf.indexOf("\n\n")) >= 0) {
874
                var block = buf.slice(0, i), kind = "message", payload = "";
875
                buf = buf.slice(i + 2);
876
                block.split("\n").forEach(function (l) {
877
                  if (l.indexOf("event:") === 0) kind = l.slice(6).trim();
878
                  else if (l.indexOf("data:") === 0) payload += l.slice(5).trim();
879
                });
880
                if (!payload) continue;
881
                var v;
882
                try { v = JSON.parse(payload); } catch (_) { continue; }
883
                if (kind === "part") { text += v; show(untitled(text)); }
884
                else if (kind === "title") {
885
                  if (titleField && (!titleField.value.trim() || titleField.value === titleField.defaultValue)) titleField.value = v;
886
                }
887
                else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; }
888
                else if (kind === "error") { end(v, false); return; }
889
              }
890
              return pump();
891
            });
892
          }
893
          return pump();
894
        })
895
        .catch(function (err) { end(btn.value + " did not answer: " + err.message + ".", false); })
896
        .then(function () { buttons.forEach(function (b) { b.disabled = false; }); });
897
    });
898
  });
899
  /* Declared in data- attributes, so pages carry no inline handlers and
900
     the Content-Security-Policy can be enforced (oxygenhub#227):
901
     data-confirm asks before a click or a form's submit goes ahead;
902
     data-autosubmit sends a select's form when it changes; data-select
903
     selects a field's text when it is focused or clicked. */
904
  document.addEventListener("click", function (e) {
905
    var el = e.target.closest && e.target.closest("[data-confirm]");
906
    if (el && el.tagName !== "FORM" && !window.confirm(el.getAttribute("data-confirm"))) {
907
      e.preventDefault();
908
      e.stopImmediatePropagation();
909
    }
910
  }, true);
911
  document.addEventListener("submit", function (e) {
912
    var f = e.target;
913
    if (f.hasAttribute && f.hasAttribute("data-confirm") && !window.confirm(f.getAttribute("data-confirm"))) e.preventDefault();
914
  }, true);
915
  document.addEventListener("change", function (e) {
916
    var el = e.target;
917
    if (el.hasAttribute && el.hasAttribute("data-autosubmit") && el.form) el.form.submit();
918
  });
919
  function selectAll(e) {
920
    var el = e.target;
921
    if (el.hasAttribute && el.hasAttribute("data-select") && el.select) el.select();
922
  }
923
  document.addEventListener("focusin", selectAll);
924
  document.addEventListener("click", selectAll);
925
926
  /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */
927
  (function () {
928
    var sel = document.querySelector("[data-mail-service]");
929
    if (!sel) return;
930
    var show = function () {
931
      document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) {
932
        el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0;
933
      });
934
    };
935
    sel.addEventListener("change", show);
936
    show();
937
  })();
938
939
  /* Drops (oxygenhub#279): add and remove file rows on the drop form.
940
     Without this file the form has its rows and one empty one; more come
941
     by saving and editing again. */
942
  (function () {
943
    var list = document.querySelector("[data-drop-files]");
944
    var add = document.querySelector("[data-drop-add]");
945
    if (!list || !add) return;
946
    function wire(row) {
947
      var rm = row.querySelector("[data-drop-remove]");
948
      if (!rm) return;
949
      rm.hidden = false;
950
      rm.addEventListener("click", function () {
951
        if (list.querySelectorAll("[data-drop-file]").length > 1) row.remove();
952
        else row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; });
953
      });
954
    }
955
    list.querySelectorAll("[data-drop-file]").forEach(wire);
956
    add.hidden = false;
957
    add.addEventListener("click", function () {
958
      var rows = list.querySelectorAll("[data-drop-file]");
959
      var row = rows[rows.length - 1].cloneNode(true);
960
      row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; f.textContent = ""; });
961
      list.appendChild(row);
962
      wire(row);
963
      row.querySelector("input").focus();
964
    });
965
  })();
966
967
  /* A repository's settings were one page with a section per #anchor;
968
     each is a page of its own now (oxygenhub#278). A kept link such as
969
     /settings#flow-priority opens the page that holds that section, when
970
     the viewer has it (its link is in the sidebar). */
971
  (function () {
972
    var nav = document.querySelector("[data-settings-anchors]");
973
    var hash = location.hash.slice(1);
974
    if (!nav || !/^[a-z-]+$/.test(hash)) return;
975
    var moved = { "history-scan": "push-protection", "flow-priority": "runs", "flow-supersede": "runs", "agent-gates": "gates" };
976
    var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]');
977
    if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash);
978
  })();
979
  /* Waiting for a phone's approval (oxygenhub#356): at sign-in the page
980
     posts its proof of the password and signs in once the phone approves;
981
     at the sudo step it asks with the session and goes back where it came
982
     from. A no, or too long a wait, says so and stops. */
983
  (function () {
984
    var box = document.querySelector("[data-approval-poll]");
985
    if (!box) return;
986
    var status = box.querySelector("[data-approval-status]");
987
    var form = box.closest("form");
988
    var url = box.getAttribute("data-approval-poll");
989
    var said = {
990
      denied: "Your phone said no, or a different number was picked. Sign in again, or use a code.",
991
      expired: "That took too long. Start again, or use a code.",
992
      unknown: "That approval is gone. Start again."
993
    };
994
    var tries = 0;
995
    function ask() {
996
      var req;
997
      if (form) {
998
        var body = new URLSearchParams();
999
        ["username", "pending", "approval"].forEach(function (n) {
1000
          var el = form.querySelector('input[name="' + n + '"]');
1001
          if (el) body.set(n, el.value);
1002
        });
1003
        req = fetch(url, { method: "POST", credentials: "same-origin", body: body });
1004
      } else {
1005
        req = fetch(url, { credentials: "same-origin", headers: { accept: "application/json" } });
1006
      }
1007
      req.then(function (r) { return r.json(); }).then(function (j) {
1008
        var state = j && j.state;
1009
        box.setAttribute("data-state", state || "");
1010
        if (state === "approved") {
1011
          if (status) status.textContent = "Approved. Carrying on…";
1012
          if (form) form.submit(); else location.href = box.getAttribute("data-approval-next") || "/";
1013
        } else if (said[state] || state === "used") {
1014
          if (status) status.textContent = said[state] || said.unknown;
1015
        } else if (++tries < 75) {
1016
          setTimeout(ask, 2000);
1017
        }
1018
      }).catch(function () { if (++tries < 75) setTimeout(ask, 4000); });
1019
    }
1020
    setTimeout(ask, 1500);
1021
  })();
1022
})();