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