OXYGENHUB
# b1e92a6233ee oxygenhub / assets / hub.js
26.6 KiB · JavaScript · last changed Mermaid diagrams in Markdown 1 month 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
  /* Off-the-shelf editors, loaded only where a page asks for them:
13
     CodeMirror 6 over textarea.code, EasyMDE over textarea.md. */
14
  function load(src, css) {
15
    return new Promise(function (ok) {
16
      if (css) { var l = document.createElement("link"); l.rel = "stylesheet"; l.href = css; document.head.appendChild(l); }
17
      var s = document.createElement("script"); s.src = src; s.onload = ok; document.head.appendChild(s);
18
    });
19
  }
20
  if (document.querySelector("textarea.code")) load("/static/vendor/editor.js");
21
  var mds = document.querySelectorAll("textarea.md");
22
  if (mds.length) load("/static/vendor/easymde.min.js", "/static/vendor/easymde.min.css").then(function () {
23
    var E = window.EasyMDE;
24
    function b(name, action, text, title) { return { name: name, action: action, text: text, title: title, className: "mde-btn" }; }
25
    var toolbar = [b("bold", E.toggleBold, "B", "Bold"), b("italic", E.toggleItalic, "I", "Italic"), b("heading", E.toggleHeadingSmaller, "H", "Heading"), "|",
26
      b("code", E.toggleCodeBlock, "</>", "Code"), b("quote", E.toggleBlockquote, "“", "Quote"), b("ul", E.toggleUnorderedList, "•", "List"), b("ol", E.toggleOrderedList, "1.", "Numbered list"), "|",
27
      b("link", E.drawLink, "link", "Link"), "|", b("preview", E.togglePreview, "preview", "Preview")];
28
    mds.forEach(function (t) { t._mde = new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); });
29
    /* An editor shown later (a :target edit form) lays itself out again. */
30
    window.addEventListener("hashchange", function () { mds.forEach(function (t) { if (t._mde) setTimeout(function () { t._mde.codemirror.refresh(); }, 0); }); });
31
  });
32
33
  /* Mermaid diagrams (```mermaid blocks in Markdown): drawn by the hub's own
34
     copy of Mermaid, loaded only on pages that have one, in strict mode (no
35
     scripts or HTML from the diagram), in the page's theme. A diagram that
36
     does not parse stays as its source, with Mermaid's reason under it. */
37
  var diagrams = document.querySelectorAll("pre > code.language-mermaid");
38
  if (diagrams.length) load("/static/vendor/mermaid.min.js").then(function () {
39
    var m = window.mermaid, dark = document.documentElement.dataset.theme === "dark";
40
    m.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: dark ? "dark" : "default" });
41
    diagrams.forEach(function (code, i) {
42
      var pre = code.parentNode, source = code.textContent;
43
      m.render("mermaid-" + i, source).then(function (r) {
44
        var fig = document.createElement("figure");
45
        fig.className = "mermaid-diagram";
46
        fig.innerHTML = r.svg;
47
        fig.setAttribute("aria-label", "Diagram");
48
        pre.replaceWith(fig);
49
      }).catch(function (e) {
50
        var note = document.createElement("p");
51
        note.className = "small warn";
52
        note.textContent = "This diagram could not be drawn: " + ((e && e.message) || e);
53
        pre.after(note);
54
      });
55
    });
56
  });
57
58
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
59
  var toggle = document.querySelector("[data-theme-toggle]");
60
  if (toggle) {
61
    var root = document.documentElement, order = ["light", "dark", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" };
62
    var show = function () {
63
      var p = root.dataset.themePref || "light";
64
      toggle.title = "Theme: " + p + " (click to change)";
65
      toggle.setAttribute("aria-label", toggle.title);
66
      toggle.firstElementChild.textContent = glyph[p];
67
    };
68
    show();
69
    toggle.addEventListener("click", function () {
70
      var p = order[(order.indexOf(root.dataset.themePref || "light") + 1) % order.length];
71
      document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax";
72
      root.dataset.themePref = p;
73
      root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
74
      show();
75
    });
76
  }
77
78
  /* Diffs: unified or split (remembered in the diff cookie), collapse all. */
79
  var diffs = document.querySelectorAll("table.code.diff");
80
  if (diffs.length) {
81
    var pane = function (tr, col, side) {
82
      var n = document.createElement("td"), t = document.createElement("td");
83
      n.className = "n"; t.className = "t" + (side === "right" ? " mid" : "");
84
      if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; }
85
      var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx";
86
      if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; }
87
      n.textContent = tr.cells[col].textContent;
88
      t.innerHTML = tr.cells[3].innerHTML;
89
      // Line comments belong to the projected (right) side only.
90
      if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); });
