OXYGENHUB
◆ v0.9.11 oxygenhub / assets / hub.js
52.6 KiB · JavaScript · last changed Copy buttons are a glyph joined to their field, with a Copied! tick; a drop's Embed and Clone line up (closes #338) 9 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
            // Sections (oxygenflow#31): ::group::TITLE opens one, ::endgroup::
419
            // (or the next ::group::) closes it; the open one takes the lines.
420
            var open = log.querySelector("details[data-open-group]");
421
            var secs = function (ms) { return Math.floor(ms / 1000) + "." + Math.floor(ms % 1000 / 100) + "s"; };
422
            var close = function (t) {
423
              if (!open) return;
424
              open.removeAttribute("data-open-group"); open.open = false;
425
              var took = document.createElement("span"); took.className = "took"; took.textContent = " " + secs(t - Number(open.getAttribute("data-from")));
426
              open.querySelector("summary").appendChild(took); open = null;
427
            };
428
            j.lines.forEach(function (l) {
429
              var tt = document.createElement("span"); tt.className = "t"; tt.textContent = secs(l.t);
430
              if (l.text.indexOf("::group::") === 0) {
431
                close(l.t);
432
                open = document.createElement("details"); open.className = "log-group"; open.open = true;
433
                open.setAttribute("data-open-group", ""); open.setAttribute("data-from", l.t);
434
                var sum = document.createElement("summary"); sum.className = "ln";
435
                sum.appendChild(tt); sum.appendChild(document.createTextNode(l.text.slice(9).trim()));
436
                open.appendChild(sum); log.appendChild(open);
437
              } else if (l.text.trim() === "::endgroup::") {
438
                close(l.t);
439
              } else {
440
                var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
441
                ln.appendChild(tt); ln.appendChild(document.createTextNode(l.text));
442
                var into = open || log;
443
                into.appendChild(ln); into.appendChild(document.createTextNode("\n"));
444
              }
445
              log.setAttribute("data-seq", l.seq);
446
            });
447
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
448
          });
449
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
450
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
451
        })
452
        .catch(function () { setTimeout(tick, 5000); });
453
    };
454
    setTimeout(tick, 1500);
455
  }
456
457
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
458
  var pending = null;
459
  document.addEventListener("keydown", function (e) {
460
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
461
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
462
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
463
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
464
    var repo = document.body.getAttribute("data-repo");
465
    if (pending === "g" && repo) {
466
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
467
      if (to !== undefined) location.href = repo + to;
468
      pending = null; return;
469
    }
470
    pending = e.key === "g" ? "g" : null;
471
  });
472
473
  // Live conflict resolution (hub spec §6.10): who else is resolving this
474
  // conflict, their choices region by region (the last one wins), and the
475
  // resolution when someone commits it.
476
  var live = document.querySelector("[data-resolve-live]");
477
  if (live && window.EventSource && window.fetch) {
478
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
479
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
480
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
481
    var applying = false;
482
    var fieldsOf = function (region) {
483
      return region === "keep"
484
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
485
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
486
    };
487
    var apply = function (region, c) {
488
      var f = fieldsOf(region);
489
      applying = true;
490
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
491
      if (f.text && c.choice === "text") f.text.value = c.text;
492
      applying = false;
493
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
494
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
495
    };
496
    var send = function (region, choice, text) {
497
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
498
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
499
    };
500
    var timers = {};
501
    live.addEventListener("change", function (ev) {
502
      var t = ev.target;
503
      if (applying || !t.name || t.type !== "radio") return;
504
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
505
      var ta = live.querySelector('textarea[name="t' + region + '"]');
506
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
507
    });
508
    live.addEventListener("input", function (ev) {
509
      var t = ev.target;
510
      if (applying || t.tagName !== "TEXTAREA") return;
511
      var region = t.name.slice(1);
512
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
513
      clearTimeout(timers[region]);
514
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
515
    });
516
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
517
    var showPresent = function (names) {
518
      present.hidden = names.length < 2;
519
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
520
    };
521
    es.addEventListener("snapshot", function (e) {
522
      var d = JSON.parse(e.data);
523
      showPresent(d.present);
524
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
525
      if (d.keep) apply("keep", d.keep);
526
    });
527
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
528
    es.addEventListener("choice", function (e) {
529
      var d = JSON.parse(e.data);
530
      if (d.client !== client) apply(d.region, d.choice);
531
    });
532
    es.addEventListener("landed", function (e) {
533
      var d = JSON.parse(e.data);
534
      es.close();
535
      landed.hidden = false;
536
      landed.textContent = "";
537
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
538
      var a = document.createElement("a");
539
      a.href = d.url;
540
      a.textContent = "see the change";
541
      landed.appendChild(a);
542
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
543
    });
544
  }
