Skip to content
quarry Artifact [8f50cbdab2]
Login

Artifact [8f50cbdab2]

Artifact 8f50cbdab2bbfec47ecc709d235e994b65c4b405033b47b780717fbbceed1c32:


// meld.js — draws what a conflicting landing's three panes cannot draw
// for themselves: the bands joining each marked block in ours or theirs
// to its place in the result, the overview strip down the right edge,
// and stepping from one conflict to the next. The panes are complete
// without it; the server already marked every block (meld.go).
//
// Where the reader may resolve (#343) it also keeps their answers: an
// arrow takes a side's lines into the result, "both" keeps the two one
// after the other, "neither" keeps what both started from, "edit" takes
// typed lines, and a file that is not text is answered whole. Commit
// sends the answers to landing.resolve; quarry rebuilds each file from
// the merge's own regions, so nothing here has to hold a whole file.
(function () {
  "use strict";

  var colour = { cf: "var(--meld-cf)", chg: "var(--meld-chg)", ins: "var(--meld-ins)" };
  function kindOf(el) {
    if (el.classList.contains("cf")) { return "cf"; }
    if (el.classList.contains("chg")) { return "chg"; }
    if (el.classList.contains("ins")) { return "ins"; }
    return "";
  }

  function mount(m, resolving) {
    var file = m.closest(".meld-file");
    var pane = function (p) { return m.querySelector('[data-pane="' + p + '"]'); };
    var seg = function (p, id) { return pane(p).querySelector('.seg[data-id="' + id + '"]'); };
    var conflicts = Array.prototype.slice.call(pane("M").querySelectorAll(".seg.cf"));
    var cur = -1;

    function bands() {
      ["L", "R"].forEach(function (side) {
        var g = m.querySelector('[data-gut="' + side + '"]'), svg = g.querySelector("svg");
        g.querySelectorAll(".gbtn").forEach(function (b) { b.remove(); });
        if (getComputedStyle(g).display === "none") { svg.innerHTML = ""; return; }
        var gr = g.getBoundingClientRect(), w = gr.width, h = w / 2, out = "";
        pane(side).querySelectorAll(".seg.cf, .seg.chg, .seg.ins").forEach(function (a) {
          var mid = seg("M", a.dataset.id);
          if (!mid) { return; }
          var ar = a.getBoundingClientRect(), mr = mid.getBoundingClientRect();
          var x0 = side === "L" ? 0 : w, x1 = side === "L" ? w : 0;
          var ya = ar.top - gr.top, yb = Math.max(ar.bottom - gr.top, ya + 1);
          var ma = mr.top - gr.top, mb = Math.max(mr.bottom - gr.top, ma + 1);
          var c = colour[kindOf(a)];
          out += '<path d="M' + x0 + "," + ya + " C" + h + "," + ya + " " + h + "," + ma + " " + x1 + "," + ma +
            " L" + x1 + "," + mb + " C" + h + "," + mb + " " + h + "," + yb + " " + x0 + "," + yb +
            ' Z" fill="' + c + '" fill-opacity=".28" stroke="' + c + '" stroke-opacity=".7"/>';
          // Every block that differs from the result gets an arrow, as in
          // Meld: on a conflict it settles it, elsewhere it puts this
          // side's lines back over the merge's.
          if (resolving && a.dataset.region) {
            var b = document.createElement("button");
            b.type = "button";
            b.className = "gbtn";
            b.textContent = side === "L" ? "→" : "←";
            b.title = "Take " + (side === "L" ? "ours" : "theirs") + " here";
            b.setAttribute("aria-label", b.title);
            b.dataset.act = side;
            b.dataset.region = a.dataset.region;
            b.style.top = (ya + 2) + "px";
            b.style[side === "L" ? "left" : "right"] = "2px";
            g.appendChild(b);
          }
        });
        svg.innerHTML = out;
      });
      overview();
    }

    // The strip maps the whole result: each marked block at its place,
    // the current conflict outlined; a mark jumps there.
    function overview() {
      var o = m.querySelector(".ovr");
      if (!o || getComputedStyle(o).display === "none") { return; }
      o.innerHTML = "";
      var inner = pane("M").querySelector(".inner"), ir = inner.getBoundingClientRect();
      var head = pane("M").querySelector(".phead").getBoundingClientRect().height;
      var span = o.getBoundingClientRect().height - head;
      if (ir.height <= 0 || span <= 0) { return; }
      pane("M").querySelectorAll(".seg.cf, .seg.chg, .seg.ins").forEach(function (s) {
        var r = s.getBoundingClientRect(), i = document.createElement("i");
        i.style.top = (head + (r.top - ir.top) / ir.height * span) + "px";
        i.style.height = Math.max(3, r.height / ir.height * span) + "px";
        i.style.background = colour[kindOf(s)];
        if (conflicts[cur] === s) { i.className = "cur"; }
        i.title = kindOf(s) === "cf" ? "conflict" : "change";
        i.addEventListener("click", function () {
          var k = conflicts.indexOf(s);
          if (k >= 0) { go(k); } else { s.scrollIntoView({ block: "center", behavior: "smooth" }); }
        });
        o.appendChild(i);
      });
    }

    function go(k) {
      if (!conflicts.length) { return; }
      cur = (k + conflicts.length) % conflicts.length;
      conflicts.forEach(function (c, i) { c.classList.toggle("cur", i === cur); });
      var pos = file.querySelector("[data-meld-pos]");
      if (pos) { pos.textContent = "conflict " + (cur + 1) + " of " + conflicts.length; }
      conflicts[cur].scrollIntoView({ block: "center", behavior: "smooth" });
      overview();
    }
    file.querySelectorAll("[data-meld-step]").forEach(function (b) {
      b.addEventListener("click", function () { go(cur < 0 ? (b.dataset.meldStep > 0 ? 0 : -1) : cur + Number(b.dataset.meldStep)); });
    });

    // A long line scrolls its own pane sideways; the conflict label
    // stays as wide as the pane it sits in.
    function fit() {
      var w = pane("M").clientWidth;
      pane("M").querySelectorAll(".tagline").forEach(function (t) { t.style.width = w + "px"; });
      bands();
    }
    ["L", "M", "R"].forEach(function (p) { pane(p).addEventListener("scroll", bands, { passive: true }); });
    window.addEventListener("resize", fit);
    if (document.fonts && document.fonts.ready) { document.fonts.ready.then(fit); }
    m.meldRedraw = fit;
    fit();
  }

  // ---- resolving (#343) ----------------------------------------------

  function resolver(view) {
    var answers = {};
    var key = view.dataset.draft;
    var files = Array.prototype.slice.call(view.querySelectorAll("[data-hook=meld-file]"));
    var form = view.querySelector("[data-hook=meld-commit]");
    // What the result showed before any answer: put back on Reset or
    // Change, and "neither" takes its lines.
    // Keyed by file and region, not by element: drawing an answer
    // replaces the result's segment.
    var orig = {};
    files.forEach(function (f) {
      f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (s) {
        orig[f.dataset.path + "\u0000" + s.dataset.region] = s.cloneNode(true);
      });
    });
    function origOf(path, region) { return orig[path + "\u0000" + region]; }
    // Each side's marks as the server drew them, put back when an
    // answer is withdrawn.
    var sideCls = {};
    files.forEach(function (f) {
      ["L", "R"].forEach(function (p) {
        f.querySelectorAll('[data-pane="' + p + '"] .seg[data-region]').forEach(function (s) {
          sideCls[f.dataset.path + "\u0000" + s.dataset.region + "\u0000" + p] = s.className;
        });
      });
    });

    function pathOf(el) { return el.closest("[data-hook=meld-file]").dataset.path; }
    function fileOf(path) {
      return files.filter(function (f) { return f.dataset.path === path; })[0];
    }
    function segOf(f, pane, region) {
      return f.querySelector('[data-pane="' + pane + '"] .seg[data-region="' + region + '"]');
    }
    function lineNodes(s) { return s ? Array.prototype.slice.call(s.querySelectorAll(":scope > .l")) : []; }
    function texts(nodes) { return nodes.map(function (l) { return l.querySelector(".t").textContent; }); }
    function plain(lines) {
      return lines.map(function (t) {
        var l = document.createElement("div"), n = document.createElement("span"), x = document.createElement("span");
        l.className = "l";
        n.className = "n";
        x.className = "t";
        x.textContent = t;
        l.appendChild(n);
        l.appendChild(x);
        return l;
      });
    }
    var said = { L: "ours", R: "theirs", LR: "both · ours first", RL: "both · theirs first", N: "neither", E: "your edit" };

    // answer records one choice; the lines travel with it, because
    // landing.resolve takes lines, not choices.
    function answer(path, region, act, typed) {
      var f = fileOf(path), L = segOf(f, "L", region), R = segOf(f, "R", region);
      var lines;
      switch (act) {
        case "L": lines = texts(lineNodes(L)); break;
        case "R": lines = texts(lineNodes(R)); break;
        case "LR": lines = texts(lineNodes(L)).concat(texts(lineNodes(R))); break;
        case "RL": lines = texts(lineNodes(R)).concat(texts(lineNodes(L))); break;
        case "N": lines = texts(lineNodes(origOf(path, region))); break;
        case "E": lines = typed; break;
        default: return;
      }
      answers[path] = answers[path] || { take: "regions", regions: {} };
      answers[path].regions[region] = { act: act, lines: lines };
      changed();
    }

    function drawRegion(f, region) {
      var m = segOf(f, "M", region), a = (answers[f.dataset.path] || { regions: {} }).regions[region];
      var fresh = origOf(f.dataset.path, region).cloneNode(true);
      if (a) {
        var L = segOf(f, "L", region), R = segOf(f, "R", region), nodes;
        switch (a.act) {
          case "L": nodes = lineNodes(L); break;
          case "R": nodes = lineNodes(R); break;
          case "LR": nodes = lineNodes(L).concat(lineNodes(R)); break;
          case "RL": nodes = lineNodes(R).concat(lineNodes(L)); break;
          case "N": nodes = lineNodes(origOf(f.dataset.path, region)); break;
          default: nodes = null;
        }
        lineNodes(fresh).forEach(function (l) { l.remove(); });
        (nodes ? nodes.map(function (n) { return n.cloneNode(true); }) : plain(a.lines)).forEach(function (l) { fresh.appendChild(l); });
        fresh.classList.remove("cf", "chg", "ins", "gap", "cur");
        fresh.classList.add("done");
        var tag = fresh.querySelector(".tagline");
        if (!tag) {
          tag = document.createElement("div");
          tag.className = "tagline";
          fresh.insertBefore(tag, fresh.firstChild);
        }
        tag.innerHTML = "";
        var t = document.createElement("span");
        t.textContent = "took " + said[a.act];
        var undo = document.createElement("button");
        undo.type = "button";
        undo.className = "both";
        undo.dataset.act = "U";
        undo.textContent = "change";
        tag.appendChild(t);
        tag.appendChild(undo);
      }
      m.replaceWith(fresh);
      // As in Meld, a side is marked where it differs from the result:
      // once a region is answered, by how it differs from the answer.
      var mine = texts(lineNodes(fresh)).join("\n");
      ["L", "R"].forEach(function (p) {
        var side = segOf(f, p, region);
        if (!side) { return; }
        if (!a) {
          side.className = sideCls[f.dataset.path + "\u0000" + region + "\u0000" + p];
          return;
        }
        var lines = texts(lineNodes(side));
        side.className = "seg";
        if (lines.join("\n") !== mine || lines.length !== lineNodes(fresh).length) {
          side.classList.add(!lines.length || !lineNodes(fresh).length ? "ins" : "chg");
          if (!lines.length) { side.classList.add("gap"); }
        }
      });
    }

    // Each pane numbers its own lines; the result renumbers as answers
    // change its length, counting a fold as the lines it stands for.
    function renumber(f) {
      var n = 1;
      f.querySelectorAll('[data-pane="M"] .seg').forEach(function (s) {
        if (s.dataset.fold) { n += Number(s.dataset.fold); return; }
        lineNodes(s).forEach(function (l) { l.querySelector(".n").textContent = n++; });
      });
    }

    function drawPhone(f) {
      f.querySelectorAll("[data-hook=phone-conflict]").forEach(function (pc) {
        var region = pc.dataset.region, a = (answers[f.dataset.path] || { regions: {} }).regions[region];
        var took = pc.querySelector("[data-took]"), acts = pc.querySelector("[data-acts]");
        pc.classList.toggle("pdone", !!a);
        pc.querySelectorAll(".pb, .pbase").forEach(function (b) { b.hidden = !!a; });
        acts.hidden = !!a;
        took.hidden = !a;
        took.innerHTML = "";
        if (!a) { return; }
        // What it took, in place in the result: three lines either side,
        // numbered in the result file.
        var m = segOf(f, "M", region), all = Array.prototype.slice.call(f.querySelectorAll('[data-pane="M"] .l'));
        var mine = lineNodes(m), first = all.indexOf(mine[0]), last = all.indexOf(mine[mine.length - 1]);
        if (first < 0) {
          var prev = m.previousElementSibling;
          while (prev && !lineNodes(prev).length) { prev = prev.previousElementSibling; }
          first = last = prev ? all.indexOf(lineNodes(prev).slice(-1)[0]) + 1 : 0;
          last = first - 1;
        }
        var head = document.createElement("div");
        head.className = "pbl";
        head.textContent = "took " + said[a.act];
        var body = document.createElement("div");
        body.className = "pbc chroma";
        all.slice(Math.max(0, first - 3), first).forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pctx"); body.appendChild(c); });
        mine.forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pconf"); body.appendChild(c); });
        if (!mine.length) { body.appendChild(plain(["(nothing here now)"])[0]); }
        all.slice(last + 1, last + 4).forEach(function (l) { var c = l.cloneNode(true); c.classList.add("pctx"); body.appendChild(c); });
        var change = document.createElement("button");
        change.type = "button";
        change.className = "btn-quiet";
        change.dataset.act = "U";
        change.textContent = "Change";
        took.appendChild(head);
        took.appendChild(body);
        took.appendChild(change);
      });
    }

    function drawWhole(f) {
      var w = f.querySelector("[data-whole-file]");
      if (!w) { return; }
      var a = answers[f.dataset.path];
      w.querySelector("[data-whole-took]").textContent = a ? "took " + (a.take === "delete" ? "delete" : a.take) : "";
      w.querySelectorAll("[data-whole]").forEach(function (b) { b.setAttribute("aria-pressed", String(!!a && a.take === b.dataset.whole)); });
    }

    function open() {
      var left = 0;
      files.forEach(function (f) {
        if (f.querySelector("[data-whole-file]")) {
          if (!answers[f.dataset.path]) { left++; }
          return;
        }
        f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) {
          var o = origOf(f.dataset.path, m.dataset.region) || m;
          if (o.classList.contains("cf") && !(answers[f.dataset.path] && answers[f.dataset.path].regions[m.dataset.region])) { left++; }
        });
      });
      return left;
    }

    function changed() {
      files.forEach(function (f) {
        if (f.querySelector("[data-whole-file]")) { drawWhole(f); return; }
        f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) { drawRegion(f, m.dataset.region); });
        renumber(f);
        drawPhone(f);
        var meld = f.querySelector("[data-meld]");
        if (meld && meld.meldRedraw) { meld.meldRedraw(); }
      });
      var left = open();
      if (form) {
        form.querySelector("[data-meld-left]").textContent = left ? left + " left to answer" : "every conflict answered";
        form.querySelector("[data-meld-submit]").disabled = left > 0;
      }
      try { localStorage.setItem(key, JSON.stringify(answers)); } catch (e) { /* a draft is a convenience */ }
    }

    function edit(path, region, host) {
      var f = fileOf(path), a = (answers[path] || { regions: {} }).regions[region];
      var start = a ? a.lines : texts(lineNodes(segOf(f, "L", region))).concat(texts(lineNodes(segOf(f, "R", region))));
      var box = document.createElement("div");
      box.className = "medit";
      var ta = document.createElement("textarea");
      ta.className = "ped";
      ta.rows = Math.max(3, start.length + 1);
      ta.spellcheck = false;
      ta.value = start.join("\n");
      var use = document.createElement("button");
      use.type = "button";
      use.className = "btn-primary";
      use.textContent = "Use this";
      var cancel = document.createElement("button");
      cancel.type = "button";
      cancel.className = "btn-quiet";
      cancel.textContent = "Cancel";
      box.appendChild(ta);
      box.appendChild(cancel);
      box.appendChild(use);
      host.appendChild(box);
      ta.focus();
      cancel.addEventListener("click", function () { box.remove(); });
      use.addEventListener("click", function () {
        box.remove();
        answer(path, region, "E", ta.value === "" ? [] : ta.value.split("\n"));
      });
    }

    view.addEventListener("click", function (e) {
      var b = e.target.closest("button");
      if (!b || !view.contains(b)) { return; }
      var path = b.closest("[data-hook=meld-file]") ? pathOf(b) : null;
      if (b.dataset.whole) {
        answers[path] = { take: b.dataset.whole };
        return changed();
      }
      if (b.dataset.takeAll) {
        var f = fileOf(path);
        f.querySelectorAll('[data-pane="M"] .seg[data-region]').forEach(function (m) {
          var have = answers[path] && answers[path].regions[m.dataset.region];
          if (origOf(path, m.dataset.region).classList.contains("cf") && !have) { answer(path, m.dataset.region, b.dataset.takeAll); }
        });
        return;
      }
      if (b.hasAttribute("data-meld-reset")) {
        answers = {};
        return changed();
      }
      var act = b.dataset.act;
      if (!act) { return; }
      var region = b.dataset.region || (b.closest("[data-region]") || {}).dataset.region;
      if (region === undefined) { return; }
      if (act === "U") {
        delete answers[path].regions[region];
        return changed();
      }
      if (act === "E") {
        var host = b.closest("[data-hook=phone-conflict]") || b.closest(".seg");
        return edit(path, region, host);
      }
      answer(path, region, act);
    });

    if (form) {
      form.addEventListener("submit", function (e) {
        if (open() > 0) { e.preventDefault(); return; }
        var out = [];
        files.forEach(function (f) {
          var a = answers[f.dataset.path];
          if (f.querySelector("[data-whole-file]")) {
            out.push({ path: f.dataset.path, take: a.take });
            return;
          }
          var regions = [];
          Object.keys((a && a.regions) || {}).forEach(function (k) {
            regions.push({ index: Number(k), lines: a.regions[k].lines });
          });
          out.push({ path: f.dataset.path, take: "regions", regions: regions });
        });
        form.querySelector('[name="answers"]').value = JSON.stringify({
          branchTip: view.dataset.branchTip, targetTip: view.dataset.targetTip, files: out });
        try { localStorage.removeItem(key); } catch (err) { /* nothing kept */ }
      });
    }

    // A draft survives a reload, for this landing and these two tips only:
    // once either moves the regions are different ones, and the old draft
    // is dropped rather than applied to them.
    try {
      var stem = key.split("/").slice(0, 2).join("/") + "/";
      for (var i = localStorage.length - 1; i >= 0; i--) {
        var k = localStorage.key(i);
        if (k && k.indexOf(stem) === 0 && k !== key) { localStorage.removeItem(k); }
      }
      var kept = JSON.parse(localStorage.getItem(key) || "null");
      if (kept && typeof kept === "object") { answers = kept; }
    } catch (e) { /* start from nothing */ }
    changed();
  }

  document.querySelectorAll("[data-meld]").forEach(function (m) {
    mount(m, !!m.closest("[data-resolve]"));
  });
  document.querySelectorAll("[data-resolve]").forEach(resolver);
})();