v0.26.1 dfd6f0b

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	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}

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 2

Tags 45

↑↓ moveenter openesc close