545
546
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
547
  // another to plan a squash or a rebase. The hub only writes the plan;
548
  // the author's client carries it out and signs it.
549
  var plan = document.querySelector("[data-plan]");
550
  if (plan && window.fetch) {
551
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
552
    plan.querySelectorAll("[data-change]").forEach(function (a) {
553
      a.style.cursor = "grab";
554
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
555
      a.addEventListener("pointermove", function () { if (from) moved = true; });
556
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
557
    });
558
    document.addEventListener("pointerup", function (ev) {
559
      var start = from;
560
      from = null;
561
      if (!start) return;
562
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
563
      if (!onto || onto.getAttribute("data-change") === start) return;
564
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
565
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
566
        .then(function (r) { return r.json(); })
567
        .then(function (d) {
568
          panel.hidden = false;
569
          panel.textContent = "";
570
          if (d.error) { panel.textContent = d.error; return; }
571
          d.options.forEach(function (o) {
572
            var p = document.createElement("p");
573
            p.appendChild(document.createTextNode(o.summary + " "));
574
            var a = document.createElement("a");
575
            a.href = o.vscode;
576
            a.textContent = "Apply in VS Code";
577
            p.appendChild(a);
578
            var code = document.createElement("pre");
579
            code.className = "snippet";
580
            code.textContent = o.command;
581
            panel.appendChild(p);
582
            panel.appendChild(code);
583
          });
584
        });
585
    });
586
  }
587
588
  /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click
589
     outside an open menu closes it. */
590
  document.querySelectorAll("[data-menu-filter]").forEach(function (input) {
591
    var items = input.closest(".menu-pop").querySelectorAll(".menu-item");
592
    input.addEventListener("input", function () {
593
      var q = input.value.trim().toLowerCase();
594
      items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; });
595
    });
596
  });
597
  document.querySelectorAll("details.menu").forEach(function (d) {
598
    d.addEventListener("toggle", function () {
599
      var f = d.querySelector("[data-menu-filter]");
600
      if (d.open && f) f.focus();
601
    });
602
  });
603
  document.addEventListener("click", function (ev) {
604
    document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; });
605
  });
606
607
  /* ··· menus: copy a link to a part of the page, or some text. */
608
  function copied(btn, text) {
609
    if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () {
610
      var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200);
611
    });
612
    var d = btn.closest("details"); if (d) d.open = false;
613
  }
614
  document.querySelectorAll("[data-copy-link]").forEach(function (b) {
615
    b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); });
616
  });
617
  document.querySelectorAll("[data-copy-text]").forEach(function (b) {
618
    b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); });
619
  });
620
  document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) {
621
    a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; });
622
  });
623
624
  /* Settings pages: a form posts and the page comes back from the top.
625
     Remember which section's form was sent, and on the same page's next
626
     load scroll back to it, bringing the notice along. */
627
  document.addEventListener("submit", function (e) {
628
    var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null;
629
    if (!sec || !sec.id) return;
630
    try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ }
631
  });
632
  try {
633
    var back = sessionStorage.getItem("oxy-return");
634
    if (back && back.indexOf(location.pathname + "#") === 0) {
635
      sessionStorage.removeItem("oxy-return");
636
      var target = document.getElementById(back.split("#")[1]);
637
      if (target) {
638
        var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error");
639
        if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target);
640
        target.scrollIntoView({ block: "start" });
641
      }
642
    }
643
  } catch (err) { /* no storage: nothing to restore */ }
644
645
  /* Working (#142): on a repository whose store is remote, a land or a
646
     page can take seconds. A pressed button says what it is doing, with
647
     the CLI's spinner, and ignores a second press; a navigation that takes
648
     more than a moment spins the Oxygen mark's rings, in the header and
649
     beside the pointer. A quick page shows none of it. */
