Commit a630835

Eric Bower  ·  2026-09-14 14:56:24 -0400 EDT
parent 4e9469b
feat: commit diff renderer
4 files changed,  +513, -8
+226, -0
......@@ -0,0 +1,226 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "html"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/alecthomas/chroma/v2"
11+ formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
12+ "github.com/alecthomas/chroma/v2/lexers"
13+ "github.com/alecthomas/chroma/v2/styles"
14+ git "github.com/gogs/git-module"
15+)
16+
17+var inlineChromaFormatter = formatterHtml.New(
18+ formatterHtml.WithClasses(true),
19+ formatterHtml.PreventSurroundingPre(true),
20+)
21+
22+func resolveLexer(fileName string) chroma.Lexer {
23+ lexer := lexers.Match(fileName)
24+ if lexer == nil {
25+ lexer = lexers.Fallback
26+ }
27+ return chroma.Coalesce(lexer)
28+}
29+
30+// FormatDiffHunk formats a git.DiffSection as a syntax-highlighted HTML table
31+// with diff overlay classes and clickable anchor links on line numbers.
32+func FormatDiffHunk(theme *chroma.Style, fileName string, section *git.DiffSection, hunkAnchor string) (string, error) {
33+ if section == nil {
34+ return "", nil
35+ }
36+ if theme == nil {
37+ theme = styles.Fallback
38+ }
39+ lexer := resolveLexer(fileName)
40+
41+ var buf bytes.Buffer
42+ buf.WriteString("<div class=\"chroma diff-container\"><table class=\"diff-table\"><tbody class=\"diff-hunk\">")
43+
44+ var hunkHeader string
45+ var diffLines []*git.DiffLine
46+
47+ if len(section.Lines) > 0 && section.Lines[0].Type == git.DiffLineSection {
48+ hunkHeader = strings.TrimSpace(section.Lines[0].Content)
49+ diffLines = section.Lines[1:]
50+ } else {
51+ diffLines = section.Lines
52+ }
53+
54+ if hunkHeader != "" {
55+ buf.WriteString("<tr class=\"diff-line diff-line-hunk\">")
56+ buf.WriteString("<td class=\"diff-num diff-num-old\">...</td>")
57+ buf.WriteString("<td class=\"diff-num diff-num-new\">...</td>")
58+ buf.WriteString("<td class=\"diff-gutter\"></td>")
59+ buf.WriteString("<td class=\"diff-code\"><span class=\"gu\">")
60+ buf.WriteString(html.EscapeString(hunkHeader))
61+ buf.WriteString("</span></td></tr>\n")
62+ }
63+
64+ for _, line := range diffLines {
65+ var oldNumStr, newNumStr, gutter, rowClass string
66+ var oldAnchorID, newAnchorID string
67+
68+ switch line.Type {
69+ case git.DiffLinePlain:
70+ if line.LeftLine > 0 {
71+ oldNumStr = strconv.Itoa(line.LeftLine)
72+ }
73+ if line.RightLine > 0 {
74+ newNumStr = strconv.Itoa(line.RightLine)
75+ }
76+ if hunkAnchor != "" {
77+ if oldNumStr != "" {
78+ oldAnchorID = fmt.Sprintf("%s-L%s", hunkAnchor, oldNumStr)
79+ }
80+ if newNumStr != "" {
81+ newAnchorID = fmt.Sprintf("%s-R%s", hunkAnchor, newNumStr)
82+ }
83+ }
84+ gutter = " "
85+ rowClass = "diff-line diff-line-context"
86+ case git.DiffLineDelete:
87+ if line.LeftLine > 0 {
88+ oldNumStr = strconv.Itoa(line.LeftLine)
89+ }
90+ newNumStr = ""
91+ if hunkAnchor != "" && oldNumStr != "" {
92+ oldAnchorID = fmt.Sprintf("%s-L%s", hunkAnchor, oldNumStr)
93+ }
94+ gutter = "-"
95+ rowClass = "diff-line diff-line-delete"
96+ case git.DiffLineAdd:
97+ oldNumStr = ""
98+ if line.RightLine > 0 {
99+ newNumStr = strconv.Itoa(line.RightLine)
100+ }
101+ if hunkAnchor != "" && newNumStr != "" {
102+ newAnchorID = fmt.Sprintf("%s-R%s", hunkAnchor, newNumStr)
103+ }
104+ gutter = "+"
105+ rowClass = "diff-line diff-line-add"
106+ default:
107+ gutter = " "
108+ rowClass = "diff-line diff-line-context"
109+ }
110+
111+ buf.WriteString("<tr class=\"")
112+ buf.WriteString(rowClass)
113+ buf.WriteString("\">")
114+
115+ // Old line number column with anchor link
116+ buf.WriteString("<td class=\"diff-num diff-num-old\"")
117+ if oldAnchorID != "" {
118+ buf.WriteString(" id=\"")
119+ buf.WriteString(html.EscapeString(oldAnchorID))
120+ buf.WriteString("\"")
121+ }
122+ if oldNumStr != "" {
123+ buf.WriteString(" data-line-number=\"")
124+ buf.WriteString(oldNumStr)
125+ buf.WriteString("\">")
126+ if oldAnchorID != "" {
127+ buf.WriteString("<a href=\"#")
128+ buf.WriteString(html.EscapeString(oldAnchorID))
129+ buf.WriteString("\">")
130+ buf.WriteString(oldNumStr)
131+ buf.WriteString("</a>")
132+ } else {
133+ buf.WriteString(oldNumStr)
134+ }
135+ } else {
136+ buf.WriteString(">")
137+ }
138+ buf.WriteString("</td>")
139+
140+ // New line number column with anchor link
141+ buf.WriteString("<td class=\"diff-num diff-num-new\"")
142+ if newAnchorID != "" {
143+ buf.WriteString(" id=\"")
144+ buf.WriteString(html.EscapeString(newAnchorID))
145+ buf.WriteString("\"")
146+ }
147+ if newNumStr != "" {
148+ buf.WriteString(" data-line-number=\"")
149+ buf.WriteString(newNumStr)
150+ buf.WriteString("\">")
151+ if newAnchorID != "" {
152+ buf.WriteString("<a href=\"#")
153+ buf.WriteString(html.EscapeString(newAnchorID))
154+ buf.WriteString("\">")
155+ buf.WriteString(newNumStr)
156+ buf.WriteString("</a>")
157+ } else {
158+ buf.WriteString(newNumStr)
159+ }
160+ } else {
161+ buf.WriteString(">")
162+ }
163+ buf.WriteString("</td>")
164+
165+ // Gutter column
166+ buf.WriteString("<td class=\"diff-gutter\">")
167+ buf.WriteString(html.EscapeString(gutter))
168+ buf.WriteString("</td>")
169+
170+ // Code cell
171+ buf.WriteString("<td class=\"diff-code\">")
172+ rawLine := line.Content
173+ var lineContent string
174+ if len(rawLine) > 0 && (rawLine[0] == ' ' || rawLine[0] == '+' || rawLine[0] == '-') {
175+ lineContent = rawLine[1:]
176+ } else {
177+ lineContent = rawLine
178+ }
179+ lineContent = strings.TrimRight(lineContent, "\r\n")
180+ if lineContent == "" {
181+ buf.WriteString("\n")
182+ } else {
183+ it, err := lexer.Tokenise(nil, lineContent)
184+ if err != nil {
185+ buf.WriteString(html.EscapeString(lineContent))
186+ } else {
187+ if err := inlineChromaFormatter.Format(&buf, theme, it); err != nil {
188+ buf.WriteString(html.EscapeString(lineContent))
189+ }
190+ }
191+ }
192+ buf.WriteString("</td></tr>\n")
193+ }
194+
195+ buf.WriteString("</tbody></table></div>")
196+ return buf.String(), nil
197+}
198+
199+// FormatDiffFile formats all hunks in a git.DiffFile into syntax-highlighted HTML.
200+func FormatDiffFile(theme *chroma.Style, file *git.DiffFile) (string, error) {
201+ if file == nil {
202+ return "", nil
203+ }
204+ if file.IsBinary() {
205+ return "<div class=\"chroma diff-container\"><pre style=\"padding: var(--grid-height);\">Binaries are not rendered as diffs.</pre></div>", nil
206+ }
207+ if len(file.Sections) == 0 {
208+ return "", nil
209+ }
210+
211+ fileName := file.Name
212+ if fileName == "" {
213+ fileName = file.OldName()
214+ }
215+
216+ var buf bytes.Buffer
217+ for hunkIdx, section := range file.Sections {
218+ hunkAnchor := fmt.Sprintf("diff-%s-hunk-%d", fileName, hunkIdx)
219+ hunkHtml, err := FormatDiffHunk(theme, fileName, section, hunkAnchor)
220+ if err != nil {
221+ return "", err
222+ }
223+ buf.WriteString(hunkHtml)
224+ }
225+ return buf.String(), nil
226+}
+160, -0
......@@ -0,0 +1,160 @@
1+package main
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/alecthomas/chroma/v2/styles"
8+ git "github.com/gogs/git-module"
9+)
10+
11+func TestFormatDiffHunkGo(t *testing.T) {
12+ section := &git.DiffSection{
13+ Lines: []*git.DiffLine{
14+ {Type: git.DiffLineSection, Content: "@@ -1,5 +1,6 @@"},
15+ {Type: git.DiffLinePlain, Content: " package main", LeftLine: 1, RightLine: 1},
16+ {Type: git.DiffLinePlain, Content: " ", LeftLine: 2, RightLine: 2},
17+ {Type: git.DiffLineDelete, Content: "-func OldFunction() int {", LeftLine: 3},
18+ {Type: git.DiffLineAdd, Content: "+func NewFunction() string {", RightLine: 3},
19+ {Type: git.DiffLineAdd, Content: "+\t// added comment", RightLine: 4},
20+ {Type: git.DiffLinePlain, Content: " \treturn 42", LeftLine: 4, RightLine: 5},
21+ {Type: git.DiffLinePlain, Content: " }", LeftLine: 5, RightLine: 6},
22+ },
23+ }
24+
25+ theme := styles.Get("dracula")
26+ anchor := "diff-main.go-hunk-0"
27+ htmlOut, err := FormatDiffHunk(theme, "main.go", section, anchor)
28+ if err != nil {
29+ t.Fatalf("FormatDiffHunk: %v", err)
30+ }
31+
32+ // Verify table structure
33+ if !strings.Contains(htmlOut, "diff-table") {
34+ t.Errorf("expected diff-table class in output")
35+ }
36+ if !strings.Contains(htmlOut, "diff-line-hunk") {
37+ t.Errorf("expected diff-line-hunk class in output")
38+ }
39+
40+ // Verify line number and diff overlays
41+ if !strings.Contains(htmlOut, "diff-line-add") {
42+ t.Errorf("expected diff-line-add class in output")
43+ }
44+ if !strings.Contains(htmlOut, "diff-line-delete") {
45+ t.Errorf("expected diff-line-delete class in output")
46+ }
47+ if !strings.Contains(htmlOut, "diff-line-context") {
48+ t.Errorf("expected diff-line-context class in output")
49+ }
50+
51+ // Verify gutter markers
52+ if !strings.Contains(htmlOut, "<td class=\"diff-gutter\">+</td>") {
53+ t.Errorf("expected '+' in gutter")
54+ }
55+ if !strings.Contains(htmlOut, "<td class=\"diff-gutter\">-</td>") {
56+ t.Errorf("expected '-' in gutter")
57+ }
58+
59+ // Verify anchor links in line numbers
60+ if !strings.Contains(htmlOut, `id="diff-main.go-hunk-0-L3"`) {
61+ t.Errorf("expected deleted line anchor id in output, got:\n%s", htmlOut)
62+ }
63+ if !strings.Contains(htmlOut, `href="#diff-main.go-hunk-0-L3"`) {
64+ t.Errorf("expected deleted line anchor href in output, got:\n%s", htmlOut)
65+ }
66+ if !strings.Contains(htmlOut, `id="diff-main.go-hunk-0-R3"`) {
67+ t.Errorf("expected added line anchor id in output, got:\n%s", htmlOut)
68+ }
69+ if !strings.Contains(htmlOut, `href="#diff-main.go-hunk-0-R3"`) {
70+ t.Errorf("expected added line anchor href in output, got:\n%s", htmlOut)
71+ }
72+
73+ // Verify syntax highlighting (Chroma classes for Go keywords / comments)
74+ if !strings.Contains(htmlOut, "class=\"kd\"") && !strings.Contains(htmlOut, "class=\"k\"") {
75+ t.Errorf("expected keyword chroma class (kd or k) for 'func' or 'package', got:\n%s", htmlOut)
76+ }
77+ if !strings.Contains(htmlOut, "class=\"c1\"") && !strings.Contains(htmlOut, "class=\"c\"") {
78+ t.Errorf("expected comment chroma class (c1 or c) for '// added comment', got:\n%s", htmlOut)
79+ }
80+}
81+
82+func TestFormatDiffHunkZig(t *testing.T) {
83+ section := &git.DiffSection{
84+ Lines: []*git.DiffLine{
85+ {Type: git.DiffLineSection, Content: "@@ -1,4 +1,5 @@"},
86+ {Type: git.DiffLinePlain, Content: " const std = @import(\"std\");", LeftLine: 1, RightLine: 1},
87+ {Type: git.DiffLinePlain, Content: " ", LeftLine: 2, RightLine: 2},
88+ {Type: git.DiffLineDelete, Content: "-pub fn main() void {", LeftLine: 3},
89+ {Type: git.DiffLineAdd, Content: "+pub fn main() !void {", RightLine: 3},
90+ {Type: git.DiffLineAdd, Content: "+ // zig comment", RightLine: 4},
91+ {Type: git.DiffLinePlain, Content: " }", LeftLine: 4, RightLine: 5},
92+ },
93+ }
94+
95+ htmlOut, err := FormatDiffHunk(nil, "src/main.zig", section, "diff-main.zig-hunk-0")
96+ if err != nil {
97+ t.Fatalf("FormatDiffHunk: %v", err)
98+ }
99+
100+ // Verify Zig syntax highlighting tokens
101+ if !strings.Contains(htmlOut, "class=\"k\"") && !strings.Contains(htmlOut, "class=\"kd\"") {
102+ t.Errorf("expected keyword class for 'pub' or 'fn' or 'const', got:\n%s", htmlOut)
103+ }
104+ if !strings.Contains(htmlOut, "diff-line-add") {
105+ t.Errorf("expected diff-line-add in zig diff")
106+ }
107+ if !strings.Contains(htmlOut, `href="#diff-main.zig-hunk-0-R3"`) {
108+ t.Errorf("expected anchor link in zig diff, got:\n%s", htmlOut)
109+ }
110+}
111+
112+func TestFormatDiffHunkFallback(t *testing.T) {
113+ section := &git.DiffSection{
114+ Lines: []*git.DiffLine{
115+ {Type: git.DiffLineSection, Content: "@@ -1,2 +1,2 @@"},
116+ {Type: git.DiffLineDelete, Content: "-foo", LeftLine: 1},
117+ {Type: git.DiffLineAdd, Content: "+bar", RightLine: 1},
118+ },
119+ }
120+
121+ // Unknown extension should not error and still render diff table correctly
122+ htmlOut, err := FormatDiffHunk(nil, "unknown.xyz", section, "")
123+ if err != nil {
124+ t.Fatalf("FormatDiffHunk: %v", err)
125+ }
126+
127+ if !strings.Contains(htmlOut, "diff-table") {
128+ t.Errorf("expected diff-table class in output")
129+ }
130+ if !strings.Contains(htmlOut, "diff-line-add") {
131+ t.Errorf("expected diff-line-add in unknown file diff")
132+ }
133+}
134+
135+func TestFormatDiffFile(t *testing.T) {
136+ file := &git.DiffFile{
137+ Name: "test.go",
138+ Sections: []*git.DiffSection{
139+ {
140+ Lines: []*git.DiffLine{
141+ {Type: git.DiffLineSection, Content: "@@ -1,2 +1,2 @@"},
142+ {Type: git.DiffLineDelete, Content: "-package old", LeftLine: 1},
143+ {Type: git.DiffLineAdd, Content: "+package new", RightLine: 1},
144+ },
145+ },
146+ },
147+ }
148+
149+ htmlOut, err := FormatDiffFile(nil, file)
150+ if err != nil {
151+ t.Fatalf("FormatDiffFile: %v", err)
152+ }
153+
154+ if !strings.Contains(htmlOut, "diff-test.go-hunk-0") {
155+ t.Errorf("expected hunk anchor in file diff")
156+ }
157+ if !strings.Contains(htmlOut, "diff-line-add") {
158+ t.Errorf("expected diff-line-add")
159+ }
160+}
+12, -8
......@@ -617,14 +617,7 @@ func (c *Config) writeLogDiff(repo *git.Repository, pageData PageData, commit *C
617617 NumAdditions: file.NumAdditions(),
618618 NumDeletions: file.NumDeletions(),
619619 }
620- content := ""
621- for _, section := range file.Sections {
622- for _, line := range section.Lines {
623- content += fmt.Sprintf("%s\n", line.Content)
624- }
625- }
626- // set filename to something our `ParseText` recognizes (e.g. `.diff`)
627- finContent, err := c.parseText("commit.diff", content)
620+ finContent, err := FormatDiffFile(c.Theme, file)
628621 bail(err)
629622
630623 fl.Content = template.HTML(finContent)
......@@ -1220,6 +1213,16 @@ func style(theme chroma.Style) string {
12201213 --link-color: %s;
12211214 --hover: %s;
12221215 --visited: %s;
1216+ --grey-light: %s;
1217+ --admin: #f38ba8;
1218+ --success: #66f859;
1219+ --diff-add-bg: rgba(102, 248, 89, 0.12);
1220+ --diff-add-gutter: var(--success);
1221+ --diff-del-bg: rgba(243, 139, 168, 0.12);
1222+ --diff-del-gutter: var(--admin);
1223+ --diff-hunk-bg: rgba(139, 233, 253, 0.08);
1224+ --diff-hunk-text: var(--link-color);
1225+ --diff-num-text: var(--grey-light);
12231226 }`,
12241227 bg.Background.String(),
12251228 txt.Colour.String(),
......@@ -1227,6 +1230,7 @@ func style(theme chroma.Style) string {
12271230 nv.Colour.String(),
12281231 kw.Colour.String(),
12291232 ln.Colour.String(),
1233+ cm.Colour.String(),
12301234 )
12311235 }
12321236
+115, -0
......@@ -300,3 +300,118 @@ footer {
300300 display: none;
301301 }
302302 }
303+
304+.color-green {
305+ color: var(--success);
306+}
307+
308+.color-red {
309+ color: var(--admin);
310+}
311+
312+.chroma .gi {
313+ font-weight: normal !important;
314+}
315+
316+.diff-container {
317+ width: 100%;
318+ overflow-x: auto;
319+ border: 1px solid var(--border);
320+ border-radius: 4px;
321+ margin: var(--grid-height) 0;
322+}
323+
324+.diff-table {
325+ width: 100%;
326+ border-collapse: collapse;
327+ font-family: monospace;
328+ font-size: 0.88em;
329+ line-height: var(--line-height);
330+ tab-size: 4;
331+ margin: 0;
332+}
333+
334+.diff-line {
335+ vertical-align: top;
336+}
337+
338+.diff-num {
339+ width: 1%;
340+ min-width: 2.5rem;
341+ padding: 0 0.5rem;
342+ text-align: right;
343+ color: var(--diff-num-text, var(--grey-light));
344+ user-select: none;
345+ -webkit-user-select: none;
346+ font-size: 0.85em;
347+ white-space: nowrap;
348+ scroll-margin-top: 4rem;
349+}
350+
351+.diff-num a {
352+ color: inherit;
353+ text-decoration: none;
354+ display: block;
355+}
356+
357+.diff-num a:hover {
358+ color: var(--link-color);
359+ text-decoration: underline;
360+}
361+
362+.diff-num-old {
363+ border-right: 1px solid transparent;
364+}
365+
366+.diff-num-new {
367+ border-right: 1px solid var(--border);
368+}
369+
370+.diff-line:has(.diff-num:target),
371+.diff-line:target {
372+ background-color: rgba(189, 147, 249, 0.2) !important;
373+ outline: 1px solid var(--visited);
374+}
375+
376+.diff-gutter {
377+ width: 1%;
378+ min-width: 1.2rem;
379+ padding: 0 0.25rem;
380+ text-align: center;
381+ user-select: none;
382+ -webkit-user-select: none;
383+ font-weight: bold;
384+}
385+
386+.diff-code {
387+ padding: 0 0.5rem;
388+ white-space: pre;
389+ font-family: inherit;
390+ width: 100%;
391+}
392+
393+.diff-line-add {
394+ background-color: var(--diff-add-bg, rgba(102, 248, 89, 0.12));
395+}
396+
397+.diff-line-add .diff-gutter {
398+ color: var(--diff-add-gutter, var(--success));
399+}
400+
401+.diff-line-delete {
402+ background-color: var(--diff-del-bg, rgba(243, 139, 168, 0.12));
403+}
404+
405+.diff-line-delete .diff-gutter {
406+ color: var(--diff-del-gutter, var(--admin));
407+}
408+
409+.diff-line-hunk {
410+ background-color: var(--diff-hunk-bg, rgba(139, 233, 253, 0.08));
411+}
412+
413+.diff-line-hunk .diff-code,
414+.diff-line-hunk .diff-num {
415+ color: var(--diff-hunk-text, var(--link-color));
416+}
417+