OXYGENHUB
# e9bfea8e26db oxygenhub / assets / hub.js
20.0 KiB · JavaScript · last changed Plan squashes and rebases by dragging on a proposal's swimlanes 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) { new E({ element: t, spellChecker: false, status: false, forceSync: true, autoDownloadFontAwesome: false, toolbar: toolbar }); });
29
  });
30
31
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
32
  var toggle = document.querySelector("[data-theme-toggle]");
33
  if (toggle) {
34
    var root = document.documentElement, order = ["dark", "light", "auto"], glyph = { dark: "◐", light: "☀", auto: "◑" };
35
    var show = function () {
36
      var p = root.dataset.themePref || "dark";
37
      toggle.title = "Theme: " + p + " (click to change)";
38
      toggle.setAttribute("aria-label", toggle.title);
39
      toggle.firstElementChild.textContent = glyph[p];
40
    };
41
    show();
42
    toggle.addEventListener("click", function () {
43
      var p = order[(order.indexOf(root.dataset.themePref || "dark") + 1) % order.length];
44
      document.cookie = "theme=" + p + "; path=/; max-age=31536000; samesite=lax";
45
      root.dataset.themePref = p;
46
      root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
47
      show();
48
    });
49
  }
50
51
  /* Diffs: unified or split (remembered in the diff cookie), collapse all. */
52
  var diffs = document.querySelectorAll("table.code.diff");
53
  if (diffs.length) {
54
    var pane = function (tr, col, side) {
55
      var n = document.createElement("td"), t = document.createElement("td");
56
      n.className = "n"; t.className = "t" + (side === "right" ? " mid" : "");
57
      if (!tr) { n.className += " none"; t.className += " none"; return [n, t]; }
58
      var kind = tr.classList.contains("add") ? "add" : tr.classList.contains("del") ? "del" : "ctx";
59
      if (kind !== "ctx") { n.className += " " + kind; t.className += " " + kind; }
60
      n.textContent = tr.cells[col].textContent;
61
      t.innerHTML = tr.cells[3].innerHTML;
62
      // Line comments belong to the projected (right) side only.
63
      if (side === "left") t.querySelectorAll(".linecomment").forEach(function (d) { d.remove(); });
64
      return [n, t];
65
    };
66
    var split = function (table) {
67
      var s = document.createElement("table");
68
      s.className = "code diff split";
69
      s.innerHTML = '<colgroup><col class="n"><col><col class="n"><col></colgroup>';
70
      var rows = [].slice.call(table.rows), i = 0;
71
      var add = function (l, r) {
72
        var tr = s.insertRow();
73
        pane(l, 0, "left").concat(pane(r, 1, "right")).forEach(function (c) { tr.appendChild(c); });
74
      };
75
      while (i < rows.length) {
76
        var r = rows[i];
77
        if (r.classList.contains("del") || r.classList.contains("add")) {
78
          var dels = [], adds = [];
79
          while (i < rows.length && rows[i].classList.contains("del")) dels.push(rows[i++]);
80
          while (i < rows.length && rows[i].classList.contains("add")) adds.push(rows[i++]);
81
          for (var k = 0; k < Math.max(dels.length, adds.length); k++) add(dels[k], adds[k]);
82
          continue;
83
        }
84
        if (r.classList.contains("gap") || r.querySelector("td.thread")) {
85
          var tr = s.insertRow(), td = tr.insertCell();
86
          tr.className = r.className; td.colSpan = 4; td.className = r.cells[r.cells.length - 1].className;
87
          td.innerHTML = r.cells[r.cells.length - 1].innerHTML;
88
        } else {
89
          add(r, r);
90
        }
91
        i++;
92
      }
93
      return s;
94
    };
95
    var cookieMode = (document.cookie.match(/(?:^|; )diff=(split|unified)/) || [])[1] || "unified";
96
    var apply = function (mode) {
97
      diffs.forEach(function (t) {
98
        if (mode === "split" && !t._split) { t._split = split(t); t.parentNode.insertBefore(t._split, t.nextSibling); }
99
        t.hidden = mode === "split";
100
        if (t._split) t._split.hidden = mode !== "split";
101
      });
102
      document.querySelectorAll("[data-diff-mode]").forEach(function (a) { a.classList.toggle("on", a.getAttribute("data-diff-mode") === mode); });
103
    };
104
    document.querySelectorAll("[data-diff-modes], [data-diff-collapse]").forEach(function (e) { e.hidden = false; });
105
    document.querySelectorAll("[data-diff-mode]").forEach(function (a) {
106
      a.addEventListener("click", function (ev) {
107
        ev.preventDefault();
108
        var mode = a.getAttribute("data-diff-mode");
109
        document.cookie = "diff=" + mode + "; path=/; max-age=31536000; samesite=lax";
110
        apply(mode);
111
      });
112
    });
113
    document.querySelectorAll("[data-diff-collapse]").forEach(function (a) {
114
      a.addEventListener("click", function (ev) {
115
        ev.preventDefault();
116
        var files = document.querySelectorAll("details.file-diff"), anyOpen = [].some.call(files, function (d) { return d.open; });
117
        files.forEach(function (d) { d.open = !anyOpen; });
118
        a.textContent = anyOpen ? "expand all" : "collapse all";
119
      });
120
    });
121
    apply(cookieMode);
122
  }
