detached 5278df2

diff.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close