Skip to content
quarry Artifact [0be4705c2c]
Login

Artifact [0be4705c2c]

Artifact 0be4705c2c19200b5f74e37a1754a030bb1a06d2d75bfda28aeba1e01c98b32a:


package web

import (
	"html/template"
	"net/url"
	"strconv"
	"strings"

	"craftdesign.group/quarry/internal/highlight"
)

// A conflicting landing shows its merge in three panes, Meld's way
// (#342, Option C of the "Landing conflict hunks" mockup): ours — the
// branch being landed — on the left, theirs — its target — on the
// right, and the result between them, which starts from what both sides
// started from. Changes one side made alone are already in the result;
// a conflict shows the common ancestor's lines there until somebody
// settles it. A side is marked wherever it differs from the result, so
// a band joins two panes only where they differ. Under 640px wide the
// page shows each conflict stacked instead: ours, the common ancestor,
// theirs, each with its own surroundings.
//
// Every region comes from zit's merge report through landing.conflicts.
// This file only lays out what zit said.

// meldContext is how many unchanged lines stay on either side of a
// change before the rest folds away. A store's large files are tens of
// thousands of lines, and three panes of all of them is a page nobody
// can read.
const meldContext = 6

// Phone context: shown, and with "More context".
const (
	phoneContext     = 3
	phoneMoreContext = 13
)

// conflictsReport is landing.conflicts' answer, as the page reads it.
type conflictsReport struct {
	Branch    string `json:"branch"`
	Target    string `json:"target"`
	BranchTip string `json:"branchTip"`
	TargetTip string `json:"targetTip"`
	Verdict   string `json:"verdict"`
	Files     []struct {
		Path    string `json:"path"`
		Kind    string `json:"kind"`
		Omitted string `json:"omitted"`
		Ours    struct {
			Lines []string `json:"lines"`
		} `json:"ours"`
		Theirs struct {
			Lines []string `json:"lines"`
		} `json:"theirs"`
		Base struct {
			Lines []string `json:"lines"`
		} `json:"base"`
		Regions []struct {
			Kind   string     `json:"kind"`
			Base   reportSpan `json:"base"`
			Ours   reportSpan `json:"ours"`
			Theirs reportSpan `json:"theirs"`
		} `json:"regions"`
	} `json:"files"`
}

// reportSpan is a region's range as the op sends it.
type reportSpan struct {
	Start int `json:"start"`
	End   int `json:"end"`
}

func (s reportSpan) pair() [2]int { return [2]int{s.Start, s.End} }

type meldView struct {
	Branch, Target       string
	BranchTip, TargetTip string
	Files                []meldFile
}

type meldFile struct {
	Path string
	// Omitted is why the file is listed rather than drawn.
	Omitted string
	// OursURL and TheirsURL open the file at each tip.
	OursURL, TheirsURL string
	Segs               []meldSeg
	Conflicts          int
	// FromOurs and FromTheirs count the changes merged on their own.
	FromOurs, FromTheirs int
	Phone                []phoneConflict
}

// meldSeg is one stretch of the file in all three panes at once, so
// the script can join a side's block to the result's by ID.
type meldSeg struct {
	ID string
	// Fold, when set, stands for that many unchanged lines.
	Fold    int
	L, M, R []meldLine
	// Classes, per pane: cf (a conflict), chg (differs from the
	// result), ins (one has lines the other lacks), plus gap for a
	// marked block with no lines of its own.
	LCls, MCls, RCls string
	// Tag labels an open conflict in the result pane.
	Tag string
}

type meldLine struct {
	N    int
	HTML template.HTML
}

type phoneConflict struct {
	ID                   string
	OursLine, TheirsLine int
	Ours, Base, Theirs   phoneBlock
	More                 bool
}

type phoneBlock struct {
	Lines []phoneLine
	// Empty says what an empty side means.
	Empty string
}

type phoneLine struct {
	N    int
	HTML template.HTML
	// Ctx is a surrounding line; Extra is one shown only with More
	// context.
	Ctx, Extra bool
}