123
124
  /* Settings pages: mark the section in view in the list of sections. */
125
  var navLinks = document.querySelectorAll(".section-nav a[href^='#']");
126
  if (navLinks.length && "IntersectionObserver" in window) {
127
    var byId = {};
128
    navLinks.forEach(function (a) { byId[a.getAttribute("href").slice(1)] = a; });
129
    var io = new IntersectionObserver(function (entries) {
130
      entries.forEach(function (e) {
131
        if (e.isIntersecting && byId[e.target.id]) {
132
          navLinks.forEach(function (a) { a.classList.remove("on"); });
133
          byId[e.target.id].classList.add("on");
134
        }
135
      });
136
    }, { rootMargin: "0px 0px -70% 0px" });
137
    Object.keys(byId).forEach(function (id) { var el = document.getElementById(id); if (el) io.observe(el); });
138
  }
139
140
  /* Copy buttons. */
141
  document.querySelectorAll("[data-copy]").forEach(function (b) {
142
    b.addEventListener("click", function () {
143
      navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
144
        var t = b.textContent; b.textContent = "copied"; setTimeout(function () { b.textContent = t; }, 1200);
145
      });
146
    });
147
  });
148
149
  /* Tree rows: the last change per entry, fetched after the page paints. */
150
  var tree = document.querySelector("table.tree[data-last]");
151
  if (tree) {
152
    var paths = [].map.call(tree.querySelectorAll("tr[data-path]"), function (r) { return r.getAttribute("data-path"); });
153
    if (paths.length) {
154
      fetch(tree.getAttribute("data-last") + "?paths=" + encodeURIComponent(paths.join(",")), { credentials: "same-origin" })
155
        .then(function (r) { return r.json(); })
156
        .then(function (rows) {
157
          rows.forEach(function (c) {
158
            var tr = tree.querySelector('tr[data-path="' + CSS.escape(c.path) + '"]');
159
            if (!tr) return;
160
            var a = tr.querySelector("td.last a"), t = tr.querySelector("td.age");
161
            if (a) { a.textContent = c.title; a.href = tree.getAttribute("data-change") + c.hash; }
162
            if (t) { t.textContent = c.ago; t.title = c.when; }
163
          });
164
        }).catch(function () {});
165
    }
166
  }
167
168
  /* Line anchors: #L12 or #L12-L20 highlight a range and clicking a number selects. */
169
  function mark() {
170
    var m = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/);
171
    document.querySelectorAll("tr.hl").forEach(function (r) { r.classList.remove("hl"); });
172
    if (!m) { showBar(0, 0); return; }
173
    var a = +m[1], b = +(m[2] || m[1]);
174
    for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
