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 "bnema.dev/ermite/internal/domain"
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 // OldHref and NewHref link the line number of a side to the comment form
35 // of that side; they are set on a merge request that can be commented on.
36 OldHref, NewHref string
37 // Notes are the comments of a merge request on this line, and Form the
38 // form to write one, when the reader asked for it.
39 Notes []CommentView
40 Form *CommentForm
41
42 // oldN and newN are the line numbers of the sides, 0 for none.
43 oldN, newN int
44}
45
46// SplitRow pairs a deleted line (left) with an added one (right); a context
47// line fills both sides.
48type SplitRow struct{ L, R *DiffLine }
49
50// Notes are the comments under a row of the split view: those of its left
51// line, then those of its right line; a context line, on both sides, counts
52// once.
53func (r SplitRow) Notes() []CommentView {
54 switch {
55 case r.L == nil && r.R == nil:
56 return nil
57 case r.L == nil:
58 return r.R.Notes
59 case r.R == nil || r.L == r.R:
60 return r.L.Notes
61 }
62 return append(append([]CommentView(nil), r.L.Notes...), r.R.Notes...)
63}
64
65// Form is the comment form of the row, nil when there is none.
66func (r SplitRow) Form() *CommentForm {
67 switch {
68 case r.R != nil && r.R.Form != nil:
69 return r.R.Form
70 case r.L != nil:
71 return r.L.Form
72 }
73 return nil
74}
75
76// DiffHunk is a changed region.
77type DiffHunk struct {
78 Header string
79 Lines []DiffLine
80 Split []SplitRow
81}
82
83// Bar is the change bar of the file list, "+++--·····", in three runs so each
84// can carry its colour.
85type Bar struct{ Add, Del, Rest string }
86
87// DiffFile is one changed file.
88type DiffFile struct {
89 ID string
90 Status string // A D M R C T
91 // Key is the path comments name: the new path, the old path of a deleted
92 // file. It is not fit to display (Path is).
93 Key string
94 Path string // display path, "old → new" for a rename
95 Add, Del int
96 Bar Bar
97 // Note is why there is no text to show, or what else changed: a binary
98 // file, a mode change, a truncation, a similarity.
99 Note string
100 Open bool
101 Hunks []DiffHunk
102}
103
104func lineNumber(n int) string {
105 if n <= 0 {
106 return ""
107 }
108 return itoa(n)
109}
110
111// segments splits a line of code into plain text and markers. One trailing
112// carriage return, the terminator of a CRLF file, is dropped.
113func segments(text string) []Seg {
114 text = strings.TrimSuffix(text, "\r")
115 if !strings.ContainsFunc(text, invisible) {
116 return []Seg{{Text: text}}
117 }
118 var out []Seg
119 start := 0
120 for i, r := range text {
121 if !invisible(r) {
122 continue
123 }
124 if i > start {
125 out = append(out, Seg{Text: text[start:i]})
126 }
127 out = append(out, Seg{Text: marker(r), Mark: true})
128 start = i + utf8.RuneLen(r)
129 }
130 if start < len(text) {
131 out = append(out, Seg{Text: text[start:]})
132 }
133 return out
134}
135
136// messageText is a commit message for display: line breaks normalised, the
137// invisible characters shown.
138func messageText(s string) string {
139 s = strings.ReplaceAll(s, "\r\n", "\n")
140 return visibleText(strings.TrimRight(s, "\n"))
141}
142
143// hasMarker reports whether a line shows a marker of an invisible character.
144func hasMarker(l DiffLine) bool {
145 return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
146}
147
148// pairLines builds the split view: a run of deletions lines up row by row
149// with the run of additions that follows it; context fills both sides.
150func pairLines(ls []DiffLine) []SplitRow {
151 var out []SplitRow
152 for i := 0; i < len(ls); {
153 if ls[i].Kind == "ctx" {
154 out = append(out, SplitRow{&ls[i], &ls[i]})
155 i++
156 continue
157 }
158 var dels, adds []*DiffLine
159 for ; i < len(ls) && ls[i].Kind == "del"; i++ {
160 dels = append(dels, &ls[i])
161 }
162 for ; i < len(ls) && ls[i].Kind == "add"; i++ {
163 adds = append(adds, &ls[i])
164 }
165 for j := 0; j < len(dels) || j < len(adds); j++ {
166 var row SplitRow
167 if j < len(dels) {
168 row.L = dels[j]
169 }
170 if j < len(adds) {
171 row.R = adds[j]
172 }
173 out = append(out, row)
174 }
175 }
176 return out
177}
178
179// changeBar draws the change bar of a file, scaled to the largest file.
180func changeBar(add, del, most int) Bar {
181 const cells = 10
182 if most == 0 {
183 return Bar{Rest: strings.Repeat("·", cells)}
184 }
185 scale := func(n int) int {
186 c := (n*cells + most - 1) / most
187 if n > 0 && c == 0 {
188 c = 1
189 }
190 return c
191 }
192 a, d := scale(add), scale(del)
193 if a+d > cells {
194 d = cells - a
195 }
196 return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
197}
198
199// statusLetter is the one-letter status of a file.
200func statusLetter(s domain.FileStatus) string {
201 switch s {
202 case domain.FileAdded:
203 return "A"
204 case domain.FileDeleted:
205 return "D"
206 case domain.FileRenamed:
207 return "R"
208 case domain.FileCopied:
209 return "C"
210 case domain.FileTypeChanged:
211 return "T"
212 }
213 return "M"
214}
215
216// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217func hunkHeader(h domain.Hunk) string {
218 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
219 if t := h.Header; t != "" {
220 s += " " + visibleText(strings.TrimSpace(t))
221 }
222 return s
223}
224
225// diffLine converts a line of a hunk.
226func diffLine(l domain.Line) DiffLine {
227 dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
228 switch l.Kind {
229 case domain.LineAddition:
230 dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
231 case domain.LineDeletion:
232 dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
233 default:
234 dl.Kind, dl.Mark = "ctx", " "
235 dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber)
236 }
237 return dl
238}
239
240// diffFiles converts the files of a diff for the page.
241func diffFiles(files []domain.FileDiff) []DiffFile {
242 most := 0
243 for _, f := range files {
244 most = max(most, f.Additions+f.Deletions)
245 }
246 out := make([]DiffFile, 0, len(files))
247 for i, f := range files {
248 df := DiffFile{
249 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
250 Add: f.Additions, Del: f.Deletions,
251 Bar: changeBar(f.Additions, f.Deletions, most),
252 // A file that is shown in full opens; a file with nothing to read
253 // (binary, truncated) stays closed under its note.
254 Open: len(f.Hunks) > 0 && !f.Truncated,
255 }
256 df.Key = f.NewPath
257 switch f.Status {
258 case domain.FileDeleted:
259 df.Key = f.OldPath
260 df.Path = visibleText(f.OldPath)
261 case domain.FileRenamed, domain.FileCopied:
262 df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath)
263 default:
264 df.Path = visibleText(f.NewPath)
265 }
266 df.Note = fileNote(f)
267 if f.Binary {
268 df.Bar = Bar{Rest: "bin"}
269 }
270 for _, h := range f.Hunks {
271 dh := DiffHunk{Header: hunkHeader(h)}
272 for _, l := range h.Lines {
273 dh.Lines = append(dh.Lines, diffLine(l))
274 }
275 dh.Split = pairLines(dh.Lines)
276 df.Hunks = append(df.Hunks, dh)
277 }
278 out = append(out, df)
279 }
280 return out
281}
282
283// fileNote says what the hunks cannot: the kind of change when there is no
284// text to show, the mode change, the similarity of a rename.
285func fileNote(f domain.FileDiff) string {
286 var parts []string
287 switch {
288 case f.Binary:
289 parts = append(parts, "binary file: no text diff")
290 case f.Truncated:
291 parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
292 }
293 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
294 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
295 }
296 switch f.Status {
297 case domain.FileRenamed:
298 parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
299 case domain.FileCopied:
300 parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
301 case domain.FileTypeChanged:
302 parts = append(parts, "type changed")
303 }
304 return strings.Join(parts, " · ")
305}
306
307// noEOL marks a side that ends without a line break.
308const noEOL = "\u23ce\u0338"
309
310// codeLine renders the text of a line of code. It writes exactly the text,
311// escaped, and the markers: code cells keep their white space (pre), so the
312// spaces a template adds between expressions would show up in the code.
313func codeLine(ln DiffLine) templ.Component {
314 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
315 var b strings.Builder
316 for _, s := range ln.Segs {
317 if s.Mark {
318 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
319 } else {
320 b.WriteString(html.EscapeString(s.Text))
321 }
322 }
323 if ln.NoEOL {
324 b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
325 }
326 _, err := io.WriteString(w, b.String())
327 return err
328 })
329}