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// DiffFile is one changed file.
84type DiffFile struct {
85 ID string
86 Status string // A D M R C T
87 // Key is the path comments name: the new path, the old path of a deleted
88 // file. It is not fit to display (Path is).
89 Key string
90 Path string // display path, "old → new" for a rename
91 Add, Del int
92 // Bin is true for a binary file: no lines to count.
93 Bin bool
94 // Note is why there is no text to show, or what else changed: a binary
95 // file, a mode change, a truncation, a similarity.
96 Note string
97 Open bool
98 Hunks []DiffHunk
99 // Digits is the width of the largest line number of the file, at least 2
100 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
101 Digits int
102 // Name and Dir are the last element of the path and the directory before
103 // it ("" at the root), for the compact file list.
104 Name, Dir string
105 // Notes is the number of comments on lines of the file, Open those not
106 // resolved; the file list shows them.
107 Notes, OpenNotes int
108 // Lazy is true when the hunks of the file were not read: the page shows a
109 // placeholder that loads them (Src, the window of files it belongs to).
110 Lazy bool
111 Src string
112 // From is the index of the first file of the window of a lazy file: the
113 // page without scripts links to ?from=From.
114 From int
115 // Height is the size class of a lazy file (1 to 4, by its changed lines),
116 // so that the placeholder takes about the room of the diff.
117 Height int
118 // Comments are the ids of the comments on the file ("c12"): a link to a
119 // comment of a lazy file loads that file first.
120 Comments string
121 // Loose are the comments of a file loaded with its window that are on a
122 // line its diff does not show: listed at the end of the file.
123 Loose []CommentView
124}
125
126// gutterDigits is the number of digits of the largest line number of the
127// hunks, clamped to the widths the style sheet has a class for.
128func gutterDigits(hunks []domain.Hunk) int {
129 most := 0
130 for _, h := range hunks {
131 most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines)
132 }
133 return min(max(len(itoa(most)), 2), 7)
134}
135
136func lineNumber(n int) string {
137 if n <= 0 {
138 return ""
139 }
140 return itoa(n)
141}
142
143// segments splits a line of code into plain text and markers. One trailing
144// carriage return, the terminator of a CRLF file, is dropped.
145func segments(text string) []Seg {
146 text = strings.TrimSuffix(text, "\r")
147 if !strings.ContainsFunc(text, invisible) {
148 return []Seg{{Text: text}}
149 }
150 var out []Seg
151 start := 0
152 for i, r := range text {
153 if !invisible(r) {
154 continue
155 }
156 if i > start {
157 out = append(out, Seg{Text: text[start:i]})
158 }
159 out = append(out, Seg{Text: marker(r), Mark: true})
160 start = i + utf8.RuneLen(r)
161 }
162 if start < len(text) {
163 out = append(out, Seg{Text: text[start:]})
164 }
165 return out
166}
167
168// messageText is a commit message for display: line breaks normalised, the
169// invisible characters shown.
170func messageText(s string) string {
171 s = strings.ReplaceAll(s, "\r\n", "\n")
172 return visibleText(strings.TrimRight(s, "\n"))
173}
174
175// hasMarker reports whether a line shows a marker of an invisible character.
176func hasMarker(l DiffLine) bool {
177 return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
178}
179
180// pairLines builds the split view: a run of deletions lines up row by row
181// with the run of additions that follows it; context fills both sides.
182func pairLines(ls []DiffLine) []SplitRow {
183 var out []SplitRow
184 for i := 0; i < len(ls); {
185 if ls[i].Kind == "ctx" {
186 out = append(out, SplitRow{&ls[i], &ls[i]})
187 i++
188 continue
189 }
190 var dels, adds []*DiffLine
191 for ; i < len(ls) && ls[i].Kind == "del"; i++ {
192 dels = append(dels, &ls[i])
193 }
194 for ; i < len(ls) && ls[i].Kind == "add"; i++ {
195 adds = append(adds, &ls[i])
196 }
197 for j := 0; j < len(dels) || j < len(adds); j++ {
198 var row SplitRow
199 if j < len(dels) {
200 row.L = dels[j]
201 }
202 if j < len(adds) {
203 row.R = adds[j]
204 }
205 out = append(out, row)
206 }
207 }
208 return out
209}
210
211// statusLetter is the one-letter status of a file.
212func statusLetter(s domain.FileStatus) string {
213 switch s {
214 case domain.FileAdded:
215 return "A"
216 case domain.FileDeleted:
217 return "D"
218 case domain.FileRenamed:
219 return "R"
220 case domain.FileCopied:
221 return "C"
222 case domain.FileTypeChanged:
223 return "T"
224 }
225 return "M"
226}
227
228// fileFilterMin is the number of files above which the file list has a
229// filter.
230const fileFilterMin = 10
231
232// statusName says what a status letter means, for its tooltip.
233func statusName(letter string) string {
234 switch letter {
235 case "A":
236 return "added"
237 case "D":
238 return "deleted"
239 case "R":
240 return "renamed"
241 case "C":
242 return "copied"
243 case "T":
244 return "type changed"
245 }
246 return "modified"
247}
248
249// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
250func hunkHeader(h domain.Hunk) string {
251 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
252 if t := h.Header; t != "" {
253 s += " " + visibleText(strings.TrimSpace(t))
254 }
255 return s
256}
257
258// diffLine converts a line of a hunk.
259func diffLine(l domain.Line) DiffLine {
260 dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
261 switch l.Kind {
262 case domain.LineAddition:
263 dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
264 case domain.LineDeletion:
265 dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
266 default:
267 dl.Kind, dl.Mark = "ctx", " "
268 dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber)
269 }
270 return dl
271}
272
273// diffFiles converts the files of a diff for the page.
274func diffFiles(files []domain.FileDiff) []DiffFile {
275 out := make([]DiffFile, 0, len(files))
276 for i, f := range files {
277 df := DiffFile{
278 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
279 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
280 // A file that is shown in full opens; a file with nothing to read
281 // (binary, truncated) stays closed under its note.
282 Open: len(f.Hunks) > 0 && !f.Truncated,
283 }
284 df.Key = f.NewPath
285 switch f.Status {
286 case domain.FileDeleted:
287 df.Key = f.OldPath
288 df.Path = visibleText(f.OldPath)
289 case domain.FileRenamed, domain.FileCopied:
290 df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath)
291 default:
292 df.Path = visibleText(f.NewPath)
293 }
294 df.Note = fileNote(f)
295 df.Digits = gutterDigits(f.Hunks)
296 df.Name, df.Dir = splitPath(df.Key)
297 for _, h := range f.Hunks {
298 dh := DiffHunk{Header: hunkHeader(h)}
299 for _, l := range h.Lines {
300 dh.Lines = append(dh.Lines, diffLine(l))
301 }
302 dh.Split = pairLines(dh.Lines)
303 df.Hunks = append(df.Hunks, dh)
304 }
305 out = append(out, df)
306 }
307 return out
308}
309
310// fileNote says what the hunks cannot: the kind of change when there is no
311// text to show, the mode change, the similarity of a rename.
312func fileNote(f domain.FileDiff) string {
313 var parts []string
314 switch {
315 case f.Binary:
316 parts = append(parts, "binary file: no text diff")
317 case f.Truncated:
318 parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
319 }
320 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
321 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
322 }
323 switch f.Status {
324 case domain.FileRenamed:
325 parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
326 case domain.FileCopied:
327 parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
328 case domain.FileTypeChanged:
329 parts = append(parts, "type changed")
330 }
331 return strings.Join(parts, " · ")
332}
333
334// noEOL marks a side that ends without a line break.
335const noEOL = "\u23ce\u0338"
336
337// codeLine renders the text of a line of code. It writes exactly the text,
338// escaped, and the markers: code cells keep their white space (pre), so the
339// spaces a template adds between expressions would show up in the code.
340func codeLine(ln DiffLine) templ.Component {
341 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
342 var b strings.Builder
343 for _, s := range ln.Segs {
344 if s.Mark {
345 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
346 } else {
347 b.WriteString(html.EscapeString(s.Text))
348 }
349 }
350 if ln.NoEOL {
351 b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
352 }
353 _, err := io.WriteString(w, b.String())
354 return err
355 })
356}