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