91
      return [n, t];
92
    };
93
    var split = function (table) {
94
      var s = document.createElement("table");
95
      s.className = "code diff split";
96
      s.innerHTML = '<colgroup><col class="n"><col><col class="n"><col></colgroup>';
97
      var rows = [].slice.call(table.rows), i = 0;
98
      var add = function (l, r) {
99
        var tr = s.insertRow();
100
        pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); });
101
      };
102
      while (i < rows.length) {
103
        var r = rows[i];
104
        if (r.classList.contains("del") || r.classList.contains("add")) {
105
          var dels = [], adds = [];
106
          while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]);
107
          while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]);
108
          for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]);
109
          continue;
110
        }
111
        if (r.classList.contains("gap") || r.querySelector("td.thread")) {
112
          var tr = s.insertRow(), td = tr.insertCell();
113
          tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className;
114
          td.innerHTML = r.cells[r.cells.length - 1].innerHTML;
115
        } else {
116
          add(r, r);
117
        }
118
        i++;
119
      }
120
      return s;
121
    };
122
    var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified";
123
    var apply = function (mode) {
124
      diffs.forEach(function (t) {
125
        if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); }
126
        t.hidden = mode === "split";
127
        if (t._split) t._split.hidden = mode !== "split";
128
      });
129
      document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); });
130
    };
131
    document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; });
132
    document.querySelectorAll("[data-diff-mode]").forEach(function (a) {
133
      a.addEventListener("click", function (ev) {
134
        ev.preventDefault();
135
        var mode = a.getAttribute("data-diff-mode");
136
        document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax";
137
        apply(mode);
138
      });
139
    });
140
    document.querySelectorAll("[data-diff-collapse]").forEach(function (a) {
141
      a.addEventListener("click", function (ev) {
142
        ev.preventDefault();
143
        var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; });
144
        files.forEach(function (d) { d.open = !anyOpen; });
145
        a.textContent = anyOpen ? "expand all" : "collapse all";
146
      });
147
    });
148
    apply(cookieMode);
149
  }
150
151
  /* Settings pages: mark the section in view in the list of sections. */
152
  var navLinks = document.querySelectorAll(".section-nav a[href^='#']");
153
  if (navLinks.length && "IntersectionObserver" in window) {
154
    var byId = {};
155
    navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
156
    var io = new IntersectionObserver(function (entries) {
157
      entries.forEach(function (e) {
158
        if (e.isIntersecting && byId[e.target.id]) {
159
          navLinks.forEach(function (a) { a.classList.remove("on"); });
160
          byId[e.target.id].classList.add("on");
161
        }
162
      });
163
    }, { rootMargin: "0px 0px -70% 0px" });
164
    Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); });
165
  }
166
167
  /* Copy buttons. */
168
  document.querySelectorAll("[data-copy]").forEach(function (b) {
169
    b.addEventListener("click", function () {
170
      navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
171
        var t = b.textContent; b.textContent = "copied"; setTimeout(function () { b.textContent = t; }, 1200);
172
      });
173
    });
174
  });
175
176
  /* Tree rows: the last change per entry, fetched after the page paints. */
177
  var tree = document.querySelector("table.tree[data-last]");
178
  if (tree) {
179
    var paths = [].map.call(tree.querySelectorAll("tr[data-path]"), function (r) { return r.getAttribute("data-path"); });
180
    if (paths.length) {
181
      fetch(tree.getAttribute("data-last") + "?paths=" + encodeURIComponent(paths.join(",")), { credentials: "same-origin" })
182
        .then(function (r) { return r.json(); })
183
        .then(function (rows) {
184
          rows.forEach(function (c) {
185
            var tr = tree.querySelector('tr[data-path="' + CSS.escape(c.path) + '"]');
186
            if (!tr) return;
187
            var a = tr.querySelector("td.last a"), t = tr.querySelector("td.age");
188
            if (a) { a.textContent = c.title; a.href = tree.getAttribute("data-change") + c.hash; }
189
            if (t) { t.textContent = c.ago; t.title = c.when; }
190
          });
191
        }).catch(function () {});
192
    }
193
  }