175
      var row = document.getElementById("L" + i);
176
      if (row) row.classList.add("hl");
177
    }
178
    var first = document.getElementById("L" + Math.min(a, b));
179
    if (first && !mark.done) { first.scrollIntoView({ block: "center" }); mark.done = true; }
180
    showBar(Math.min(a, b), Math.max(a, b));
181
  }
182
  /* The bar for a selected range: copy its text, or a permalink to it. */
183
  var bar = document.querySelector(".linebar");
184
  function showBar(a, b) {
185
    if (!bar) return;
186
    bar.hidden = !a;
187
    if (!a) return;
188
    bar.querySelector("[data-linebar-label]").textContent = a === b ? "Line " + a : "Lines " + a + "–" + b;
189
    bar.setAttribute("data-range", a + "-" + b);
190
  }
191
  function flash(btn) { var t = btn.textContent; btn.textContent = "Copied"; setTimeout(function () { btn.textContent = t; }, 1200); }
192
  if (bar) {
193
    bar.querySelector("[data-copy-lines]").addEventListener("click", function (ev) {
194
      var r = bar.getAttribute("data-range").split("-"), out = [];
195
      for (var i = +r[0]; i <= +r[1]; i++) { var row = document.getElementById("L" + i); if (row) out.push(row.querySelector("td.t").textContent); }
196
      navigator.clipboard.writeText(out.join("\n") + "\n").then(function () { flash(ev.target); });
197
    });
198
    var pl = bar.querySelector("[data-copy-permalink]");
199
    if (pl) pl.addEventListener("click", function () {
200
      navigator.clipboard.writeText(new URL(document.body.getAttribute("data-permalink"), location.href).href + location.hash).then(function () { flash(pl); });
201
    });
202
  }
203
  function permalink() { var p = document.body.getAttribute("data-permalink"); return p ? p + location.hash : null; }
204
  window.addEventListener("hashchange", mark);
205
  mark();
