1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "html"
9 "io"
10 "net/url"
11 "slices"
12 "strings"
13 "unicode/utf8"
14
15 "github.com/a-h/templ"
16
17 "bnema.dev/ermite/internal/domain"
18)
19
20// Seg is a piece of a line of code: plain text, or the visible marker of an
21// invisible character (Trojan Source, ADR 0017).
22type Seg struct {
23 Text string
24 Mark bool
25 // Class is the CSS class of the syntax token the text belongs to (the
26 // classes of 56-chroma.css), "" for plain text and for markers.
27 Class string
28}
29
30// DiffLine is one line of a hunk, in the unified view and, paired, in the
31// split view.
32type DiffLine struct {
33 Kind string // "ctx", "add", "del"
34 Mark string // " ", "+", "-": every change keeps its marker, not only a colour
35 Old, New string // line numbers, "" when the side has none
36 Segs []Seg
37 NoEOL bool // the side ends without a line break
38 // OldHref and NewHref link the line number of a side to the comment form
39 // of that side; they are set on a merge request that can be commented on.
40 OldHref, NewHref string
41 // Notes are the comments of a merge request on this line, and Form the
42 // form to write one, when the reader asked for it.
43 Notes []CommentView
44 Form *CommentForm
45
46 // oldN and newN are the line numbers of the sides, 0 for none.
47 oldN, newN int
48}
49
50// SplitRow pairs a deleted line (left) with an added one (right); a context
51// line fills both sides.
52type SplitRow struct{ L, R *DiffLine }
53
54// Notes are the comments under a row of the split view: those of its left
55// line, then those of its right line; a context line, on both sides, counts
56// once.
57func (r SplitRow) Notes() []CommentView {
58 switch {
59 case r.L == nil && r.R == nil:
60 return nil
61 case r.L == nil:
62 return r.R.Notes
63 case r.R == nil || r.L == r.R:
64 return r.L.Notes
65 }
66 return append(append([]CommentView(nil), r.L.Notes...), r.R.Notes...)
67}
68
69// Form is the comment form of the row, nil when there is none.
70func (r SplitRow) Form() *CommentForm {
71 switch {
72 case r.R != nil && r.R.Form != nil:
73 return r.R.Form
74 case r.L != nil:
75 return r.L.Form
76 }
77 return nil
78}
79
80// DiffHunk is a changed region.
81type DiffHunk struct {
82 Header string
83 Lines []DiffLine
84 Split []SplitRow
85 // Gap is the unchanged lines before the hunk that the diff does not show,
86 // nil when there are none or they cannot be loaded.
87 Gap *DiffGap
88}
89
90// DiffGap is a run of unchanged lines a diff hides: a row of the diff that
91// loads them (diffContext) and takes their place.
92type DiffGap struct {
93 // Key tells the gap apart within its file: the unified and the split
94 // tables hold one row each for it.
95 Key string
96 // Lines is the number of hidden lines, 0 for the rest of the file.
97 Lines int
98 Src string
99}
100
101// Label is the text of the row of the gap.
102func (g DiffGap) Label() string {
103 if g.Lines == 0 {
104 return "show the rest of the file"
105 }
106 return plural(g.Lines, "hidden line", "hidden lines")
107}
108
109// DiffFile is one changed file.
110type DiffFile struct {
111 ID string
112 Status string // A D M R C T
113 // Key is the path comments name: the new path, the old path of a deleted
114 // file. It is not fit to display (Path is).
115 Key string
116 Path string // display path, "old → new" for a rename
117 Add, Del int
118 // Bin is true for a binary file: no lines to count.
119 Bin bool
120 // Note is why there is no text to show, or what else changed: a binary
121 // file, a mode change, a truncation, a similarity.
122 Note string
123 Open bool
124 Hunks []DiffHunk
125 // Digits is the width of the largest line number of the file, at least 2
126 // and at most 7: the gutter is as wide as the file needs (class lnw-N).
127 Digits int
128 // Name and Dir are the last element of the path and the directory before
129 // it ("" at the root), for the compact file list.
130 Name, Dir string
131 // Notes is the number of comments on lines of the file, Open those not
132 // resolved; the file list shows them.
133 Notes, OpenNotes int
134 // Lazy is true when the hunks of the file were not read: the page shows a
135 // placeholder that loads them (Src, the window of files it belongs to).
136 Lazy bool
137 Src string
138 // From is the index of the first file of the window of a lazy file: the
139 // page without scripts links to ?from=From.
140 From int
141 // Height is the size class of a lazy file (1 to 4, by its changed lines),
142 // so that the placeholder takes about the room of the diff.
143 Height int
144 // Comments are the ids of the comments on the file ("c12"): a link to a
145 // comment of a lazy file loads that file first.
146 Comments string
147 // Loose are the comments of a file loaded with its window that are on a
148 // line its diff does not show: listed at the end of the file.
149 Loose []CommentView
150 // Tail is the rest of the file after its last hunk, nil when it cannot be
151 // loaded. Its length is not known until it is.
152 Tail *DiffGap
153}
154
155// diffContextLines is the number of unchanged lines Git shows around a change
156// (the -U3 of the diffs of the Git adapter, Git's default).
157const diffContextLines = 3
158
159// contextGaps gives the files of a diff the gaps around their hunks, which
160// load the hidden lines of the new side from base (/<repo>/context/<commit>,
161// the commit of the new side). Only a file whose two sides hold text has
162// them: an added or a deleted file shows every line already.
163func contextGaps(out []DiffFile, files []domain.FileDiff, base string) {
164 for i, f := range files {
165 if i >= len(out) || f.Binary || f.Truncated || len(f.Hunks) == 0 || len(out[i].Hunks) != len(f.Hunks) {
166 continue
167 }
168 switch f.Status {
169 case domain.FileModified, domain.FileRenamed, domain.FileCopied:
170 default:
171 continue
172 }
173 gap := func(key string, oldFrom, newFrom, n int) *DiffGap {
174 q := url.Values{}
175 q.Set("path", f.NewPath)
176 q.Set("old", itoa(oldFrom))
177 q.Set("new", itoa(newFrom))
178 q.Set("n", itoa(n))
179 q.Set("key", key)
180 return &DiffGap{Key: key, Lines: n, Src: base + "?" + q.Encode()}
181 }
182 // first is the first line of a side of a hunk: a side without lines
183 // names the line before the hunk ("-5,0").
184 first := func(start, lines int) int {
185 if lines == 0 {
186 return start + 1
187 }
188 return start
189 }
190 oldNext, newNext := 1, 1
191 for j, h := range f.Hunks {
192 oldFirst, newFirst := first(h.OldStart, h.OldLines), first(h.NewStart, h.NewLines)
193 if n := newFirst - newNext; n > 0 && n == oldFirst-oldNext {
194 out[i].Hunks[j].Gap = gap("g"+itoa(j), oldNext, newNext, n)
195 }
196 oldNext, newNext = oldFirst+h.OldLines, newFirst+h.NewLines
197 }
198 // Git ends a hunk diffContextLines lines after its last change, or at
199 // the end of the file: fewer lines after the last change mean the
200 // file ends there.
201 last := f.Hunks[len(f.Hunks)-1].Lines
202 trailing := 0
203 for k := len(last) - 1; k >= 0 && last[k].Kind == domain.LineContext && !last[k].NoNewlineAtEOF; k-- {
204 trailing++
205 }
206 if trailing >= diffContextLines {
207 out[i].Tail = gap("tail", oldNext, newNext, 0)
208 }
209 }
210}
211
212// gutterDigits is the number of digits of the largest line number of the
213// hunks, clamped to the widths the style sheet has a class for.
214func gutterDigits(hunks []domain.Hunk) int {
215 most := 0
216 for _, h := range hunks {
217 most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines)
218 }
219 return min(max(len(itoa(most)), 2), 7)
220}
221
222func lineNumber(n int) string {
223 if n <= 0 {
224 return ""
225 }
226 return itoa(n)
227}
228
229// segments splits a line of code into plain text and markers. One trailing
230// carriage return, the terminator of a CRLF file, is dropped.
231func segments(text string) []Seg {
232 return appendSegs(nil, strings.TrimSuffix(text, "\r"), "")
233}
234
235// appendSegs appends to out the pieces of text, a piece of code of the
236// syntax class class: plain text of that class, and the markers of its
237// invisible characters.
238func appendSegs(out []Seg, text, class string) []Seg {
239 if !strings.ContainsFunc(text, invisible) {
240 return append(out, Seg{Text: text, Class: class})
241 }
242 start := 0
243 for i, r := range text {
244 if !invisible(r) {
245 continue
246 }
247 if i > start {
248 out = append(out, Seg{Text: text[start:i], Class: class})
249 }
250 out = append(out, Seg{Text: marker(r), Mark: true})
251 start = i + utf8.RuneLen(r)
252 }
253 if start < len(text) {
254 out = append(out, Seg{Text: text[start:], Class: class})
255 }
256 return out
257}
258
259// messageText is a commit message for display: line breaks normalised, the
260// invisible characters shown.
261func messageText(s string) string {
262 s = strings.ReplaceAll(s, "\r\n", "\n")
263 return visibleText(strings.TrimRight(s, "\n"))
264}
265
266// hasMarker reports whether a line shows a marker of an invisible character.
267func hasMarker(l DiffLine) bool {
268 return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
269}
270
271// pairLines builds the split view: a run of deletions lines up row by row
272// with the run of additions that follows it; context fills both sides.
273func pairLines(ls []DiffLine) []SplitRow {
274 var out []SplitRow
275 for i := 0; i < len(ls); {
276 if ls[i].Kind == "ctx" {
277 out = append(out, SplitRow{&ls[i], &ls[i]})
278 i++
279 continue
280 }
281 var dels, adds []*DiffLine
282 for ; i < len(ls) && ls[i].Kind == "del"; i++ {
283 dels = append(dels, &ls[i])
284 }
285 for ; i < len(ls) && ls[i].Kind == "add"; i++ {
286 adds = append(adds, &ls[i])
287 }
288 for j := 0; j < len(dels) || j < len(adds); j++ {
289 var row SplitRow
290 if j < len(dels) {
291 row.L = dels[j]
292 }
293 if j < len(adds) {
294 row.R = adds[j]
295 }
296 out = append(out, row)
297 }
298 }
299 return out
300}
301
302// statusLetter is the one-letter status of a file.
303func statusLetter(s domain.FileStatus) string {
304 switch s {
305 case domain.FileAdded:
306 return "A"
307 case domain.FileDeleted:
308 return "D"
309 case domain.FileRenamed:
310 return "R"
311 case domain.FileCopied:
312 return "C"
313 case domain.FileTypeChanged:
314 return "T"
315 }
316 return "M"
317}
318
319// fileFilterMin is the number of files above which the file list has a
320// filter.
321const fileFilterMin = 10
322
323// statusName says what a status letter means, for its tooltip.
324func statusName(letter string) string {
325 switch letter {
326 case "A":
327 return "added"
328 case "D":
329 return "deleted"
330 case "R":
331 return "renamed"
332 case "C":
333 return "copied"
334 case "T":
335 return "type changed"
336 }
337 return "modified"
338}
339
340// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
341func hunkHeader(h domain.Hunk) string {
342 s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
343 if t := h.Header; t != "" {
344 s += " " + visibleText(strings.TrimSpace(t))
345 }
346 return s
347}
348
349// diffLine converts a line of a hunk.
350func diffLine(l domain.Line) DiffLine {
351 dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber}
352 switch l.Kind {
353 case domain.LineAddition:
354 dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber)
355 case domain.LineDeletion:
356 dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber)
357 default:
358 dl.Kind, dl.Mark = "ctx", " "
359 dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber)
360 }
361 return dl
362}
363
364// diffFiles converts the files of a diff for the page.
365func diffFiles(files []domain.FileDiff) []DiffFile {
366 out := make([]DiffFile, 0, len(files))
367 for i, f := range files {
368 df := DiffFile{
369 ID: "f" + itoa(i+1), Status: statusLetter(f.Status),
370 Add: f.Additions, Del: f.Deletions, Bin: f.Binary,
371 // A file that is shown in full opens; a file with nothing to read
372 // (binary, truncated) stays closed under its note.
373 Open: len(f.Hunks) > 0 && !f.Truncated,
374 }
375 df.Key = f.NewPath
376 switch f.Status {
377 case domain.FileDeleted:
378 df.Key = f.OldPath
379 df.Path = visibleText(f.OldPath)
380 case domain.FileRenamed, domain.FileCopied:
381 df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath)
382 default:
383 df.Path = visibleText(f.NewPath)
384 }
385 df.Note = fileNote(f)
386 df.Digits = gutterDigits(f.Hunks)
387 df.Name, df.Dir = splitPath(df.Key)
388 for _, h := range f.Hunks {
389 dh := DiffHunk{Header: hunkHeader(h)}
390 for _, l := range h.Lines {
391 dh.Lines = append(dh.Lines, diffLine(l))
392 }
393 dh.Split = pairLines(dh.Lines)
394 df.Hunks = append(df.Hunks, dh)
395 }
396 out = append(out, df)
397 }
398 return out
399}
400
401// fileNote says what the hunks cannot: the kind of change when there is no
402// text to show, the mode change, the similarity of a rename.
403func fileNote(f domain.FileDiff) string {
404 var parts []string
405 switch {
406 case f.Binary:
407 parts = append(parts, "binary file: no text diff")
408 case f.Truncated:
409 parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions))
410 }
411 if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode {
412 parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode))
413 }
414 switch f.Status {
415 case domain.FileRenamed:
416 parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity))
417 case domain.FileCopied:
418 parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity))
419 case domain.FileTypeChanged:
420 parts = append(parts, "type changed")
421 }
422 return strings.Join(parts, " · ")
423}
424
425// noEOL marks a side that ends without a line break.
426const noEOL = "\u23ce\u0338"
427
428// codeLine renders the text of a line of code. It writes exactly the text,
429// escaped, and the markers: code cells keep their white space (pre), so the
430// spaces a template adds between expressions would show up in the code.
431func codeLine(ln DiffLine) templ.Component {
432 return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
433 var b strings.Builder
434 for _, s := range ln.Segs {
435 switch {
436 case s.Mark:
437 b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
438 case s.Class != "":
439 b.WriteString(`<span class="` + html.EscapeString(s.Class) + `">` + html.EscapeString(s.Text) + `</span>`)
440 default:
441 b.WriteString(html.EscapeString(s.Text))
442 }
443 }
444 if ln.NoEOL {
445 b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
446 }
447 _, err := io.WriteString(w, b.String())
448 return err
449 })
450}