194
195
  /* Line anchors: #L12 or #L12-L20 highlight a range and clicking a number selects. */
196
  function mark() {
197
    var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/);
198
    document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); });
199
    if (!m) { showBar(0, 0); return; }
200
    var a = +m[1], b = +(m[2] || m[1]);
201
    for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
202
      var row = document.getElementById("L" + i);
203
      if (row) row.classList.add("hl");
204
    }
205
    var first = document.getElementById("L" + Math.min(a, b));
206
    if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; }
207
    showBar(Math.min(a, b), Math.max(a, b));
208
  }
209
  /* The bar for a selected range: copy its text, or a permalink to it. */
210
  var bar = document.querySelector(".linebar");
211
  function showBar(a, b) {
212
    if (!bar) return;
213
    bar.hidden = !a;
214
    if (!a) return;
215
    bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b;
216
    bar.setAttribute("data-range", a + "-" + b);
217
  }
218
  function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); }
219
  if (bar) {
220
    bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) {
221
      var r = bar.getAttribute("data-range").split("-"), out = [];
222
      for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); }
223
      navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); });
224
    });
225
    var pl = bar.querySelector("[data-copy-permalink]");
226
    if (pl) pl.addEventListener("click", function () {
227
      navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); });
228
    });
229
  }
230
  function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; }
231
  window.addEventListener("hashchange", mark);
232
  mark();
233
  document.querySelectorAll("table.code td.n").forEach(function (td) {
234
    td.addEventListener("click", function (e) {
235
      var n = td.parentNode.id;
236
      if (e.shiftKey && location.hash.match(/^#L(\d+)/)) location.hash = location.hash.replace(/^#(L\d+).*/, "$1-" + n);
237
      else location.hash = n;
238
    });
239
  });
240
241
  /* Flow run page: statuses and new log lines while the run is live. */
242
  var liveUrl = document.body.getAttribute("data-flow-live");
243
  if (liveUrl) {
244
    var tick = function () {
245
      var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) {
246
        return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq");
247
      }).join(",");
248
      fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } })
249
        .then(function (r) { return r.json(); })
250
        .then(function (s) {
251
          s.jobs.forEach(function (j) {
252
            var cls = "st-" + j.status;
253
            var node = document.querySelector("[data-job-node='" + j.id + "']");
254
            if (node) {
255
              node.setAttribute("class", "node " + cls);
256
              node.querySelector("[data-glyph]").textContent = j.glyph;
257
              node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration;
258
            }
259
            var d = document.querySelector("details[data-job='" + j.id + "']");
260
            if (!d) return;
261
            d.className = "panel flow-job " + cls;
262
            var g = d.querySelector("summary [data-glyph]");
263
            g.className = "flow-glyph " + cls; g.textContent = j.glyph;
264
            d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration;
265
            if (j.status === "running" || j.status === "failed") d.open = true;
266
            var log = d.querySelector("[data-log]");
267
            var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
268
            j.lines.forEach(function (l) {
269
              var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
270
              var t = document.createElement("span"); t.className = "t"; t.textContent = (l.t / 1000).toFixed(1) + "s";
271
              ln.appendChild(t); ln.appendChild(document.createTextNode(l.text));
272
              log.appendChild(ln); log.appendChild(document.createTextNode("\n"));
273
              log.setAttribute("data-seq", l.seq);
274
            });
275
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
276
          });
277
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
278
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
279
        })
280
        .catch(function () { setTimeout(tick, 5000); });
281
    };
282
    setTimeout(tick, 1500);
283
  }
284
285
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
286
  var pending = null;
287
  document.addEventListener("keydown", function (e) {
288
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
289
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
290
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
291
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
292
    var repo = document.body.getAttribute("data-repo");
293
    if (pending === "g" && repo) {
294
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
295
      if (to !== undefined) location.href = repo + to;
296
      pending = null; return;
297
    }
298
    pending = e.key === "g" ? "g" : null;
299
  });
300
301
  // Live conflict resolution (hub spec §6.10): who else is resolving this
302
  // conflict, their choices region by region (the last one wins), and the
303
  // resolution when someone commits it.
304
  var live = document.querySelector("[data-resolve-live]");
