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