v0.1.0 3f267b5

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

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