650
  var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
651
  var waitTimer = 0, spinTimer = 0, pointer = null, mark = null;
652
  document.addEventListener("pointermove", function (e) {
653
    pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null;
654
    if (mark && pointer) place();
655
  }, { passive: true });
656
  function place() {
657
    if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); }
658
    else mark.classList.add("corner");
659
  }
660
  function waiting() {
661
    clearTimeout(waitTimer);
662
    waitTimer = setTimeout(function () {
663
      if (!mark) {
664
        var logo = document.querySelector("header .brand svg");
665
        mark = document.createElement("div");
666
        mark.className = "oxy-wait-mark";
667
        mark.setAttribute("role", "status");
668
        mark.setAttribute("aria-label", "Working");
669
        if (logo) mark.appendChild(logo.cloneNode(true));
670
        document.body.appendChild(mark);
671
      }
672
      place();
673
      document.documentElement.classList.add("oxy-wait");
674
    }, 300);
675
  }
676
  function busy(button, form) {
677
    if (!button) return;
678
    var label = button.getAttribute("data-busy") || button.textContent.trim();
679
    button.dataset.idle = button.innerHTML;
680
    button.style.minWidth = button.offsetWidth + "px";
681
    button.textContent = label + " ";
682
    var glyph = document.createElement("span");
683
    glyph.className = "oxy-spin";
684
    glyph.setAttribute("aria-hidden", "true");
685
    glyph.textContent = SPIN[0];
686
    button.appendChild(glyph);
687
    button.setAttribute("aria-busy", "true");
688
    var i = 0;
689
    clearInterval(spinTimer);
690
    spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80);
691
    /* Disabled only after the form's data is gathered, so the button's
692
       own name and value still go with it. */
693
    setTimeout(function () { button.disabled = true; }, 0);
694
    form.dataset.sending = "1";
695
  }
696
  document.addEventListener("submit", function (e) {
697
    var form = e.target;
698
    if (form.dataset.sending) { e.preventDefault(); return; }
699
    if (e.defaultPrevented || (form.target && form.target !== "_self")) return;
700
    if ((form.getAttribute("method") || "get").toLowerCase() === "post") {
701
      busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form);
702
    }
703
    waiting();
704
  });
705
  document.addEventListener("click", function (e) {
706
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
707
    var a = e.target.closest ? e.target.closest("a[href]") : null;
708
    if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
709
    var url = new URL(a.href, location.href);
710
    if (url.origin !== location.origin) return;
711
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
712
    waiting();
713
  });
714
  /* Back to a page the browser kept: it is not working any more. */
715
  window.addEventListener("pageshow", function (e) {
716
    if (!e.persisted) return;
717
    clearTimeout(waitTimer);
718
    clearInterval(spinTimer);
719
    document.documentElement.classList.remove("oxy-wait");
720
    document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) {
721
      b.innerHTML = b.dataset.idle;
722
      b.disabled = false;
723
      b.removeAttribute("aria-busy");
724
      b.style.minWidth = "";
725
      if (b.form) delete b.form.dataset.sending;
726
    });
727
  });
728
729
  /* AI assistants (#109): a provider fills the name, endpoint and API;
730
     List models asks the endpoint what it offers and fills the model
731
     field's suggestions. Without this script, both are typed by hand. */
732
  document.querySelectorAll("[data-preset-select]").forEach(function (sel) {
733
    var form = sel.form;
734
    function apply() {
735
      var o = sel.options[sel.selectedIndex];
736
      if (!o || !o.value) return;
737
      var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]");
738
      name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api;
739
    }
740
    sel.addEventListener("change", apply);
741
    apply();
742
  });
743
  document.querySelectorAll("[data-list-models]").forEach(function (b) {
744
    b.addEventListener("click", function () {
745
      var form = b.form, input = document.getElementById(b.dataset.for);
746
      var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']");
747
      var body = new URLSearchParams();
748
      body.set("_csrf", b.dataset.csrf);
749
      ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); });
750
      if (b.dataset.id) body.set("id", b.dataset.id);
751
      if (note) note.textContent = "Asking the provider…";
752
      fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" })
753
        .then(function (r) { return r.json(); })
754
        .then(function (j) {
755
          if (j.models) {
756
            list.innerHTML = "";
757
            j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); });
758
            if (note) note.textContent = j.models.length + " models: pick one in the Model field.";
759
            input.focus();
760
          } else if (note) {
761
            note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name.";
762
          }
