detached 5f5b000

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

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