Skip to content
quarry Artifact [245b486ede]
Login

Artifact [245b486ede]

Artifact 245b486ede197b6ec9c1d58fc6e2d98e7b91310c468f707fcf8ede89c0193c60:


package web

import (
	"container/list"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"html/template"
	"net/url"
	"strconv"
	"strings"
	"sync"

	"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
	// CanResolve offers the controls (#343): the reader may push to the
	// branch, and every file can be answered here. landing.resolve
	// decides again when the answers arrive.
	CanResolve bool
	// Unresolvable says which files cannot be answered on the page, when
	// any cannot: the merge is then the author's to make in a checkout.
	// It counts them all and names the first few (jva, ^3771: it named
	// only the last of 87).
	Unresolvable string
	// Total is how many files conflict. Files holds the first
	// meldDrawnMax of them; Rest names the next meldRestMax by path
	// only, and RestMore counts the ones past that, so a pair with
	// 26,000 conflicting files is a page, not 15 MB of empty headers.
	Total    int
	Rest     []string
	RestMore int
	// Preview is set where no merge can be made here (a mirror): the
	// middle pane is what a merge would make, not a result to edit.
	Preview bool
	// ResolveURL takes the answers; DraftKey keeps them in the browser
	// until they are committed, per landing and tip pair.
	ResolveURL, DraftKey string
	// Into is where the merge page may commit the merge: the target, the
	// branch, or both to choose from. Empty on a landing, which always
	// commits onto its branch.
	Into []meldInto
}

// meldInto is one side a resolved merge may be committed onto.
type meldInto struct {
	Value, Name string
	Checked     bool
}

type meldFile struct {
	Path string
	// Kind is zit's: text, binary, add-add, delete-modify, ... A file
	// that is not text, or not drawn, is answered whole.
	Kind 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
	// Region is the segment's index in the file's merge regions, or -1
	// for unchanged lines and folds: an answer names a region by it.
	Region int
	// 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
	// Region is the conflict's index in the file's merge regions.
	Region               int
	OursLine, TheirsLine int
	Ours, Base, Theirs   phoneBlock
	More                 bool
}

type phoneBlock struct {
	Lines []phoneLine
	// Empty says what an empty side means.
	Empty string
	// Top and Bottom say where the context stops, when it stops short
	// of what More context would show: the file's start or end, or the
	// neighbouring conflict. Otherwise a block that cannot grow
	// downward reads as one that forgot to.
	Top, Bottom *phoneEdge
}

// phoneEdge is where a block's context ends. Kind is start, end or
// conflict; Extra is an edge only reached with More context.
type phoneEdge struct {
	Kind, Text string
	Extra      bool
}

type phoneLine struct {
	N    int
	HTML template.HTML
	// Ctx is a surrounding line; Extra is one shown only with More
	// context. Gap stands where a side has no lines of the conflict,
	// so its Empty note sits between the lines around it.
	Ctx, Extra, Gap bool
}

// buildMeld lays out a conflicts report. base is the repository's
// address, which the file links hang from.
// buildMeld is the conflict view of rep, from a small cache keyed by the
// pair of tips. A pair of tips merges the same way forever, and drawing
// it highlights both sides of every conflicting file: the bare merge
// page of tcl, fetched again and again, spent 6.5 s of CPU in 30 on it.
// Each caller gets its own copy to fill in; the files are shared and
// only read.
func buildMeld(rep conflictsReport, base string) *meldView {
	// Keyed by the report itself, hashed: the tips name it in practice,
	// but a key that trusted them alone would serve one report's view for
	// another that happened to share them.
	raw, err := json.Marshal(rep)
	if err != nil {
		return buildMeldUncached(rep, base)
	}
	sum := sha256.Sum256(append([]byte(base+"\x00"), raw...))
	key := string(sum[:])
	melds.Lock()
	if el, ok := melds.byKey[key]; ok {
		melds.order.MoveToFront(el)
		v := *el.Value.(*meldEntry).view
		melds.Unlock()
		return &v
	}
	melds.Unlock()
	built := buildMeldUncached(rep, base)
	melds.Lock()
	defer melds.Unlock()
	if _, ok := melds.byKey[key]; !ok {
		melds.byKey[key] = melds.order.PushFront(&meldEntry{key: key, view: built})
		for melds.order.Len() > meldCacheSize {
			last := melds.order.Back()
			melds.order.Remove(last)
			delete(melds.byKey, last.Value.(*meldEntry).key)
		}
	}
	v := *built
	return &v
}