// buildMeld lays out a conflicts report. base is the repository's
// address, which the file links hang from.
func buildMeld(rep conflictsReport, base string) *meldView {
	v := &meldView{Branch: rep.Branch, Target: rep.Target, BranchTip: rep.BranchTip, TargetTip: rep.TargetTip}
	for fi, f := range rep.Files {
		mf := meldFile{Path: f.Path, Omitted: f.Omitted,
			OursURL:   base + "/file/" + rep.BranchTip + "/" + escapePath(f.Path),
			TheirsURL: base + "/file/" + rep.TargetTip + "/" + escapePath(f.Path)}
		if f.Omitted == "" {
			ours := highlightAll(f.Ours.Lines, f.Path)
			theirs := highlightAll(f.Theirs.Lines, f.Path)
			anc := highlightAll(f.Base.Lines, f.Path)
			regions := make([]meldRegion, len(f.Regions))
			for i, r := range f.Regions {
				regions[i] = meldRegion{r.Kind, r.Base.pair(), r.Ours.pair(), r.Theirs.pair()}
			}
			mf.Segs, mf.Conflicts, mf.FromOurs, mf.FromTheirs = meldSegs(fi, regions, ours, theirs, anc)
			mf.Phone = phoneConflicts(fi, regions, ours, theirs, anc)
		}
		v.Files = append(v.Files, mf)
	}
	return v
}

type meldRegion struct {
	Kind               string
	Base, Ours, Theirs [2]int
}

// meldSegs lays the regions out as aligned segments. Each pane numbers
// its lines in its own file; the result counts as it goes.
func meldSegs(fi int, regions []meldRegion, ours, theirs, anc []template.HTML) (segs []meldSeg, conflicts, fromOurs, fromTheirs int) {
	resultN := 1
	lines := func(src []template.HTML, r [2]int, first int) []meldLine {
		var out []meldLine
		for i := r[0]; i < r[1] && i < len(src); i++ {
			out = append(out, meldLine{N: first + i - r[0], HTML: src[i]})
		}
		return out
	}
	result := func(src []template.HTML, r [2]int) []meldLine {
		ls := lines(src, r, resultN)
		resultN += len(ls)
		return ls
	}
	id := func() string { return "m" + strconv.Itoa(fi) + "-" + strconv.Itoa(len(segs)) }
	for i, r := range regions {
		switch r.Kind {
		case "same":
			n := r.Ours[1] - r.Ours[0]
			head, tail := meldContext, meldContext
			if i == 0 {
				head = 0
			}
			if i == len(regions)-1 {
				tail = 0
			}
			if n-head-tail < 2 {
				segs = append(segs, meldSeg{ID: id(), L: lines(ours, r.Ours, r.Ours[0]+1),
					M: result(ours, r.Ours), R: lines(theirs, r.Theirs, r.Theirs[0]+1)})
				continue
			}
			if head > 0 {
				o, t := [2]int{r.Ours[0], r.Ours[0] + head}, [2]int{r.Theirs[0], r.Theirs[0] + head}
				segs = append(segs, meldSeg{ID: id(), L: lines(ours, o, o[0]+1), M: result(ours, o), R: lines(theirs, t, t[0]+1)})
			}
			fold := n - head - tail
			segs = append(segs, meldSeg{ID: id(), Fold: fold})
			resultN += fold
			if tail > 0 {
				o, t := [2]int{r.Ours[1] - tail, r.Ours[1]}, [2]int{r.Theirs[1] - tail, r.Theirs[1]}
				segs = append(segs, meldSeg{ID: id(), L: lines(ours, o, o[0]+1), M: result(ours, o), R: lines(theirs, t, t[0]+1)})
			}
		case "ours", "theirs", "conflict":
			s := meldSeg{ID: id(), L: lines(ours, r.Ours, r.Ours[0]+1), R: lines(theirs, r.Theirs, r.Theirs[0]+1)}
			switch r.Kind {
			case "ours":
				s.M = result(ours, r.Ours)
				fromOurs++
			case "theirs":
				s.M = result(theirs, r.Theirs)
				fromTheirs++
			default:
				s.M = result(anc, r.Base)
				conflicts++
				s.Tag = "conflict · common ancestor shown"
				if r.Base[0] == r.Base[1] {
					s.Tag = "conflict · both added here"
				}
			}
			s.LCls, s.RCls, s.MCls = meldClasses(r.Kind, s.L, s.M, s.R)
			if s.Tag != "" {
				// The label gives an open conflict its height even
				// where the ancestor had no lines.
				s.MCls = strings.TrimSuffix(s.MCls, " gap")
			}
			segs = append(segs, s)
		}
	}
	return segs, conflicts, fromOurs, fromTheirs
}