763
        })
764
        .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; });
765
    });
766
  });
767
})();
768
769
// Live pages (docs/live.md, oxygenhub#204): elements marked
770
// data-live="<topic>" data-fragment="<name>" are refreshed from this page's
771
// own route when the hub says their topic changed (one fetch for all of
772
// them; ?fragment=<name> serves a single part to anyone else). Someone typing inside one is not interrupted:
773
// it is refreshed when they leave it. Without JavaScript or a stream the
774
// page is as it was; a reload shows everything.
775
(function () {
776
  var marked = document.querySelectorAll("[data-live][data-fragment]");
777
  if (!marked.length || !window.EventSource || !window.fetch) return;
778
  var topics = {};
779
  // data-live may name several topics, separated by spaces.
780
  var topicsOf = function (el) { return el.getAttribute("data-live").split(/\s+/).filter(Boolean); };
781
  Array.prototype.forEach.call(marked, function (el) { topicsOf(el).forEach(function (t) { topics[t] = true; }); });
782
  var typingIn = function (el) {
783
    var a = document.activeElement;
784
    return a && el.contains(a) && (a.tagName === "TEXTAREA" || (a.tagName === "INPUT" && a.type !== "hidden")) && a.value;
785
  };
786
  // Stale parts are gathered for a moment, then the page is fetched once
787
  // and each part swapped from it: one render however many parts and
788
  // events (a slow repository's page costs the server seconds each time).
789
  var stale = [], timer = null, fetching = false;
790
  var swapFrom = function (doc) {
791
    var todo = stale;
792
    stale = [];
793
    todo.forEach(function (el) {
794
      if (typingIn(el)) {
795
        el.addEventListener("focusout", function again() {
796
          el.removeEventListener("focusout", again);
797
          refresh(el);
798
        });
799
        return;
800
      }
801
      var name = el.getAttribute("data-fragment");
802
      var fresh = doc.querySelector('[data-fragment="' + name + '"]');
803
      if (fresh) el.innerHTML = fresh.innerHTML;
804
    });
805
  };
806
  var flush = function () {
807
    timer = null;
808
    if (fetching || !stale.length) return;
809
    fetching = true;
810
    fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } })
811
      .then(function (r) { return r.ok ? r.text() : null; })
812
      .then(function (html) {
813
        fetching = false;
814
        if (html !== null) swapFrom(new DOMParser().parseFromString(html, "text/html"));
815
        if (stale.length) timer = setTimeout(flush, 300);
816
      })
817
      .catch(function () { fetching = false; });
818
  };
819
  var refresh = function (el) {
820
    if (stale.indexOf(el) < 0) stale.push(el);
821
    if (!timer) timer = setTimeout(flush, 300);
822
  };
823
  var es = new EventSource("/live?topics=" + encodeURIComponent(Object.keys(topics).join(",")));
824
  es.addEventListener("change", function (e) {
825
    var d;
826
    try { d = JSON.parse(e.data); } catch (err) { return; }
827
    Array.prototype.forEach.call(marked, function (el) {
828
      if (topicsOf(el).indexOf(d.topic) >= 0) refresh(el);
829
    });
830
  });
831
  // Missed more than the hub keeps: everything marked is fetched again.
832
  es.addEventListener("reload", function () {
833
    Array.prototype.forEach.call(marked, refresh);
834
  });
835
})();
836
837
/* Insights (oxygenhub#257): a part marked data-refresh="SECONDS"
838
   data-fragment="NAME" is fetched again on that interval while the page is
839
   visible, and swapped in place; never while someone is using a control in
840
   it. Without this file the page is a snapshot; a reload updates it. */