206
  document.querySelectorAll("table.code td.n").forEach(function (td) {
207
    td.addEventListener("click", function (e) {
208
      var n = td.parentNode.id;
209
      if (e.shiftKey && location.hash.match(/^#L(\d+)/)) location.hash = location.hash.replace(/^#(L\d+).*/, "$1-" + n);
210
      else location.hash = n;
211
    });
212
  });
213
214
  /* Flow run page: statuses and new log lines while the run is live. */
215
  var liveUrl = document.body.getAttribute("data-flow-live");
216
  if (liveUrl) {
217
    var tick = function () {
218
      var after = [].map.call(document.querySelectorAll("details[data-job]"), function (d) {
219
        return d.getAttribute("data-job") + ":" + d.querySelector("[data-log]").getAttribute("data-seq");
220
      }).join(",");
221
      fetch(liveUrl + "?after=" + after, { headers: { accept: "application/json" } })
222
        .then(function (r) { return r.json(); })
223
        .then(function (s) {
224
          s.jobs.forEach(function (j) {
225
            var cls = "st-" + j.status;
226
            var node = document.querySelector("[data-job-node='" + j.id + "']");
227
            if (node) {
228
              node.setAttribute("class", "node " + cls);
229
              node.querySelector("[data-glyph]").textContent = j.glyph;
230
              node.querySelector("[data-meta]").textContent = j.status + " · " + j.duration;
231
            }
232
            var d = document.querySelector("details[data-job='" + j.id + "']");
233
            if (!d) return;
234
            d.className = "panel flow-job " + cls;
235
            var g = d.querySelector("summary [data-glyph]");
236
            g.className = "flow-glyph " + cls; g.textContent = j.glyph;
237
            d.querySelector("summary [data-meta]").textContent = j.status + (j.exit_code !== null ? " · exit " + j.exit_code : "") + " · " + j.duration;
238
            if (j.status === "running" || j.status === "failed") d.open = true;
239
            var log = d.querySelector("[data-log]");
240
            var stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
241
            j.lines.forEach(function (l) {
242
              var ln = document.createElement("span"); ln.className = "ln s-" + l.stream;
243
              var t = document.createElement("span"); t.className = "t"; t.textContent = (l.t / 1000).toFixed(1) + "s";
244
              ln.appendChild(t); ln.appendChild(document.createTextNode(l.text));
245
              log.appendChild(ln); log.appendChild(document.createTextNode("\n"));
246
              log.setAttribute("data-seq", l.seq);
247
            });
248
            if (stick && j.lines.length) log.scrollTop = log.scrollHeight;
249
          });
250
          var st = document.querySelector("[data-run-status]"); if (st) st.textContent = s.status;
251
          if (s.live) setTimeout(tick, 1500); else setTimeout(function () { location.reload(); }, 800);
252
        })
253
        .catch(function () { setTimeout(tick, 5000); });
254
    };
255
    setTimeout(tick, 1500);
256
  }
257
258
  /* Keyboard: / focuses search; g c / g p / g i jump; ? shows the sheet. */
259
  var pending = null;
260
  document.addEventListener("keydown", function (e) {
261
    if (e.target && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
262
    if (e.key === "/") { var s = document.querySelector("input[name=q]"); if (s) { e.preventDefault(); s.focus(); } return; }
263
    if (e.key === "y" && !e.ctrlKey && !e.metaKey && !e.altKey && permalink()) { history.replaceState(null, "", permalink()); pending = null; return; }
264
    if (e.key === "?") { var k = document.getElementById("keys"); if (k) k.hidden = !k.hidden; return; }
265
    var repo = document.body.getAttribute("data-repo");
266
    if (pending === "g" && repo) {
267
      var to = { c: "", p: "/proposals", i: "/issues", h: "/changes/" + (document.body.getAttribute("data-ref") || "main"), b: "/bookmarks", t: "/trust" }[e.key];
268
      if (to !== undefined) location.href = repo + to;
269
      pending = null; return;
270
    }
271
    pending = e.key === "g" ? "g" : null;
272
  });
273
274
  // Live conflict resolution (hub spec §6.10): who else is resolving this
275
  // conflict, their choices region by region (the last one wins), and the
276
  // resolution when someone commits it.
277
  var live = document.querySelector("[data-resolve-live]");
278
  if (live && window.EventSource && window.fetch) {
279
    var url = live.getAttribute("data-resolve-live"), csrf = live.getAttribute("data-csrf");
280
    var client = Math.random().toString(36).slice(2) + Date.now().toString(36);
281
    var present = document.querySelector("[data-live-present]"), landed = document.querySelector("[data-live-landed]");
282
    var applying = false;
283
    var fieldsOf = function (region) {
284
      return region === "keep"
285
        ? { radios: live.querySelectorAll('input[name="keep"]'), text: null }
286
        : { radios: live.querySelectorAll('input[name="r' + region + '"]'), text: live.querySelector('textarea[name="t' + region + '"]') };
287
    };
288
    var apply = function (region, c) {
289
      var f = fieldsOf(region);
290
      applying = true;
291
      f.radios.forEach(function (r) { r.checked = r.value === c.choice; });
292
      if (f.text && c.choice === "text") f.text.value = c.text;
293
      applying = false;
294
      var sec = f.radios.length ? f.radios[0].closest("section") : null;
295
      if (sec) sec.setAttribute("title", "chosen by " + c.by);
296
    };
297
    var send = function (region, choice, text) {
298
      fetch(url, { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
299
        body: JSON.stringify({ _csrf: csrf, client: client, region: region, choice: choice, text: text || "" }) });
300
    };
301
    var timers = {};
302
    live.addEventListener("change", function (ev) {
303
      var t = ev.target;
304
      if (applying || !t.name || t.type !== "radio") return;
305
      var region = t.name === "keep" ? "keep" : t.name.slice(1);
306
      var ta = live.querySelector('textarea[name="t' + region + '"]');
307
      send(region, t.value, t.value === "text" && ta ? ta.value : "");
308
    });
309
    live.addEventListener("input", function (ev) {
310
      var t = ev.target;
311
      if (applying || t.tagName !== "TEXTAREA") return;
312
      var region = t.name.slice(1);
313
      live.querySelectorAll('input[name="r' + region + '"]').forEach(function (r) { r.checked = r.value === "text"; });
314
      clearTimeout(timers[region]);
315
      timers[region] = setTimeout(function () { send(region, "text", t.value); }, 400);
316
    });
317
    var es = new EventSource(url + "?client=" + encodeURIComponent(client));
318
    var showPresent = function (names) {
319
      present.hidden = names.length < 2;
320
      present.textContent = "Resolving together: " + names.join(", ") + ". Choices are shared as you make them; the last choice for a conflict wins.";
321
    };
322
    es.addEventListener("snapshot", function (e) {
323
      var d = JSON.parse(e.data);
324
      showPresent(d.present);
325
      Object.keys(d.choices).forEach(function (r) { apply(r, d.choices[r]); });
326
      if (d.keep) apply("keep", d.keep);
327
    });
328
    es.addEventListener("presence", function (e) { showPresent(JSON.parse(e.data).present); });
329
    es.addEventListener("choice", function (e) {
330
      var d = JSON.parse(e.data);
331
      if (d.client !== client) apply(d.region, d.choice);
332
    });
333
    es.addEventListener("landed", function (e) {
334
      var d = JSON.parse(e.data);
335
      es.close();
336
      landed.hidden = false;
337
      landed.textContent = "";
338
      landed.appendChild(document.createTextNode(d.by + " committed the resolution: "));
339
      var a = document.createElement("a");
340
      a.href = d.url;
341
      a.textContent = "see the change";
342
      landed.appendChild(a);
343
      live.querySelectorAll("input, textarea, button").forEach(function (x) { x.disabled = true; });
344
    });
345
  }
346
347
  // Swimlanes of a proposal's bookmark (hub spec §6.7): drag a change onto
348
  // another to plan a squash or a rebase. The hub only writes the plan;
349
  // the author's client carries it out and signs it.
350
  var plan = document.querySelector("[data-plan]");
351
  if (plan && window.fetch) {
352
    var panel = document.querySelector("[data-plan-panel]"), from = null, moved = false;
353
    plan.querySelectorAll("[data-change]").forEach(function (a) {
354
      a.style.cursor = "grab";
355
      a.addEventListener("pointerdown", function (ev) { from = a.getAttribute("data-change"); moved = false; ev.preventDefault(); });
356
      a.addEventListener("pointermove", function () { if (from) moved = true; });
357
      a.addEventListener("click", function (ev) { if (moved) ev.preventDefault(); });
358
    });
359
    document.addEventListener("pointerup", function (ev) {
360
      var start = from;
361
      from = null;
362
      if (!start) return;
363
      var el = document.elementFromPoint(ev.clientX, ev.clientY), onto = el && el.closest ? el.closest("[data-change]") : null;
364
      if (!onto || onto.getAttribute("data-change") === start) return;
365
      var body = new URLSearchParams({ _csrf: plan.getAttribute("data-csrf"), change: start, target: onto.getAttribute("data-change") });
366
      fetch(plan.getAttribute("data-plan"), { method: "POST", credentials: "same-origin", body: body })
367
        .then(function (r) { return r.json(); })
368
        .then(function (d) {
369
          panel.hidden = false;
370
          panel.textContent = "";
371
          if (d.error) { panel.textContent = d.error; return; }
372
          d.options.forEach(function (o) {
373
            var p = document.createElement("p");
374
            p.appendChild(document.createTextNode(o.summary + " "));
375
            var a = document.createElement("a");
376
            a.href = o.vscode;
377
            a.textContent = "Apply in VS Code";
378
            p.appendChild(a);
379
            var code = document.createElement("pre");
380
            code.className = "snippet";
381
            code.textContent = o.command;
382
            panel.appendChild(p);
383
            panel.appendChild(code);
384
          });
385
        });
386
    });
387
  }
388
})();