// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"context"
"fmt"
"html"
"io"
"slices"
"strings"
"unicode/utf8"
"github.com/a-h/templ"
pb "bnema.dev/ermite-api/gen/ermite"
)
// Seg is a piece of a line of code: plain text, or the visible marker of an
// invisible character (Trojan Source, ADR 0017).
type Seg struct {
Text string
Mark bool
}
// DiffLine is one line of a hunk, in the unified view and, paired, in the
// split view.
type DiffLine struct {
Kind string // "ctx", "add", "del"
Mark string // " ", "+", "-": every change keeps its marker, not only a colour
Old, New string // line numbers, "" when the side has none
Segs []Seg
NoEOL bool // the side ends without a line break
// OldHref and NewHref link the line number of a side to the comment form
// of that side; they are set on a merge request that can be commented on.
OldHref, NewHref string
// Notes are the comments of a merge request on this line, and Form the
// form to write one, when the reader asked for it.
Notes []CommentView
Form *CommentForm
// oldN and newN are the line numbers of the sides, 0 for none.
oldN, newN int
}
// SplitRow pairs a deleted line (left) with an added one (right); a context
// line fills both sides.
type SplitRow struct{ L, R *DiffLine }
// Notes are the comments under a row of the split view: those of its left
// line, then those of its right line; a context line, on both sides, counts
// once.
func (r SplitRow) Notes() []CommentView {
switch {
case r.L == nil && r.R == nil:
return nil
case r.L == nil:
return r.R.Notes
case r.R == nil || r.L == r.R:
return r.L.Notes
}
return append(append([]CommentView(nil), r.L.Notes...), r.R.Notes...)
}
// Form is the comment form of the row, nil when there is none.
func (r SplitRow) Form() *CommentForm {
switch {
case r.R != nil && r.R.Form != nil:
return r.R.Form
case r.L != nil:
return r.L.Form
}
return nil
}
// DiffHunk is a changed region.
type DiffHunk struct {
Header string
Lines []DiffLine
Split []SplitRow
}
// Bar is the change bar of the file list, "+++--·····", in three runs so each
// can carry its colour.
type Bar struct{ Add, Del, Rest string }
// DiffFile is one changed file.
type DiffFile struct {
ID string
Status string // A D M R C T
// Key is the path comments name: the new path, the old path of a deleted
// file. It is not fit to display (Path is).
Key string
Path string // display path, "old → new" for a rename
Add, Del int
Bar Bar
// Note is why there is no text to show, or what else changed: a binary
// file, a mode change, a truncation, a similarity.
Note string
Open bool
Hunks []DiffHunk
}
func lineNumber(n int32) string {
if n <= 0 {
return ""
}
return itoa(int(n))
}
// segments splits a line of code into plain text and markers. One trailing
// carriage return, the terminator of a CRLF file, is dropped.
func segments(text string) []Seg {
text = strings.TrimSuffix(text, "\r")
if !strings.ContainsFunc(text, invisible) {
return []Seg{{Text: text}}
}
var out []Seg
start := 0
for i, r := range text {
if !invisible(r) {
continue
}
if i > start {
out = append(out, Seg{Text: text[start:i]})
}
out = append(out, Seg{Text: marker(r), Mark: true})
start = i + utf8.RuneLen(r)
}
if start < len(text) {
out = append(out, Seg{Text: text[start:]})
}
return out
}
// messageText is a commit message for display: line breaks normalised, the
// invisible characters shown.
func messageText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return visibleText(strings.TrimRight(s, "\n"))
}
// hasMarker reports whether a line shows a marker of an invisible character.
func hasMarker(l DiffLine) bool {
return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
}
// pairLines builds the split view: a run of deletions lines up row by row
// with the run of additions that follows it; context fills both sides.
func pairLines(ls []DiffLine) []SplitRow {
var out []SplitRow
for i := 0; i < len(ls); {
if ls[i].Kind == "ctx" {
out = append(out, SplitRow{&ls[i], &ls[i]})
i++
continue
}
var dels, adds []*DiffLine
for ; i < len(ls) && ls[i].Kind == "del"; i++ {
dels = append(dels, &ls[i])
}
for ; i < len(ls) && ls[i].Kind == "add"; i++ {
adds = append(adds, &ls[i])
}
for j := 0; j < len(dels) || j < len(adds); j++ {
var row SplitRow
if j < len(dels) {
row.L = dels[j]
}
if j < len(adds) {
row.R = adds[j]
}
out = append(out, row)
}
}
return out
}
// changeBar draws the change bar of a file, scaled to the largest file.
func changeBar(add, del, most int) Bar {
const cells = 10
if most == 0 {
return Bar{Rest: strings.Repeat("·", cells)}
}
scale := func(n int) int {
c := (n*cells + most - 1) / most
if n > 0 && c == 0 {
c = 1
}
return c
}
a, d := scale(add), scale(del)
if a+d > cells {
d = cells - a
}
return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
}
// statusLetter is the one-letter status of a file.
func statusLetter(s pb.FileStatus) string {
switch s {
case pb.FileStatus_FILE_STATUS_ADDED:
return "A"
case pb.FileStatus_FILE_STATUS_DELETED:
return "D"
case pb.FileStatus_FILE_STATUS_RENAMED:
return "R"
case pb.FileStatus_FILE_STATUS_COPIED:
return "C"
case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
return "T"
}
return "M"
}
// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
func hunkHeader(h *pb.Hunk) string {
s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
if t := h.GetHeader(); t != "" {
s += " " + visibleText(strings.TrimSpace(t))
}
return s
}
// diffLine converts a line of the API.
func diffLine(l *pb.Line) DiffLine {
dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())}
switch l.GetKind() {
case pb.LineKind_LINE_KIND_ADDITION:
dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
case pb.LineKind_LINE_KIND_DELETION:
dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
default:
dl.Kind, dl.Mark = "ctx", " "
dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber())
}
return dl
}
// diffFiles converts the files of a diff for the page.
func diffFiles(files []*pb.FileDiff) []DiffFile {
most := 0
for _, f := range files {
most = max(most, int(f.GetAdditions()+f.GetDeletions()))
}
out := make([]DiffFile, 0, len(files))
for i, f := range files {
df := DiffFile{
ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
// A file that is shown in full opens; a file with nothing to read
// (binary, truncated) stays closed under its note.
Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
}
df.Key = f.GetNewPath()
switch f.GetStatus() {
case pb.FileStatus_FILE_STATUS_DELETED:
df.Key = f.GetOldPath()
df.Path = visibleText(f.GetOldPath())
case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
default:
df.Path = visibleText(f.GetNewPath())
}
df.Note = fileNote(f)
if f.GetBinary() {
df.Bar = Bar{Rest: "bin"}
}
for _, h := range f.GetHunks() {
dh := DiffHunk{Header: hunkHeader(h)}
for _, l := range h.GetLines() {
dh.Lines = append(dh.Lines, diffLine(l))
}
dh.Split = pairLines(dh.Lines)
df.Hunks = append(df.Hunks, dh)
}
out = append(out, df)
}
return out
}
// fileNote says what the hunks cannot: the kind of change when there is no
// text to show, the mode change, the similarity of a rename.
func fileNote(f *pb.FileDiff) string {
var parts []string
switch {
case f.GetBinary():
parts = append(parts, "binary file: no text diff")
case f.GetTruncated():
parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
}
if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode()))
}
switch f.GetStatus() {
case pb.FileStatus_FILE_STATUS_RENAMED:
parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
case pb.FileStatus_FILE_STATUS_COPIED:
parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
parts = append(parts, "type changed")
}
return strings.Join(parts, " · ")
}
// noEOL marks a side that ends without a line break.
const noEOL = "\u23ce\u0338"
// codeLine renders the text of a line of code. It writes exactly the text,
// escaped, and the markers: code cells keep their white space (pre), so the
// spaces a template adds between expressions would show up in the code.
func codeLine(ln DiffLine) templ.Component {
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
var b strings.Builder
for _, s := range ln.Segs {
if s.Mark {
b.WriteString(`` + html.EscapeString(s.Text) + ``)
} else {
b.WriteString(html.EscapeString(s.Text))
}
}
if ln.NoEOL {
b.WriteString(`` + noEOL + ``)
}
_, err := io.WriteString(w, b.String())
return err
})
}