841
(function () {
842
  "use strict";
843
  var parts = document.querySelectorAll("[data-refresh][data-fragment]");
844
  if (!parts.length || !window.fetch || !window.DOMParser) return;
845
  var every = Math.max(10, parseInt(parts[0].getAttribute("data-refresh"), 10) || 30) * 1000;
846
  var busy = false;
847
  var inUse = function (el) {
848
    var a = document.activeElement;
849
    return a && el.contains(a) && /^(SELECT|INPUT|TEXTAREA)$/.test(a.tagName);
850
  };
851
  var tick = function () {
852
    if (busy || document.hidden) return;
853
    busy = true;
854
    fetch(location.href, { credentials: "same-origin", headers: { "X-Fragment": "page" } })
855
      .then(function (r) { return r.ok ? r.text() : null; })
856
      .then(function (html) {
857
        busy = false;
858
        if (html === null) return;
859
        var doc = new DOMParser().parseFromString(html, "text/html");
860
        Array.prototype.forEach.call(parts, function (el) {
861
          if (inUse(el)) return;
862
          var fresh = doc.querySelector('[data-fragment="' + el.getAttribute("data-fragment") + '"]');
863
          if (fresh) el.innerHTML = fresh.innerHTML;
864
        });
865
      })
866
      .catch(function () { busy = false; });
867
  };
868
  setInterval(tick, every);
869
  document.addEventListener("visibilitychange", function () { if (!document.hidden) tick(); });
870
})();
871
872
/* Drafting with an assistant (oxygenhub#248): the draft is streamed into
873
   the description as it is written, so a long one is neither cut off by a
874
   proxy nor waited for in silence. Without this file the button posts the
875
   form and the page comes back with the draft. */
876
(function () {
877
  "use strict";
878
  if (!window.fetch || !window.TextDecoder || !window.URLSearchParams) return;
879
  document.querySelectorAll("button.agent-btn[data-stream]").forEach(function (btn) {
880
    btn.addEventListener("click", function (e) {
881
      var form = btn.form, area = form && form.querySelector('textarea[name="body"]');
882
      if (!form || !area) return;
883
      e.preventDefault();
884
      function get() { return area._mde ? area._mde.value() : area.value; }
885
      function show(t) { if (area._mde) area._mde.value(t); else area.value = t; }
886
      // A new proposal's draft begins with its title line (#316): kept out
887
      // of the description, and put in the title unless the person wrote one.
888
      var titleField = form.querySelector('input[name="title"]');
889
      function untitled(t) { return t.replace(/^\s*title:[^\n]*(\n\s*|$)/i, ""); }
890
      var before = get(), text = "";
891
      var box = (area._mde && area.parentNode.querySelector(".EasyMDEContainer")) || area;
892
      var note = form.querySelector(".draft-note");
893
      if (!note) { note = document.createElement("p"); note.className = "draft-note small mute"; box.parentNode.insertBefore(note, box.nextSibling); }
894
      note.textContent = btn.value + " is drafting the description…";
895
      var buttons = form.querySelectorAll("button.agent-btn");
896
      buttons.forEach(function (b) { b.disabled = true; });
897
      var data = new URLSearchParams(new FormData(form));
898
      data.set("assistant", btn.value);
899
      var finished = false;
900
      function end(message, keep) { finished = true; if (!keep) show(before); note.textContent = message; }
901
      fetch(btn.dataset.stream, { method: "POST", body: data, credentials: "same-origin" })
902
        .then(function (r) {
903
          if (!r.ok || !r.body) throw new Error("the hub answered " + r.status);
904
          var reader = r.body.getReader(), dec = new TextDecoder(), buf = "";
905
          function pump() {
906
            return reader.read().then(function (res) {
907
              if (res.done) { if (!finished) end(btn.value + " stopped before finishing.", text !== ""); return; }
908
              buf += dec.decode(res.value, { stream: true });
909
              var i;
910
              while ((i = buf.indexOf("\n\n")) >= 0) {
911
                var block = buf.slice(0, i), kind = "message", payload = "";
912
                buf = buf.slice(i + 2);
913
                block.split("\n").forEach(function (l) {
914
                  if (l.indexOf("event:") === 0) kind = l.slice(6).trim();
915
                  else if (l.indexOf("data:") === 0) payload += l.slice(5).trim();
916
                });
917
                if (!payload) continue;
918
                var v;
919
                try { v = JSON.parse(payload); } catch (_) { continue; }
920
                if (kind === "part") { text += v; show(untitled(text)); }
921
                else if (kind === "title") {
922
                  if (titleField && (!titleField.value.trim() || titleField.value === titleField.defaultValue)) titleField.value = v;
923
                }
924
                else if (kind === "done") { show(v); end("Drafted by " + btn.value + "; nothing is saved until you save the proposal.", true); return; }
925
                else if (kind === "error") { end(v, false); return; }
926
              }
927
              return pump();
928
            });
929
          }
930
          return pump();
931
        })
932
        .catch(function (err) { end(btn.value + " did not answer: " + err.message + ".", false); })
933
        .then(function () { buttons.forEach(function (b) { b.disabled = false; }); });
934
    });
935
  });
936
  /* Declared in data- attributes, so pages carry no inline handlers and
937
     the Content-Security-Policy can be enforced (oxygenhub#227):
938
     data-confirm asks before a click or a form's submit goes ahead;
939
     data-autosubmit sends a select's form when it changes; data-select
940
     selects a field's text when it is focused or clicked. */
941
  document.addEventListener("click", function (e) {
942
    var el = e.target.closest && e.target.closest("[data-confirm]");
943
    if (el && el.tagName !== "FORM" && !window.confirm(el.getAttribute("data-confirm"))) {
944
      e.preventDefault();
945
      e.stopImmediatePropagation();
946
    }
947
  }, true);
948
  document.addEventListener("submit", function (e) {
949
    var f = e.target;
950
    if (f.hasAttribute && f.hasAttribute("data-confirm") && !window.confirm(f.getAttribute("data-confirm"))) e.preventDefault();
951
  }, true);
952
  document.addEventListener("change", function (e) {
953
    var el = e.target;
954
    if (el.hasAttribute && el.hasAttribute("data-autosubmit") && el.form) el.form.submit();
955
  });
956
  function selectAll(e) {
957
    var el = e.target;
958
    if (el.hasAttribute && el.hasAttribute("data-select") && el.select) el.select();
959
  }
960
  document.addEventListener("focusin", selectAll);
961
  document.addEventListener("click", selectAll);
962
963
  /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */
964
  (function () {
965
    var sel = document.querySelector("[data-mail-service]");
966
    if (!sel) return;
967
    var show = function () {
968
      document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) {
969
        el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0;
970
      });
971
    };
972
    sel.addEventListener("change", show);
973
    show();
974
  })();