305
  if (live && window.EventSource && window.fetch) {
306
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
307
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
308
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
309
    var applying = false;
310
    var fieldsOf = function (region) {
311
      return region === "keep"
312
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
313
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
314
    };
315
    var apply = function (region, c) {
316
      var f = fieldsOf(region);
317
      applying = true;
318
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
319
      if (f.text && c.choice === "text") f.text.value = c.text;
320
      applying = false;
321
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
322
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
323
    };
324
    var send = function (region, choice, text) {
325
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
326
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
327
    };
328
    var timers = {};
329
    live.addEventListener("change", function (ev) {
330
      var t = ev.target;
331
      if (applying || !t.name || t.type !== "radio") return;
332
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
333
      var ta = live.querySelector('textarea[name="t' + region + '"]');
334
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
335
    });
336
    live.addEventListener("input", function (ev) {
337
      var t = ev.target;
338
      if (applying || t.tagName !== "TEXTAREA") return;
339
      var region = t.name.slice(1);
340
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
341
      clearTimeout(timers[region]);
342
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
343
    });
344
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
345
    var showPresent = function (names) {
346
      present.hidden = names.length < 2;
347
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
348
    };
349
    es.addEventListener("snapshot", function (e) {
350
      var d = JSON.parse(e.data);
351
      showPresent(d.present);
352
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
353
      if (d.keep) apply("keep", d.keep);
354
    });
355
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
356
    es.addEventListener("choice", function (e) {
357
      var d = JSON.parse(e.data);
358
      if (d.client !== client) apply(d.region, d.choice);
359
    });
360
    es.addEventListener("landed", function (e) {
361
      var d = JSON.parse(e.data);
362
      es.close();
363
      landed.hidden = false;
364
      landed.textContent = "";
365
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
366
      var a = document.createElement("a");
367
      a.href = d.url;
368
      a.textContent = "see the change";
369
      landed.appendChild(a);
370
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
371
    });
372
  }
373
374
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
375
  // another to plan a squash or a rebase. The hub only writes the plan;
376
  // the author's client carries it out and signs it.
377
  var plan = document.querySelector("[data-plan]");
378
  if (plan && window.fetch) {
379
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
380
    plan.querySelectorAll("[data-change]").forEach(function (a) {
381
      a.style.cursor = "grab";
382
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
383
      a.addEventListener("pointermove", function () { if (from) moved = true; });
384
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
385
    });
386
    document.addEventListener("pointerup", function (ev) {
387
      var start = from;
388
      from = null;
389
      if (!start) return;
390
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
391
      if (!onto || onto.getAttribute("data-change") === start) return;
392
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
393
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
394
        .then(function (r) { return r.json(); })
395
        .then(function (d) {
396
          panel.hidden = false;
397
          panel.textContent = "";
398
          if (d.error) { panel.textContent = d.error; return; }
399
          d.options.forEach(function (o) {
400
            var p = document.createElement("p");
401
            p.appendChild(document.createTextNode(o.summary + " "));
402
            var a = document.createElement("a");
403
            a.href = o.vscode;
404
            a.textContent = "Apply in VS Code";
405
            p.appendChild(a);
406
            var code = document.createElement("pre");
407
            code.className = "snippet";
408
            code.textContent = o.command;
409
            panel.appendChild(p);
410
            panel.appendChild(code);
411
          });
412
        });
413
    });
414
  }
415
416
  /* Sidebar menus (oxygenhub#82): type to filter a menu's items; a click
417
     outside an open menu closes it. */
418
  document.querySelectorAll("[data-menu-filter]").forEach(function (input) {
419
    var items = input.closest(".menu-pop").querySelectorAll(".menu-item");
420
    input.addEventListener("input", function () {
421
      var q = input.value.trim().toLowerCase();
422
      items.forEach(function (it) { it.hidden = q !== "" && it.textContent.toLowerCase().indexOf(q) < 0; });
423
    });
424
  });
425
  document.querySelectorAll("details.menu").forEach(function (d) {
426
    d.addEventListener("toggle", function () {
427
      var f = d.querySelector("[data-menu-filter]");
428
      if (d.open && f) f.focus();
429
    });
430
  });
431
  document.addEventListener("click", function (ev) {
432
    document.querySelectorAll("details.menu[open]").forEach(function (d) { if (!d.contains(ev.target)) d.open = false; });
433
  });