// meldClasses marks each pane of a changed segment as Meld does: a side
// is marked where it differs from the result, red for a conflict, green
// when one of the two has no lines there, blue otherwise; the result is
// marked as the side that differs from it.
func meldClasses(kind string, l, m, r []meldLine) (lc, rc, mc string) {
	mark := func(side []meldLine) string {
		if kind == "conflict" {
			return "cf"
		}
		if sameLines(side, m) {
			return ""
		}
		if len(side) == 0 || len(m) == 0 {
			return "ins"
		}
		return "chg"
	}
	lc, rc = mark(l), mark(r)
	mc = lc
	if mc == "" {
		mc = rc
	}
	gap := func(cls string, ls []meldLine) string {
		if cls != "" && len(ls) == 0 {
			return cls + " gap"
		}
		return cls
	}
	return gap(lc, l), gap(rc, r), gap(mc, m)
}

func sameLines(a, b []meldLine) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i].HTML != b[i].HTML {
			return false
		}
	}
	return true
}

// phoneConflicts lays each conflict out for a phone: ours, the common
// ancestor and theirs, each with the lines around it in its own file,
// stopping at a neighbouring conflict.
func phoneConflicts(fi int, regions []meldRegion, ours, theirs, anc []template.HTML) []phoneConflict {
	var cf []int
	for i, r := range regions {
		if r.Kind == "conflict" {
			cf = append(cf, i)
		}
	}
	var out []phoneConflict
	for k, i := range cf {
		r := regions[i]
		bound := func(pick func(meldRegion) [2]int, n int) (lo, hi int) {
			lo, hi = 0, n
			if k > 0 {
				lo = pick(regions[cf[k-1]])[1]
			}
			if k < len(cf)-1 {
				hi = pick(regions[cf[k+1]])[0]
			}
			return lo, hi
		}
		block := func(src []template.HTML, rg [2]int, pick func(meldRegion) [2]int, empty string) phoneBlock {
			lo, hi := bound(pick, len(src))
			b := phoneBlock{Empty: empty}
			for j := max(lo, rg[0]-phoneMoreContext); j < rg[0]; j++ {
				b.Lines = append(b.Lines, phoneLine{N: j + 1, HTML: src[j], Ctx: true, Extra: j < rg[0]-phoneContext})
			}
			for j := rg[0]; j < rg[1] && j < len(src); j++ {
				b.Lines = append(b.Lines, phoneLine{N: j + 1, HTML: src[j]})
			}
			for j := rg[1]; j < min(hi, rg[1]+phoneMoreContext) && j < len(src); j++ {
				b.Lines = append(b.Lines, phoneLine{N: j + 1, HTML: src[j], Ctx: true, Extra: j >= rg[1]+phoneContext})
			}
			return b
		}
		pc := phoneConflict{ID: "m" + strconv.Itoa(fi) + "-c" + strconv.Itoa(k),
			OursLine: r.Ours[0] + 1, TheirsLine: r.Theirs[0] + 1,
			Ours:   block(ours, r.Ours, func(x meldRegion) [2]int { return x.Ours }, "nothing here"),
			Base:   block(anc, r.Base, func(x meldRegion) [2]int { return x.Base }, "nothing: both sides added lines here"),
			Theirs: block(theirs, r.Theirs, func(x meldRegion) [2]int { return x.Theirs }, "nothing here")}
		for _, b := range []phoneBlock{pc.Ours, pc.Base, pc.Theirs} {
			for _, l := range b.Lines {
				pc.More = pc.More || l.Extra
			}
		}
		out = append(out, pc)
	}
	return out
}

// highlightAll colours a file's lines, one HTML string per line.
func highlightAll(lines []string, path string) []template.HTML {
	if len(lines) == 0 {
		return nil
	}
	code := []byte(strings.Join(lines, "\n") + "\n")
	hl, _, _ := highlight.Lines(code, "", path, 0, len(lines))
	out := make([]template.HTML, len(lines))
	for i := range out {
		if i < len(hl) {
			out[i] = template.HTML(hl[i])
		} else {
			out[i] = template.HTML(template.HTMLEscapeString(lines[i]))
		}
	}
	return out
}

// escapePath escapes each segment of a repository path for a URL.
func escapePath(p string) string {
	segs := strings.Split(p, "/")
	for i := range segs {
		segs[i] = url.PathEscape(segs[i])
	}
	return strings.Join(segs, "/")
}