// SPDX-License-Identifier: AGPL-3.0-or-later
package web
import (
"context"
"fmt"
"html"
"io"
"slices"
"strings"
"unicode/utf8"
"github.com/a-h/templ"
"bnema.dev/ermite/internal/domain"
)
// 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
}
// 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
// Bin is true for a binary file: no lines to count.
Bin bool
// 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
// Digits is the width of the largest line number of the file, at least 2
// and at most 7: the gutter is as wide as the file needs (class lnw-N).
Digits int
// Name and Dir are the last element of the path and the directory before
// it ("" at the root), for the compact file list.
Name, Dir string
// Notes is the number of comments on lines of the file, Open those not
// resolved; the file list shows them.
Notes, OpenNotes int
// Lazy is true when the hunks of the file were not read: the page shows a
// placeholder that loads them (Src, the window of files it belongs to).
Lazy bool
Src string
// From is the index of the first file of the window of a lazy file: the
// page without scripts links to ?from=From.
From int
// Height is the size class of a lazy file (1 to 4, by its changed lines),
// so that the placeholder takes about the room of the diff.
Height int
// Comments are the ids of the comments on the file ("c12"): a link to a
// comment of a lazy file loads that file first.
Comments string
// Loose are the comments of a file loaded with its window that are on a
// line its diff does not show: listed at the end of the file.
Loose []CommentView
}
// gutterDigits is the number of digits of the largest line number of the
// hunks, clamped to the widths the style sheet has a class for.
func gutterDigits(hunks []domain.Hunk) int {
most := 0
for _, h := range hunks {
most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines)
}
return min(max(len(itoa(most)), 2), 7)
}
func lineNumber(n int) string {
if n <= 0 {
return ""
}
return itoa(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
}
// statusLetter is the one-letter status of a file.
func statusLetter(s domain.FileStatus) string {
switch s {
case domain.FileAdded:
return "A"
case domain.FileDeleted:
return "D"
case domain.FileRenamed:
return "R"
case domain.FileCopied:
return "C"
case domain.FileTypeChanged:
return "T"
}
return "M"
}
// fileFilterMin is the number of files above which the file list has a
// filter.
const fileFilterMin = 10
// statusName says what a status letter means, for its tooltip.
func statusName(letter string) string {
switch letter {
case "A":
return "added"
case "D":
return "deleted"
case "R":
return "renamed"
case "C":
return "copied"
case "T":
return "type changed"
}
return "modified"
}
// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
func hunkHeader(h domain.Hunk) string {
s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
if t := h.Header; t != "" {
s += " " + visibleText(strings.TrimSpace(t))
}
return s
}
// diffLine converts a line of a hunk.
func diffLine(l domain.Line) DiffLine {
dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
switch l.Kind {
case domain.LineAddition:
dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
case domain.LineDeletion:
dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
default:
dl.Kind, dl.Mark = "ctx", " "
dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber)
}
return dl
}
// diffFiles converts the files of a diff for the page.
func diffFiles(files []domain.FileDiff) []DiffFile {
out := make([]DiffFile, 0, len(files))
for i, f := range files {
df := DiffFile{
ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
// A file that is shown in full opens; a file with nothing to read
// (binary, truncated) stays closed under its note.
Open: len(f.Hunks) > 0 && !f.Truncated,
}
df.Key = f.NewPath
switch f.Status {
case domain.FileDeleted:
df.Key = f.OldPath
df.Path = visibleText(f.OldPath)
case domain.FileRenamed, domain.FileCopied:
df.Path = visibleText(f.OldPath) + " โ " + visibleText(f.NewPath)
default:
df.Path = visibleText(f.NewPath)
}
df.Note = fileNote(f)
df.Digits = gutterDigits(f.Hunks)
df.Name, df.Dir = splitPath(df.Key)
for _, h := range f.Hunks {
dh := DiffHunk{Header: hunkHeader(h)}
for _, l := range h.Lines {
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 domain.FileDiff) string {
var parts []string
switch {
case f.Binary:
parts = append(parts, "binary file: no text diff")
case f.Truncated:
parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
}
if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
parts = append(parts, fmt.Sprintf("mode %06o โ %06o", f.OldMode, f.NewMode))
}
switch f.Status {
case domain.FileRenamed:
parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
case domain.FileCopied:
parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
case domain.FileTypeChanged:
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
})
}