// meldCacheSize is how many built conflict views are kept: a few pairs
// readers are looking at, each a page of highlighted files.
const meldCacheSize = 16

type meldEntry struct {
	key  string
	view *meldView
}

var melds = struct {
	sync.Mutex
	order *list.List
	byKey map[string]*list.Element
}{order: list.New(), byKey: map[string]*list.Element{}}

func buildMeldUncached(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, Kind: f.Kind, 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)
	}
	v.Total = len(v.Files)
	v.Unresolvable = unresolvableOf(v.Files)
	if len(v.Files) > meldDrawnMax {
		for _, f := range v.Files[meldDrawnMax:] {
			if len(v.Rest) == meldRestMax {
				v.RestMore++
				continue
			}
			v.Rest = append(v.Rest, f.Path)
		}
		v.Files = v.Files[:meldDrawnMax:meldDrawnMax]
	}
	return v
}

// meldDrawnMax is how many conflicting files get a row of their own;
// meldRestMax how many more are named by path below them.
const (
	meldDrawnMax = 50
	meldRestMax  = 200
)

// unresolvableOf is meldView.Unresolvable for files: empty when every
// file can be answered on the page, else how many cannot and the first
// few with why.
func unresolvableOf(files []meldFile) string {
	var named []string
	n := 0
	for _, f := range files {
		switch f.Omitted {
		case "too large", "past the file limit", "unreadable":
			n++
			if len(named) < 3 {
				named = append(named, f.Path+" is "+f.Omitted)
			}
		}
	}
	switch {
	case n == 0:
		return ""
	case n == 1:
		return named[0]
	case n <= len(named):
		return fmt.Sprintf("%d files cannot be answered here: %s", n, strings.Join(named, ", "))
	}
	return fmt.Sprintf("%d files cannot be answered here: %s and %d more", n, strings.Join(named, ", "), n-len(named))
}

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)) }
	unchanged := func(s meldSeg) meldSeg { s.Region = -1; return s }
	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, unchanged(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, unchanged(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, unchanged(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, unchanged(meldSeg{ID: id(), L: lines(ours, o, o[0]+1), M: result(ours, o), R: lines(theirs, t, t[0]+1)}))
			}
		case "ours", "theirs", "both", "conflict":
			// both: the two sides made the same change, merged as either;
			// the three panes agree there, so nothing is marked.
			s := meldSeg{ID: id(), Region: i, L: lines(ours, r.Ours, r.Ours[0]+1), R: lines(theirs, r.Theirs, r.Theirs[0]+1)}
			switch r.Kind {
			case "both":
				s.M = result(ours, r.Ours)
			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}
			if lo >= rg[0]-phoneMoreContext {
				e := &phoneEdge{Kind: "start", Text: "start of file", Extra: lo < rg[0]-phoneContext}
				if k > 0 {
					e.Kind, e.Text = "conflict", "conflict "+strconv.Itoa(k)+" is above"
				}
				b.Top = e
			}
			if hi <= rg[1]+phoneMoreContext {
				e := &phoneEdge{Kind: "end", Text: "end of file", Extra: hi > rg[1]+phoneContext}
				if k < len(cf)-1 {
					e.Kind, e.Text = "conflict", "conflict "+strconv.Itoa(k+2)+" is below"
				}
				b.Bottom = e
			}
			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]})
			}
			if rg[0] >= rg[1] {
				b.Lines = append(b.Lines, phoneLine{Gap: true})
			}
			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), Region: i,
			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, "/")
}