Go · 11046 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package render produces HTML for parsed diffs. Two modes share the
4 // same per-file structure (header + hunks); only line-level layout
5 // differs. Per-line syntax highlighting via Chroma; binary and image
6 // files get their own placeholders.
7 //
8 // The renderer doesn't know about HTTP — callers wrap the output in
9 // `template.HTML` for the response. This keeps it usable from the
10 // commit page (S18), compare page (S20), and PR files-changed page
11 // (S22) without leaking response shape into the rendering surface.
12 package render
13
14 import (
15 "bytes"
16 "fmt"
17 "html"
18 "path"
19 "strings"
20
21 "github.com/tenseleyFlow/shithub/internal/repos/diff/parse"
22 "github.com/tenseleyFlow/shithub/internal/repos/highlight"
23 )
24
25 // Mode picks unified or split rendering.
26 type Mode string
27
28 const (
29 ModeUnified Mode = "unified"
30 ModeSplit Mode = "split"
31 )
32
33 // Options tunes a render. HighlightCap caps per-file highlight cost;
34 // past it the renderer falls back to plain `<pre>`. Zero uses the
35 // 200 KiB default from the spec.
36 type Options struct {
37 Mode Mode
38 HighlightCap int
39 PerFileLineCap int // collapse files with > this many changed lines (0 = 1000 default)
40 PerFileBytesCap int // collapse files larger than this (0 = 500 KiB)
41 WholeDiffFileCap int // truncate the whole-diff after this many files (0 = 100)
42 }
43
44 // Default returns the spec defaults so callers don't recompute them.
45 func defaults(o Options) Options {
46 if o.Mode == "" {
47 o.Mode = ModeUnified
48 }
49 if o.HighlightCap == 0 {
50 o.HighlightCap = 200 * 1024
51 }
52 if o.PerFileLineCap == 0 {
53 o.PerFileLineCap = 1000
54 }
55 if o.PerFileBytesCap == 0 {
56 o.PerFileBytesCap = 500 * 1024
57 }
58 if o.WholeDiffFileCap == 0 {
59 o.WholeDiffFileCap = 100
60 }
61 return o
62 }
63
64 // Diff renders a full Diff to HTML. Entry point for the templates.
65 // The output is one big string of HTML; for very large diffs callers
66 // can hit RenderFile instead and stream per-file.
67 func Diff(d *parse.Diff, opts Options) string {
68 opts = defaults(opts)
69 if d == nil || len(d.Files) == 0 {
70 return `<div class="shithub-diff-empty">No changes.</div>`
71 }
72 var buf bytes.Buffer
73 totalFiles := len(d.Files)
74 collapseAll := totalFiles > opts.WholeDiffFileCap
75 for i := range d.Files {
76 f := &d.Files[i]
77 // Per-file collapse decision.
78 lineCount := 0
79 for _, h := range f.Hunks {
80 lineCount += len(h.Lines)
81 }
82 f.IsTooLarge = collapseAll ||
83 lineCount > opts.PerFileLineCap ||
84 f.SizeBytes > opts.PerFileBytesCap
85 buf.WriteString(RenderFile(f, opts))
86 }
87 if collapseAll {
88 fmt.Fprintf(&buf,
89 `<div class="shithub-diff-truncated">Diff truncated: %d files; expand each to load its hunks.</div>`,
90 totalFiles)
91 }
92 return buf.String()
93 }
94
95 // RenderFile renders one File. Public so callers (the diff-fragment
96 // endpoint) can re-render a single file on demand.
97 func RenderFile(f *parse.File, opts Options) string {
98 opts = defaults(opts)
99 var buf bytes.Buffer
100 label, _ := fileLabelAction(f)
101 fmt.Fprintf(&buf, `<section id="%s" class="shithub-diff-file">`, html.EscapeString(diffFileAnchor(label)))
102 buf.WriteString(renderHeader(f))
103 switch {
104 case f.IsBinary:
105 buf.WriteString(renderBinary(f))
106 case f.IsTooLarge:
107 buf.WriteString(renderTooLarge(f))
108 case opts.Mode == ModeSplit:
109 buf.WriteString(renderHunksSplit(f, opts))
110 default:
111 buf.WriteString(renderHunksUnified(f, opts))
112 }
113 buf.WriteString(`</section>`)
114 return buf.String()
115 }
116
117 func renderHeader(f *parse.File) string {
118 label, action := fileLabelAction(f)
119 return fmt.Sprintf(
120 `<header class="shithub-diff-file-head"><code>%s</code><span class="shithub-diff-file-action">%s</span></header>`,
121 html.EscapeString(label), html.EscapeString(action),
122 )
123 }
124
125 func fileLabelAction(f *parse.File) (string, string) {
126 var label, action string
127 switch {
128 case f.IsRename:
129 label = fmt.Sprintf("%s → %s", f.OldPath, f.NewPath)
130 action = fmt.Sprintf("renamed (%d%% similarity)", f.Score)
131 case f.IsCopy:
132 label = fmt.Sprintf("%s → %s", f.OldPath, f.NewPath)
133 action = fmt.Sprintf("copied (%d%% similarity)", f.Score)
134 case f.IsNew:
135 label = f.NewPath
136 action = "added"
137 case f.IsDelete:
138 label = f.OldPath
139 action = "deleted"
140 default:
141 label = f.NewPath
142 if label == "" {
143 label = f.OldPath
144 }
145 action = "modified"
146 }
147 return label, action
148 }
149
150 func diffFileAnchor(p string) string {
151 var b strings.Builder
152 b.WriteString("diff-")
153 for _, r := range p {
154 switch {
155 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
156 b.WriteRune(r)
157 default:
158 b.WriteByte('-')
159 }
160 }
161 return b.String()
162 }
163
164 func renderBinary(f *parse.File) string {
165 if isImageExt(f.NewPath) || isImageExt(f.OldPath) {
166 return renderImage(f)
167 }
168 return `<div class="shithub-diff-binary">Binary file changed.</div>`
169 }
170
171 func renderImage(f *parse.File) string {
172 // Both old/new image previews — these resolve via the existing /raw
173 // route, but we don't have ref context here, so the renderer emits
174 // placeholder URLs the caller can substitute later. For the v1
175 // commit-page integration the caller provides absolute /raw URLs
176 // before calling Diff (or simply skips image preview). Keep the
177 // surface simple: a placeholder note.
178 return `<div class="shithub-diff-image">Image file changed (preview rendering wires once /raw URLs are threaded into the diff renderer).</div>`
179 }
180
181 func renderTooLarge(f *parse.File) string {
182 lineCount := 0
183 for _, h := range f.Hunks {
184 lineCount += len(h.Lines)
185 }
186 return fmt.Sprintf(
187 `<details class="shithub-diff-file-toolarge"><summary>%d lines changed — click to load</summary>%s</details>`,
188 lineCount,
189 // Lazy version: render hunks under the <details>. For a real
190 // per-file fragment fetch (S19 spec) the summary would link to
191 // /diff-fragment instead; keep the inline expand for now —
192 // it's still safer than rendering above the fold.
193 renderHunksUnified(f, Options{HighlightCap: 0}),
194 )
195 }
196
197 func renderHunksUnified(f *parse.File, opts Options) string {
198 var buf bytes.Buffer
199 buf.WriteString(`<table class="shithub-diff-table shithub-diff-unified">`)
200 for _, h := range f.Hunks {
201 fmt.Fprintf(&buf, `<tr class="shithub-diff-hunk-head"><td colspan="3"><code>%s</code> <span>%s</span></td></tr>`,
202 html.EscapeString(hunkHeader(h)),
203 html.EscapeString(h.Section))
204 for _, l := range h.Lines {
205 old, neu := lineNoCells(l)
206 content := highlightOrEscape(f.NewPath, l.Content, opts)
207 fmt.Fprintf(
208 &buf,
209 `<tr class="%s"><td class="shithub-diff-lineno">%s</td><td class="shithub-diff-lineno">%s</td><td class="shithub-diff-content"><pre>%s%s</pre></td></tr>`,
210 lineClass(l.Kind), old, neu, lineMarker(l.Kind), content,
211 )
212 }
213 }
214 buf.WriteString(`</table>`)
215 return buf.String()
216 }
217
218 func renderHunksSplit(f *parse.File, opts Options) string {
219 var buf bytes.Buffer
220 buf.WriteString(`<table class="shithub-diff-table shithub-diff-split">`)
221 for _, h := range f.Hunks {
222 fmt.Fprintf(&buf, `<tr class="shithub-diff-hunk-head"><td colspan="4"><code>%s</code> <span>%s</span></td></tr>`,
223 html.EscapeString(hunkHeader(h)),
224 html.EscapeString(h.Section))
225 // Pair adjacent +/- so the same row shows both sides. Context
226 // lines fill both cells. Dangling adds/deletes pad the other.
227 paired := pairLines(h.Lines)
228 for _, p := range paired {
229 leftClass := "shithub-diff-pad"
230 rightClass := "shithub-diff-pad"
231 leftNo := ""
232 rightNo := ""
233 leftContent := ""
234 rightContent := ""
235 if p.left != nil {
236 leftClass = lineClass(p.left.Kind)
237 leftNo = numStr(p.left.OldLineNo)
238 leftContent = lineMarker(p.left.Kind) + highlightOrEscape(f.OldPath, p.left.Content, opts)
239 }
240 if p.right != nil {
241 rightClass = lineClass(p.right.Kind)
242 rightNo = numStr(p.right.NewLineNo)
243 rightContent = lineMarker(p.right.Kind) + highlightOrEscape(f.NewPath, p.right.Content, opts)
244 }
245 fmt.Fprintf(
246 &buf,
247 `<tr><td class="shithub-diff-lineno %s">%s</td><td class="shithub-diff-content %s"><pre>%s</pre></td><td class="shithub-diff-lineno %s">%s</td><td class="shithub-diff-content %s"><pre>%s</pre></td></tr>`,
248 leftClass, leftNo, leftClass, leftContent,
249 rightClass, rightNo, rightClass, rightContent,
250 )
251 }
252 }
253 buf.WriteString(`</table>`)
254 return buf.String()
255 }
256
257 // pair models one row of split-view: left side (old) and right side (new).
258 type pair struct {
259 left, right *parse.Line
260 }
261
262 // pairLines walks a hunk's lines and aligns adds/deletes for the
263 // split view. The pairing is "row-by-row": consecutive deletes pair
264 // with consecutive adds in order; trailing additions on either side
265 // pad the other column. Context lines pair with themselves.
266 func pairLines(lines []parse.Line) []pair {
267 var out []pair
268 var pendingDel []parse.Line
269 flush := func() {
270 // Emit pendingDel as left-only rows.
271 for i := range pendingDel {
272 d := pendingDel[i]
273 out = append(out, pair{left: &d})
274 }
275 pendingDel = pendingDel[:0]
276 }
277 for i := range lines {
278 l := lines[i]
279 switch l.Kind {
280 case parse.LineDelete:
281 pendingDel = append(pendingDel, l)
282 case parse.LineAdd:
283 if len(pendingDel) > 0 {
284 d := pendingDel[0]
285 pendingDel = pendingDel[1:]
286 out = append(out, pair{left: &d, right: &l})
287 } else {
288 out = append(out, pair{right: &l})
289 }
290 default:
291 flush()
292 out = append(out, pair{left: &l, right: &l})
293 }
294 }
295 flush()
296 return out
297 }
298
299 func hunkHeader(h parse.Hunk) string {
300 return fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines)
301 }
302
303 func lineClass(k parse.LineKind) string {
304 switch k {
305 case parse.LineAdd:
306 return "shithub-diff-add"
307 case parse.LineDelete:
308 return "shithub-diff-del"
309 case parse.LineNoNewline:
310 return "shithub-diff-nonewline"
311 default:
312 return "shithub-diff-ctx"
313 }
314 }
315
316 func lineMarker(k parse.LineKind) string {
317 switch k {
318 case parse.LineAdd:
319 return "+"
320 case parse.LineDelete:
321 return "-"
322 default:
323 return " "
324 }
325 }
326
327 func lineNoCells(l parse.Line) (string, string) {
328 return numStr(l.OldLineNo), numStr(l.NewLineNo)
329 }
330
331 func numStr(n int) string {
332 if n <= 0 {
333 return ""
334 }
335 return fmt.Sprintf("%d", n)
336 }
337
338 // highlightOrEscape returns syntax-highlighted HTML for the line, or
339 // HTML-escaped plain text when over the highlight cap (or filename is
340 // empty). Highlighting one line at a time lets the caller honor the
341 // per-file size cap without buffering the whole hunk.
342 func highlightOrEscape(filename, content string, opts Options) string {
343 if opts.HighlightCap > 0 && len(content) > opts.HighlightCap {
344 return html.EscapeString(content)
345 }
346 if filename == "" || filename == "/dev/null" {
347 return html.EscapeString(content)
348 }
349 // Per-line chroma highlight is available via highlight.RenderLines
350 // for callers that want token classes, but the diff renderer just
351 // escapes for now — the page already has a "view source" link to
352 // the highlighted blob.
353 _ = highlight.RenderLines
354 return html.EscapeString(content)
355 }
356
357 func isImageExt(p string) bool {
358 switch strings.ToLower(path.Ext(p)) {
359 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
360 return true
361 }
362 return false
363 }
364