Skip to content
quarry Artifact [623ca63c6d]
Login

Artifact [623ca63c6d]

Artifact 623ca63c6d2769d07621d63791578d9b05ff210813596e072edefaeb6b7d6777:


package web

import (
	"fmt"
	"hash/fnv"
	"strconv"
	"strings"

	"craftdesign.group/quarry/internal/diff"
	"craftdesign.group/quarry/internal/index"
)

// Line notes on a landing: a remark pinned to one line of the diff,
// stored as an ordinary reply in the record's thread (index.LineNote).
// The page shows each under its line and in the discussion, and a
// reviewer who may speak opens one by pressing a line number.

// lineCompose is the line the composer is aimed at (?line=).
type lineCompose struct {
	File, Side string
	Line       int
	At         string
}

func lineKey(file, side string, n int) string {
	return file + "\x00" + side + "\x00" + strconv.Itoa(n)
}

// lineID is a row's anchor: a short hash of the path, so any path makes
// a valid id, and the side and number a reader can see.
func lineID(file, side string, n int) string {
	h := fnv.New32a()
	h.Write([]byte(file))
	return fmt.Sprintf("L%08x-%c%d", h.Sum32(), side[0], n)
}

// lineHere is a line's ?line= value: file, side and number.
func lineHere(file, side string, n int) string {
	return file + ":" + side + ":" + strconv.Itoa(n)
}

// sideOf says which numbering names a diff line: a removed line exists
// only in the target, so its old number; every other line by the
// branch's number.
func sideOf(l diff.Line) (string, int) {
	if l.Kind == "deletion" {
		return "old", l.OldNum
	}
	return "new", l.NewNum
}

func lineNotesAt(m map[string][]forumPostRow, file string, l diff.Line) []forumPostRow {
	if m == nil {
		return nil
	}
	side, n := sideOf(l)
	return m[lineKey(file, side, n)]
}

func splitNotesAt(m map[string][]forumPostRow, file string, r splitRow) []forumPostRow {
	if m == nil {
		return nil
	}
	var out []forumPostRow
	if r.Left != nil && r.Left.Kind == "deletion" {
		out = append(out, m[lineKey(file, "old", r.Left.OldNum)]...)
	}
	if r.Right != nil {
		out = append(out, m[lineKey(file, "new", r.Right.NewNum)]...)
	}
	return out
}

// lineNotesFor marks each line note stale when the branch has moved on
// since it was written (its line number may no longer name the same
// line, so it stays in the discussion but leaves the diff), maps the
// rest to their lines, and aims the composer at ?line= when the reader
// may comment and the line is one this diff shows.
func lineNotesFor(data *pageData, det *landingDetail, tip, q string) {
	for i := range det.Comments {
		c := &det.Comments[i]
		if c.Line == nil || c.Hidden {
			continue
		}
		if c.Line.At != "" && tip != "" && !strings.HasPrefix(tip, c.Line.At) {
			c.LineStale = true
			continue
		}
		if data.LineNotes == nil {
			data.LineNotes = map[string][]forumPostRow{}
		}
		k := lineKey(c.Line.File, c.Line.Side, c.Line.Line)
		data.LineNotes[k] = append(data.LineNotes[k], *c)
	}
	data.LineLinks = det.CanSpeak && det.State == "open" && det.Diff != nil
	if q == "" || !data.LineLinks {
		return
	}
	rest, num, ok := cutLastColon(q)
	if !ok {
		return
	}
	file, side, ok := cutLastColon(rest)
	if !ok {
		return
	}
	n, err := strconv.Atoi(num)
	if err != nil {
		return
	}
	note := index.LineNote{File: file, Side: side, Line: n, At: tip}
	if note.Valid() != nil || !diffHasLine(det.Diff, note) {
		return
	}
	data.LineCompose = &lineCompose{File: file, Side: side, Line: n, At: tip}
}

func cutLastColon(s string) (before, after string, ok bool) {
	i := strings.LastIndexByte(s, ':')
	if i < 0 {
		return "", "", false
	}
	return s[:i], s[i+1:], true
}

// diffHasLine reports whether the diff on the page shows that line, so
// a hand-typed ?line= cannot aim a note at a line nobody was shown.
func diffHasLine(d *diffDetail, n index.LineNote) bool {
	for _, f := range d.Structured {
		if f.Path != n.File {
			continue
		}
		for _, h := range f.Hunks {
			for _, l := range h.Lines {
				if side, num := sideOf(l); side == n.Side && num == n.Line {
					return true
				}
			}
		}
	}
	return false
}

// rowID is a unified row's anchor, by the numbering that names it.
func rowID(file string, l diff.Line) string {
	side, n := sideOf(l)
	return lineID(file, side, n)
}

// splitID anchors a side-by-side row by its new line, or its old one
// when the branch removed it.
func splitID(file string, r splitRow) string {
	if r.Right != nil {
		return lineID(file, "new", r.Right.NewNum)
	}
	if r.Left != nil {
		return lineID(file, "old", r.Left.OldNum)
	}
	return ""
}

// lineIs reports whether the composer is aimed at this row.
func lineIs(c *lineCompose, file string, l diff.Line) bool {
	if c == nil || c.File != file {
		return false
	}
	side, n := sideOf(l)
	return side == c.Side && n == c.Line
}

func splitLineIs(c *lineCompose, file string, r splitRow) bool {
	if c == nil || c.File != file {
		return false
	}
	return (r.Right != nil && c.Side == "new" && r.Right.NewNum == c.Line) ||
		(r.Left != nil && r.Left.Kind == "deletion" && c.Side == "old" && r.Left.OldNum == c.Line)
}