v0.6.0 d5cc8fe

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	"slices"
11	"strings"
12	"unicode/utf8"
13
14	"github.com/a-h/templ"
15
16	pb "bnema.dev/ermite-api/gen/ermite"
17)
18
19// Seg is a piece of a line of code: plain text, or the visible marker of an
20// invisible character (Trojan Source, ADR 0017).
21type Seg struct {
22	Text string
23	Mark bool
24}
25
26// DiffLine is one line of a hunk, in the unified view and, paired, in the
27// split view.
28type DiffLine struct {
29	Kind     string // "ctx", "add", "del"
30	Mark     string // " ", "+", "-": every change keeps its marker, not only a colour
31	Old, New string // line numbers, "" when the side has none
32	Segs     []Seg
33	NoEOL    bool // the side ends without a line break
34	// OldHref and NewHref link the line number of a side to the comment form
35	// of that side; they are set on a merge request that can be commented on.
36	OldHref, NewHref string
37	// Notes are the comments of a merge request on this line, and Form the
38	// form to write one, when the reader asked for it.
39	Notes []CommentView
40	Form  *CommentForm
41
42	// oldN and newN are the line numbers of the sides, 0 for none.
43	oldN, newN int
44}
45
46// SplitRow pairs a deleted line (left) with an added one (right); a context
47// line fills both sides.
48type SplitRow struct{ L, R *DiffLine }
49
50// Notes are the comments under a row of the split view: those of its left
51// line, then those of its right line; a context line, on both sides, counts
52// once.
53func (r SplitRow) Notes() []CommentView {
54	switch {
55	case r.L == nil && r.R == nil:
56		return nil
57	case r.L == nil:
58		return r.R.Notes
59	case r.R == nil || r.L == r.R:
60		return r.L.Notes
61	}
62	return append(append([]CommentView(nil), r.L.Notes...), r.R.Notes...)
63}
64
65// Form is the comment form of the row, nil when there is none.
66func (r SplitRow) Form() *CommentForm {
67	switch {
68	case r.R != nil && r.R.Form != nil:
69		return r.R.Form
70	case r.L != nil:
71		return r.L.Form
72	}
73	return nil
74}
75
76// DiffHunk is a changed region.
77type DiffHunk struct {
78	Header string
79	Lines  []DiffLine
80	Split  []SplitRow
81}
82
83// Bar is the change bar of the file list, "+++--·····", in three runs so each
84// can carry its colour.
85type Bar struct{ Add, Del, Rest string }
86
87// DiffFile is one changed file.
88type DiffFile struct {
89	ID     string
90	Status string // A D M R C T
91	// Key is the path comments name: the new path, the old path of a deleted
92	// file. It is not fit to display (Path is).
93	Key      string
94	Path     string // display path, "old → new" for a rename
95	Add, Del int
96	Bar      Bar
97	// Note is why there is no text to show, or what else changed: a binary
98	// file, a mode change, a truncation, a similarity.
99	Note  string
100	Open  bool
101	Hunks []DiffHunk
102}
103
104func lineNumber(n int32) string {
105	if n <= 0 {
106		return ""
107	}
108	return itoa(int(n))
109}
110
111// segments splits a line of code into plain text and markers. One trailing
112// carriage return, the terminator of a CRLF file, is dropped.
113func segments(text string) []Seg {
114	text = strings.TrimSuffix(text, "\r")
115	if !strings.ContainsFunc(text, invisible) {
116		return []Seg{{Text: text}}
117	}
118	var out []Seg
119	start := 0
120	for i, r := range text {
121		if !invisible(r) {
122			continue
123		}
124		if i > start {
125			out = append(out, Seg{Text: text[start:i]})
126		}
127		out = append(out, Seg{Text: marker(r), Mark: true})
128		start = i + utf8.RuneLen(r)
129	}
130	if start < len(text) {
131		out = append(out, Seg{Text: text[start:]})
132	}
133	return out
134}
135
136// messageText is a commit message for display: line breaks normalised, the
137// invisible characters shown.
138func messageText(s string) string {
139	s = strings.ReplaceAll(s, "\r\n", "\n")
140	return visibleText(strings.TrimRight(s, "\n"))
141}
142
143// hasMarker reports whether a line shows a marker of an invisible character.
144func hasMarker(l DiffLine) bool {
145	return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark })
146}
147
148// pairLines builds the split view: a run of deletions lines up row by row
149// with the run of additions that follows it; context fills both sides.
150func pairLines(ls []DiffLine) []SplitRow {
151	var out []SplitRow
152	for i := 0; i < len(ls); {
153		if ls[i].Kind == "ctx" {
154			out = append(out, SplitRow{&ls[i], &ls[i]})
155			i++
156			continue
157		}
158		var dels, adds []*DiffLine
159		for ; i < len(ls) && ls[i].Kind == "del"; i++ {
160			dels = append(dels, &ls[i])
161		}
162		for ; i < len(ls) && ls[i].Kind == "add"; i++ {
163			adds = append(adds, &ls[i])
164		}
165		for j := 0; j < len(dels) || j < len(adds); j++ {
166			var row SplitRow
167			if j < len(dels) {
168				row.L = dels[j]
169			}
170			if j < len(adds) {
171				row.R = adds[j]
172			}
173			out = append(out, row)
174		}
175	}
176	return out
177}
178
179// changeBar draws the change bar of a file, scaled to the largest file.
180func changeBar(add, del, most int) Bar {
181	const cells = 10
182	if most == 0 {
183		return Bar{Rest: strings.Repeat("·", cells)}
184	}
185	scale := func(n int) int {
186		c := (n*cells + most - 1) / most
187		if n > 0 && c == 0 {
188			c = 1
189		}
190		return c
191	}
192	a, d := scale(add), scale(del)
193	if a+d > cells {
194		d = cells - a
195	}
196	return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)}
197}
198
199// statusLetter is the one-letter status of a file.
200func statusLetter(s pb.FileStatus) string {
201	switch s {
202	case pb.FileStatus_FILE_STATUS_ADDED:
203		return "A"
204	case pb.FileStatus_FILE_STATUS_DELETED:
205		return "D"
206	case pb.FileStatus_FILE_STATUS_RENAMED:
207		return "R"
208	case pb.FileStatus_FILE_STATUS_COPIED:
209		return "C"
210	case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
211		return "T"
212	}
213	return "M"
214}
215
216// hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk.
217func hunkHeader(h *pb.Hunk) string {
218	s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines())
219	if t := h.GetHeader(); t != "" {
220		s += " " + visibleText(strings.TrimSpace(t))
221	}
222	return s
223}
224
225// diffLine converts a line of the API.
226func diffLine(l *pb.Line) DiffLine {
227	dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())}
228	switch l.GetKind() {
229	case pb.LineKind_LINE_KIND_ADDITION:
230		dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
231	case pb.LineKind_LINE_KIND_DELETION:
232		dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber())
233	default:
234		dl.Kind, dl.Mark = "ctx", " "
235		dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber())
236	}
237	return dl
238}
239
240// diffFiles converts the files of a diff for the page.
241func diffFiles(files []*pb.FileDiff) []DiffFile {
242	most := 0
243	for _, f := range files {
244		most = max(most, int(f.GetAdditions()+f.GetDeletions()))
245	}
246	out := make([]DiffFile, 0, len(files))
247	for i, f := range files {
248		df := DiffFile{
249			ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()),
250			Add: int(f.GetAdditions()), Del: int(f.GetDeletions()),
251			Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most),
252			// A file that is shown in full opens; a file with nothing to read
253			// (binary, truncated) stays closed under its note.
254			Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
255		}
256		df.Key = f.GetNewPath()
257		switch f.GetStatus() {
258		case pb.FileStatus_FILE_STATUS_DELETED:
259			df.Key = f.GetOldPath()
260			df.Path = visibleText(f.GetOldPath())
261		case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
262			df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
263		default:
264			df.Path = visibleText(f.GetNewPath())
265		}
266		df.Note = fileNote(f)
267		if f.GetBinary() {
268			df.Bar = Bar{Rest: "bin"}
269		}
270		for _, h := range f.GetHunks() {
271			dh := DiffHunk{Header: hunkHeader(h)}
272			for _, l := range h.GetLines() {
273				dh.Lines = append(dh.Lines, diffLine(l))
274			}
275			dh.Split = pairLines(dh.Lines)
276			df.Hunks = append(df.Hunks, dh)
277		}
278		out = append(out, df)
279	}
280	return out
281}
282
283// fileNote says what the hunks cannot: the kind of change when there is no
284// text to show, the mode change, the similarity of a rename.
285func fileNote(f *pb.FileDiff) string {
286	var parts []string
287	switch {
288	case f.GetBinary():
289		parts = append(parts, "binary file: no text diff")
290	case f.GetTruncated():
291		parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions()))
292	}
293	if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() {
294		parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode()))
295	}
296	switch f.GetStatus() {
297	case pb.FileStatus_FILE_STATUS_RENAMED:
298		parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity()))
299	case pb.FileStatus_FILE_STATUS_COPIED:
300		parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity()))
301	case pb.FileStatus_FILE_STATUS_TYPE_CHANGED:
302		parts = append(parts, "type changed")
303	}
304	return strings.Join(parts, " · ")
305}
306
307// noEOL marks a side that ends without a line break.
308const noEOL = "\u23ce\u0338"
309
310// codeLine renders the text of a line of code. It writes exactly the text,
311// escaped, and the markers: code cells keep their white space (pre), so the
312// spaces a template adds between expressions would show up in the code.
313func codeLine(ln DiffLine) templ.Component {
314	return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
315		var b strings.Builder
316		for _, s := range ln.Segs {
317			if s.Mark {
318				b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`)
319			} else {
320				b.WriteString(html.EscapeString(s.Text))
321			}
322		}
323		if ln.NoEOL {
324			b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`)
325		}
326		_, err := io.WriteString(w, b.String())
327		return err
328	})
329}

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