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)
}