1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "html"
9 "io"
10 "slices"
11 "strings"
12 "unicode/utf8"
13
14 "github.com/a-h/templ"
15
16 pb "bnema.dev/ermite-api/gen/ermite"
17)
18
19// Seg is a piece of a line of code: plain text, or the visible marker of an
20// invisible character (Trojan Source, ADR 0017).
21type Seg struct {
22 Text string
23 Mark bool
24}
25
26// DiffLine is one line of a hunk, in the unified view and, paired, in the
27// split view.
28type DiffLine struct {
29 Kind string // "ctx", "add", "del"
30 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
31 Old, New string // line numbers, "" when the side has none
32 Segs []Seg
33 NoEOL bool // the side ends without a line break
34}
35
36// SplitRow pairs a deleted line (left) with an added one (right); a context
37// line fills both sides.
38type SplitRow struct{ L, R *DiffLine }
39
40// DiffHunk is a changed region.
41type DiffHunk struct {
42 Header string
43 Lines []DiffLine
44 Split []SplitRow
45}
46
47// Bar is the change bar of the file list, "+++--·····", in three runs so each
48// can carry its colour.
49type Bar struct{ Add, Del, Rest string }
50
51// DiffFile is one changed file.
52type DiffFile struct {
53 ID string
54 Status string // A D M R C T
55 Path string // display path, "old → new" for a rename
56 Add, Del int
57 Bar Bar
58 // Note is why there is no text to show, or what else changed: a binary
59 // file, a mode change, a truncation, a similarity.
60 Note string
61 Open bool
62 Hunks []DiffHunk
63}
64
65func lineNumber(n int32) string {
66 if n <= 0 {
67 return ""
68 }
69 return itoa(int(n))
70}
71
72// segments splits a line of code into plain text and markers. One trailing
73// carriage return, the terminator of a CRLF file, is dropped.
74func segments(text string) []Seg {
75 text = strings.TrimSuffix(text, "\r")
76 if !strings.ContainsFunc(text, invisible) {
77 return []Seg{{Text: text}}
78 }
79 var out []Seg
80 start := 0
81 for i, r := range text {
82 if !invisible(r) {
83 continue
84 }
85 if i > start {
86 out = append(out, Seg{Text: text[start:i]})
87 }
88 out = append(out, Seg{Text: marker(r), Mark: true})
89 start = i + utf8.RuneLen(r)
90 }
91 if start < len(text) {
92 out = append(out, Seg{Text: text[start:]})
93 }
94 return out
95}
96
97// messageText is a commit message for display: line breaks normalised, the
98// invisible characters shown.
99func messageText(s string) string {
100 s = strings.ReplaceAll(s, "\r\n", "\n")
101 return visibleText(strings.TrimRight(s, "\n"))
102}
103
104// hasMarker reports whether a line shows a marker of an invisible character.
105func hasMarker(l DiffLine) bool {
106 return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
107}
108
109// pairLines builds the split view: a run of deletions lines up row by row
110// with the run of additions that follows it; context fills both sides.
111func pairLines(ls []DiffLine) []SplitRow {
112 var out []SplitRow
113 for i := 0; i < len(ls); {
114 if ls[i].Kind == "ctx" {
115 out = append(out, SplitRow{&ls[i], &ls[i]})
116 i++
117 continue
118 }
119 var dels, adds []*DiffLine
120 for ; i < len(ls) && ls[i].Kind == "del"; i++ {
121 dels = append(dels, &ls[i])
122 }
123 for ; i < len(ls) && ls[i].Kind == "add"; i++ {
124 adds = append(adds, &ls[i])
125 }
126 for j := 0; j < len(dels) || j < len(adds); j++ {
127 var row SplitRow
128 if j < len(dels) {
129 row.L = dels[j]
130 }
131 if j < len(adds) {
132 row.R = adds[j]
133 }
134 out = append(out, row)
135 }
136 }
137 return out
138}
139
140// changeBar draws the change bar of a file, scaled to the largest file.
141func changeBar(add, del, most int) Bar {
142 const cells = 10
143 if most == 0 {
144 return Bar{Rest: strings.Repeat("·", cells)}
145 }
146 scale := func(n int) int {
147 c := (n*cells + most - 1) / most
148 if n > 0 && c == 0 {
149 c = 1
150 }
151 return c
152 }
153 a, d := scale(add), scale(del)
154 if a+d > cells {
155 d = cells - a
156 }
157 return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
158}
159
160// statusLetter is the one-letter status of a file.
161func statusLetter(s pb.FileStatus) string {
162 switch s {
163 case pb.FileStatus_FILE_STATUS_ADDED:
164 return "A"
165 case pb.FileStatus_FILE_STATUS_DELETED:
166 return "D"
167 case pb.FileStatus_FILE_STATUS_RENAMED:
168 return "R"
169 case pb.FileStatus_FILE_STATUS_COPIED:
170 return "C"
171 case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
172 return "T"
173 }
174 return "M"
175}
176
177// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
178func hunkHeader(h *pb.Hunk) string {
179 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
180 if t := h.GetHeader(); t != "" {
181 s += " " + visibleText(strings.TrimSpace(t))
182 }
183 return s
184}
185
186// diffLine converts a line of the API.
187func diffLine(l *pb.Line) DiffLine {
188 dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof()}
189 switch l.GetKind() {
190 case pb.LineKind_LINE_KIND_ADDITION:
191 dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
192 case pb.LineKind_LINE_KIND_DELETION:
193 dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
194 default:
195 dl.Kind, dl.Mark = "ctx", " "
196 dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber())
197 }
198 return dl
199}
200
201// diffFiles converts the files of a diff for the page.
202func diffFiles(files []*pb.FileDiff) []DiffFile {
203 most := 0
204 for _, f := range files {
205 most = max(most, int(f.GetAdditions()+f.GetDeletions()))
206 }
207 out := make([]DiffFile, 0, len(files))
208 for i, f := range files {
209 df := DiffFile{
210 ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
211 Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
212 Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
213 // A file that is shown in full opens; a file with nothing to read
214 // (binary, truncated) stays closed under its note.
215 Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
216 }
217 switch f.GetStatus() {
218 case pb.FileStatus_FILE_STATUS_DELETED:
219 df.Path = visibleText(f.GetOldPath())
220 case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
221 df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
222 default:
223 df.Path = visibleText(f.GetNewPath())
224 }
225 df.Note = fileNote(f)
226 if f.GetBinary() {
227 df.Bar = Bar{Rest: "bin"}
228 }
229 for _, h := range f.GetHunks() {
230 dh := DiffHunk{Header: hunkHeader(h)}
231 for _, l := range h.GetLines() {
232 dh.Lines = append(dh.Lines, diffLine(l))
233 }
234 dh.Split = pairLines(dh.Lines)
235 df.Hunks = append(df.Hunks, dh)
236 }
237 out = append(out, df)
238 }
239 return out
240}
241
242// fileNote says what the hunks cannot: the kind of change when there is no
243// text to show, the mode change, the similarity of a rename.
244func fileNote(f *pb.FileDiff) string {
245 var parts []string
246 switch {
247 case f.GetBinary():
248 parts = append(parts, "binary file: no text diff")
249 case f.GetTruncated():
250 parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
251 }
252 if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
253 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode()))
254 }
255 switch f.GetStatus() {
256 case pb.FileStatus_FILE_STATUS_RENAMED:
257 parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
258 case pb.FileStatus_FILE_STATUS_COPIED:
259 parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
260 case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
261 parts = append(parts, "type changed")
262 }
263 return strings.Join(parts, " · ")
264}
265
266// noEOL marks a side that ends without a line break.
267const noEOL = "\u23ce\u0338"
268
269// codeLine renders the text of a line of code. It writes exactly the text,
270// escaped, and the markers: code cells keep their white space (pre), so the
271// spaces a template adds between expressions would show up in the code.
272func codeLine(ln DiffLine) templ.Component {
273 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
274 var b strings.Builder
275 for _, s := range ln.Segs {
276 if s.Mark {
277 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
278 } else {
279 b.WriteString(html.EscapeString(s.Text))
280 }
281 }
282 if ln.NoEOL {
283 b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
284 }
285 _, err := io.WriteString(w, b.String())
286 return err
287 })
288}