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("%s files cannot be answered here: %s", thousands(n), strings.Join(named, ", "))
}
return fmt.Sprintf("%s files cannot be answered here: %s and %s more", thousands(n), strings.Join(named, ", "), thousands(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, "/")
}