diff --git a/internal/render/render.go b/internal/render/render.go
index f523fe4..c423b78 100644
--- a/internal/render/render.go
+++ b/internal/render/render.go
@@ -232,6 +232,62 @@ func MarkdownLinks(src []byte, lc *LinkContext) template.HTML {
return template.HTML(sanitizer.SanitizeBytes(buf.Bytes())) //nolint:gosec // sanitized above
}
+// Diff renders a unified diff (git's patch text) to HTML: one styled row per
+// line — file/hunk headers, additions, deletions, context — so the browse UI
+// shows a colored diff instead of a plain <pre>. The input is git's own output
+// (trusted origin), but every line is HTML-escaped, so the result is safe to
+// embed. An empty diff yields "" (the caller shows nothing).
+func Diff(unified string) template.HTML {
+ if strings.TrimSpace(unified) == "" {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString(`<div class="diff">`)
+ lines := strings.Split(unified, "\n")
+ // A trailing newline splits into a final empty element; drop it so it doesn't
+ // render as a stray blank row.
+ if n := len(lines); n > 0 && lines[n-1] == "" {
+ lines = lines[:n-1]
+ }
+ for _, line := range lines {
+ b.WriteString(`<div class="dl `)
+ b.WriteString(diffLineClass(line))
+ b.WriteString(`">`)
+ // An empty line still needs height; a zero-width space keeps the row.
+ if line == "" {
+ b.WriteString("​")
+ } else {
+ b.WriteString(template.HTMLEscapeString(line))
+ }
+ b.WriteString(`</div>`)
+ }
+ b.WriteString(`</div>`)
+ return template.HTML(b.String()) //nolint:gosec // every line is HTML-escaped above
+}
+
+// diffLineClass maps a unified-diff line to its CSS row class by the leading
+// marker. File-level headers (`diff --git`, `+++`, `---`, mode/rename lines, …)
+// are checked before the bare +/- so they aren't miscolored as add/delete.
+func diffLineClass(line string) string {
+ switch {
+ case strings.HasPrefix(line, "@@"):
+ return "dl-hunk"
+ case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),
+ strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "),
+ strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"),
+ strings.HasPrefix(line, "old mode"), strings.HasPrefix(line, "new mode"),
+ strings.HasPrefix(line, "rename "), strings.HasPrefix(line, "similarity "),
+ strings.HasPrefix(line, "copy "), strings.HasPrefix(line, "Binary files"):
+ return "dl-file"
+ case strings.HasPrefix(line, "+"):
+ return "dl-add"
+ case strings.HasPrefix(line, "-"):
+ return "dl-del"
+ default: // context, "\ No newline at end of file", blank
+ return "dl-ctx"
+ }
+}
+
// HasMermaid reports whether rendered markdown contains a mermaid diagram block
// (emitted as <pre class="mermaid">), so a page pulls in the mermaid script only
// when it actually has a diagram. Repo text that literally reads class="mermaid"
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
index 1875784..f265b1b 100644
--- a/internal/render/render_test.go
+++ b/internal/render/render_test.go
@@ -145,6 +145,53 @@ func TestMarkdownLinksNilContextIsInert(t *testing.T) {
}
}
+func TestDiff(t *testing.T) {
+ unified := strings.Join([]string{
+ "diff --git a/hello.go b/hello.go",
+ "index 1111111..2222222 100644",
+ "--- a/hello.go",
+ "+++ b/hello.go",
+ "@@ -1,3 +1,3 @@ func main() {",
+ " context stays",
+ "-func main() {}",
+ "+func main() { println(1) }",
+ }, "\n") + "\n"
+ out := string(Diff(unified))
+
+ for _, want := range []string{
+ `class="diff"`,
+ `class="dl dl-file">diff --git a/hello.go b/hello.go</div>`,
+ `class="dl dl-file">--- a/hello.go</div>`, // --- is a file header, not a deletion
+ `class="dl dl-file">+++ b/hello.go</div>`, // +++ is a file header, not an addition
+ `class="dl dl-hunk">@@ -1,3 +1,3 @@`,
+ `class="dl dl-ctx"> context stays</div>`,
+ `class="dl dl-del">-func main() {}</div>`,
+ `class="dl dl-add">+func main() { println(1) }</div>`,
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("diff HTML missing %q:\n%s", want, out)
+ }
+ }
+ // The trailing newline must not produce a stray empty row.
+ if strings.Contains(out, `<div class="dl dl-ctx">​</div></div>`) {
+ t.Errorf("trailing newline rendered as a blank row:\n%s", out)
+ }
+}
+
+func TestDiffEscapesAndEmpty(t *testing.T) {
+ if got := Diff(" \n"); got != "" {
+ t.Errorf("blank diff = %q, want empty", got)
+ }
+ // A diff line containing HTML must be escaped, never emitted as live markup.
+ out := string(Diff("+<script>alert(1)</script>\n"))
+ if strings.Contains(out, "<script>") {
+ t.Fatalf("diff did not escape HTML:\n%s", out)
+ }
+ if !strings.Contains(out, `dl-add">+<script>`) {
+ t.Fatalf("expected escaped addition line:\n%s", out)
+ }
+}
+
func TestMarkdownMermaid(t *testing.T) {
src := []byte("# Diagram\n\n```mermaid\ngraph TD\n A --> B\n```\n")
out := string(Markdown(src))
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index ff0aed8..a875853 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -476,7 +476,7 @@ func (h *Handler) renderCommit(w http.ResponseWriter, r *http.Request, c browseC
h.render(w, "browse_commit.html", commitData{
browseBase: h.browseHeader(r, c),
Commit: commit,
- Diff: diff,
+ Diff: render.Diff(diff),
Run: run,
})
}
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index 784b224..9ec1c8c 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -323,8 +323,16 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("commit = %d (%s)", rec.Code, rec.Body)
}
- if !strings.Contains(rec.Body.String(), head) {
- t.Fatalf("commit body missing sha:\n%s", rec.Body)
+ body = rec.Body.String()
+ if !strings.Contains(body, head) {
+ t.Fatalf("commit body missing sha:\n%s", body)
+ }
+ // The diff renders as a colored diff (the second commit edits hello.go, so it
+ // has an added and a removed line), not a plain <pre>.
+ for _, want := range []string{`class="diff"`, "dl-add", "dl-del"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("commit diff not rendered as a colored diff (missing %q):\n%s", want, body)
+ }
}
}
diff --git a/internal/webui/data.go b/internal/webui/data.go
index 8b3a375..9b422a1 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -127,8 +127,8 @@ type commitData struct {
Path string // always empty; present so the shared browse_head renders
Crumbs []crumb // always nil; present so the shared browse_head renders
Commit repo.Commit
- Diff string
- Run *meta.Run // the latest CI run for this commit's SHA, if any (badge)
+ Diff template.HTML // the unified diff rendered as a colored diff (render.Diff)
+ Run *meta.Run // the latest CI run for this commit's SHA, if any (badge)
}
// --- ci status ---
diff --git a/internal/webui/templates/browse_commit.html b/internal/webui/templates/browse_commit.html
index 71433c3..d28e17a 100644
--- a/internal/webui/templates/browse_commit.html
+++ b/internal/webui/templates/browse_commit.html
@@ -7,5 +7,5 @@
{{if .Run}}· CI <a href="/{{.Repo}}/runs/{{.Run.ID}}" style="color:{{statusColor .Run.Status}}">{{.Run.Status}}</a>{{end}}
</p>
{{if .Commit.Body}}<pre>{{.Commit.Body}}</pre>{{end}}
-<pre>{{.Diff}}</pre>
+{{.Diff}}
{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/layout.html b/internal/webui/templates/layout.html
index b0de99f..2774377 100644
--- a/internal/webui/templates/layout.html
+++ b/internal/webui/templates/layout.html
@@ -24,6 +24,12 @@
pre { overflow-x: auto; padding: .75rem; background: #8881; border-radius: 6px; }
.chroma { padding: .75rem; border-radius: 6px; overflow-x: auto; }
.markdown-body { border-top: 1px solid #8884; margin-top: 1.5rem; padding-top: 1rem; }
+ .diff { overflow-x: auto; border: 1px solid #8884; border-radius: 6px; margin: 1rem 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; }
+ .diff .dl { white-space: pre; padding: 0 .6rem; min-height: 1.3em; }
+ .diff .dl-file { background: #8883; font-weight: 600; }
+ .diff .dl-hunk { background: #8882; color: #6b8cae; }
+ .diff .dl-add { background: #2ea04326; }
+ .diff .dl-del { background: #f8514926; }
{{highlightCSS}}
</style>
</head>