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