975
976
  /* Drops (oxygenhub#279): add and remove file rows on the drop form.
977
     Without this file the form has its rows and one empty one; more come
978
     by saving and editing again. */
979
  (function () {
980
    var list = document.querySelector("[data-drop-files]");
981
    var add = document.querySelector("[data-drop-add]");
982
    if (!list || !add) return;
983
    function wire(row) {
984
      var rm = row.querySelector("[data-drop-remove]");
985
      if (!rm) return;
986
      rm.hidden = false;
987
      rm.addEventListener("click", function () {
988
        if (list.querySelectorAll("[data-drop-file]").length > 1) row.remove();
989
        else row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; });
990
      });
991
    }
992
    list.querySelectorAll("[data-drop-file]").forEach(wire);
993
    add.hidden = false;
994
    add.addEventListener("click", function () {
995
      var rows = list.querySelectorAll("[data-drop-file]");
996
      var row = rows[rows.length - 1].cloneNode(true);
997
      row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; f.textContent = ""; });
998
      list.appendChild(row);
999
      wire(row);
1000
      row.querySelector("input").focus();
1001
    });
1002
  })();
1003
1004
  /* A repository's settings were one page with a section per #anchor;
1005
     each is a page of its own now (oxygenhub#278). A kept link such as
1006
     /settings#flow-priority opens the page that holds that section, when
1007
     the viewer has it (its link is in the sidebar). */
1008
  (function () {
1009
    var nav = document.querySelector("[data-settings-anchors]");
1010
    var hash = location.hash.slice(1);
1011
    if (!nav || !/^[a-z-]+$/.test(hash)) return;
1012
    var moved = { "history-scan": "push-protection", "flow-priority": "runs", "flow-supersede": "runs", "agent-gates": "gates" };
1013
    var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]');
1014
    if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash);
1015
  })();
1016
})();