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