434
435
  /* ··· menus: copy a link to a part of the page, or some text. */
436
  function copied(btn, text) {
437
    if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () {
438
      var was = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = was; }, 1200);
439
    });
440
    var d = btn.closest("details"); if (d) d.open = false;
441
  }
442
  document.querySelectorAll("[data-copy-link]").forEach(function (b) {
443
    b.addEventListener("click", function () { copied(b, location.origin + location.pathname + location.search + b.getAttribute("data-copy-link")); });
444
  });
445
  document.querySelectorAll("[data-copy-text]").forEach(function (b) {
446
    b.addEventListener("click", function () { copied(b, b.getAttribute("data-copy-text")); });
447
  });
448
  document.querySelectorAll('.menu-actions a[href^="#"]').forEach(function (a) {
449
    a.addEventListener("click", function () { var d = a.closest("details"); if (d) d.open = false; });
450
  });
451
452
  /* Settings pages: a form posts and the page comes back from the top.
453
     Remember which section's form was sent, and on the same page's next
454
     load scroll back to it, bringing the notice along. */
455
  document.addEventListener("submit", function (e) {
456
    var sec = e.target.closest ? e.target.closest("section[id], form[id], details[id], div[id]") : null;
457
    if (!sec || !sec.id) return;
458
    try { sessionStorage.setItem("oxy-return", location.pathname + "#" + sec.id); } catch (err) { /* private mode */ }
459
  });
460
  try {
461
    var back = sessionStorage.getItem("oxy-return");
462
    if (back && back.indexOf(location.pathname + "#") === 0) {
463
      sessionStorage.removeItem("oxy-return");
464
      var target = document.getElementById(back.split("#")[1]);
465
      if (target) {
466
        var flash = document.querySelector("main > .note, main > .error, .sections > .note, .sections > .error");
467
        if (flash && !target.contains(flash)) target.parentNode.insertBefore(flash, target);
468
        target.scrollIntoView({ block: "start" });
469
      }
470
    }
471
  } catch (err) { /* no storage: nothing to restore */ }
472
473
  /* AI assistants (#109): a provider fills the name, endpoint and API;
474
     List models asks the endpoint what it offers and fills the model
475
     field's suggestions. Without this script, both are typed by hand. */
476
  document.querySelectorAll("[data-preset-select]").forEach(function (sel) {
477
    var form = sel.form;
478
    function apply() {
479
      var o = sel.options[sel.selectedIndex];
480
      if (!o || !o.value) return;
481
      var name = form.querySelector("[name=name]"), ep = form.querySelector("[name=endpoint]"), api = form.querySelector("[name=api]");
482
      name.placeholder = o.dataset.name; ep.value = o.dataset.endpoint; api.value = o.dataset.api;
483
    }
484
    sel.addEventListener("change", apply);
485
    apply();
486
  });
487
  document.querySelectorAll("[data-list-models]").forEach(function (b) {
488
    b.addEventListener("click", function () {
489
      var form = b.form, input = document.getElementById(b.dataset.for);
490
      var list = document.getElementById(b.dataset.for + "-list"), note = document.querySelector("[data-models-note='" + b.dataset.for + "']");
491
      var body = new URLSearchParams();
492
      body.set("_csrf", b.dataset.csrf);
493
      ["endpoint", "api", "token", "preset"].forEach(function (k) { var el = form.querySelector("[name=" + k + "]"); if (el) body.set(k, el.value); });
494
      if (b.dataset.id) body.set("id", b.dataset.id);
495
      if (note) note.textContent = "Asking the provider…";
496
      fetch(b.dataset.listModels, { method: "POST", body: body, credentials: "same-origin" })
497
        .then(function (r) { return r.json(); })
498
        .then(function (j) {
499
          if (j.models) {
500
            list.innerHTML = "";
501
            j.models.forEach(function (m) { var o = document.createElement("option"); o.value = m; list.appendChild(o); });
502
            if (note) note.textContent = j.models.length + " models: pick one in the Model field.";
503
            input.focus();
504
          } else if (note) {
505
            note.textContent = "No list: " + (j.error || "unknown") + ". Type the model's name.";
506
          }
507
        })
508
        .catch(function () { if (note) note.textContent = "The hub could not ask; type the model's name."; });
509
    });
510
  });
511
})();