diffview.go
⎇
Raw
1package views
2
3import (
4 "sort"
5 "strconv"
6 "strings"
7
8 "hearthforge/internal/db"
9 "hearthforge/internal/highlight"
10
11 g "maragu.dev/gomponents"
12 . "maragu.dev/gomponents/html"
13)
14
15// slugify turns a file path into the anchor id of its diff block.
16func slugify(s string) string {
17 return "diff-" + strings.Map(func(r rune) rune {
18 if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
19 return r
20 }
21 return '-'
22 }, s)
23}
24
25// statusLetter is the single letter shown in the status badge.
26func statusLetter(status highlight.DiffStatus) string {
27 switch status {
28 case highlight.StatusAdded:
29 return "A"
30 case highlight.StatusDeleted:
31 return "D"
32 case highlight.StatusRenamed:
33 return "R"
34 case highlight.StatusCopied:
35 return "C"
36 default:
37 return "M"
38 }
39}
40
41// diffTree is a directory tree of the changed files. It keeps insertion order
42// so the rendered tree matches the order of the diff.
43type diffTree struct {
44 order []string
45 nodes map[string]*diffTreeNode
46}
47
48type diffTreeNode struct {
49 file *highlight.RenderedDiffFile
50 children *diffTree
51}
52
53func newDiffTree() *diffTree {
54 return &diffTree{nodes: map[string]*diffTreeNode{}}
55}
56
57func (t *diffTree) set(name string, node *diffTreeNode) {
58 if _, ok := t.nodes[name]; !ok {
59 t.order = append(t.order, name)
60 }
61 t.nodes[name] = node
62}
63
64func buildFileTree(files []highlight.RenderedDiffFile) *diffTree {
65 root := newDiffTree()
66 for i := range files {
67 p := files[i].NewPath
68 if p == "" {
69 p = files[i].OldPath
70 }
71 parts := strings.Split(p, "/")
72 current := root
73 for _, part := range parts[:len(parts)-1] {
74 node, ok := current.nodes[part]
75 if !ok {
76 node = &diffTreeNode{children: newDiffTree()}
77 current.set(part, node)
78 }
79 if node.children != nil {
80 current = node.children
81 }
82 }
83 current.set(parts[len(parts)-1], &diffTreeNode{file: &files[i]})
84 }
85 return root
86}
87
88// renderFileTree renders the file navigation, directories first.
89func renderFileTree(tree *diffTree) g.Node {
90 names := append([]string(nil), tree.order...)
91 sort.SliceStable(names, func(i, j int) bool {
92 a, b := tree.nodes[names[i]], tree.nodes[names[j]]
93 return a.children != nil && b.children == nil
94 })
95 return Ul(Class("file-nav-list"),
96 g.Map(names, func(name string) g.Node {
97 node := tree.nodes[name]
98 if node.children != nil {
99 return Li(Class("file-nav-dir"),
100 Details(Open(),
101 Summary(Class("file-nav-dir-toggle"),
102 Span(Class("file-nav-toggle-icon"), g.Text("▾")),
103 Span(g.Text(name+"/")),
104 ),
105 renderFileTree(node.children),
106 ),
107 )
108 }
109 f := node.file
110 path := f.NewPath
111 if path == "" {
112 path = f.OldPath
113 }
114 return Li(
115 A(Href("#"+slugify(path)), Class("file-nav-item"), Title(path),
116 Span(Class("file-nav-status file-status-"+string(f.Status)),
117 g.Text(statusLetter(f.Status))),
118 Span(Class("file-nav-name"), g.Text(name)),
119 Span(Class("file-nav-stat"),
120 g.If(f.IsBinary, Span(Class("nav-binary"),
121 g.If(f.HasBinarySize, g.Group{
122 Span(Class("nav-del"), g.Text(strconv.FormatInt(f.BinaryFrom, 10))),
123 g.Text(" → "),
124 Span(Class("nav-add"), g.Text(strconv.FormatInt(f.BinaryTo, 10))),
125 }),
126 g.If(!f.HasBinarySize, g.Text("Bin")),
127 )),
128 g.If(!f.IsBinary, g.Group{
129 g.If(f.Added > 0, Span(Class("nav-add"), g.Text("+"+strconv.Itoa(f.Added)))),
130 g.If(f.Removed > 0, Span(Class("nav-del"), g.Text("-"+strconv.Itoa(f.Removed)))),
131 }),
132 ),
133 ),
134 )
135 }),
136 )
137}
138
139// plural appends "s" unless n is 1.
140func plural(n int, word string) string {
141 if n == 1 {
142 return word
143 }
144 return word + "s"
145}
146
147// DiffView renders the file navigation and the diff of every file.
148// sha is empty when no "view at sha" link should be shown.
149func DiffView(files []highlight.RenderedDiffFile, repo *db.Repo, sha string) g.Node {
150 totalAdded, totalRemoved := 0, 0
151 for _, f := range files {
152 totalAdded += f.Added
153 totalRemoved += f.Removed
154 }
155 parts := []string{strconv.Itoa(len(files)) + " " + plural(len(files), "file") + " changed"}
156 if totalAdded > 0 {
157 parts = append(parts, strconv.Itoa(totalAdded)+" "+plural(totalAdded, "insertion")+"(+)")
158 }
159 if totalRemoved > 0 {
160 parts = append(parts, strconv.Itoa(totalRemoved)+" "+plural(totalRemoved, "deletion")+"(-)")
161 }
162 changedStr := strings.Join(parts, ", ")
163
164 if len(files) == 0 {
165 return P(Class("text-muted"), Style("margin-top: var(--space-6)"),
166 g.Text("Empty diff or no diff available."))
167 }
168
169 return g.Group{
170 Div(Class("commit-stats-bar"),
171 Span(Class("commit-stats-text"), g.Text(changedStr)),
172 ),
173 Div(Class("commit-layout"),
174 Aside(Class("commit-file-nav"),
175 Details(Class("file-nav-details"), Open(),
176 Summary(Class("file-nav-toggle"),
177 Span(Class("file-nav-toggle-icon"), g.Text("▾")),
178 Span(g.Text("Files changed ("+strconv.Itoa(len(files))+")")),
179 ),
180 renderFileTree(buildFileTree(files)),
181 ),
182 ),
183 Div(Class("commit-diffs"),
184 g.Map(files, func(f highlight.RenderedDiffFile) g.Node {
185 return diffFile(f, repo, sha)
186 }),
187 ),
188 ),
189 }
190}
191
192func diffFile(f highlight.RenderedDiffFile, repo *db.Repo, sha string) g.Node {
193 displayPath := f.NewPath
194 if displayPath == "" {
195 displayPath = f.OldPath
196 }
197 shortSha := sha
198 if len(shortSha) > 7 {
199 shortSha = shortSha[:7]
200 }
201 return Details(Class("diff-file"), ID(slugify(displayPath)), Open(),
202 Summary(Class("diff-file-header"),
203 Div(Class("diff-file-header-left"),
204 Span(Class("diff-file-toggle-icon"), g.Text("▾")),
205 Span(Class("diff-status-badge diff-status-"+string(f.Status)), g.Text(statusLetter(f.Status))),
206 Span(Class("diff-file-path mono"), g.Text(displayPath)),
207 g.If(f.Status == highlight.StatusRenamed && f.OldPath != f.NewPath,
208 Span(Class("diff-rename-arrow"), g.Text("← "+f.OldPath))),
209 ),
210 Div(Class("diff-file-header-right"),
211 g.If(f.IsBinary, Span(Class("diff-stat-binary"),
212 g.If(f.HasBinarySize, g.Group{
213 g.Text("Bin "),
214 Span(Class("diff-stat-del"), g.Text(strconv.FormatInt(f.BinaryFrom, 10))),
215 g.Text(" → "),
216 Span(Class("diff-stat-add"), g.Text(strconv.FormatInt(f.BinaryTo, 10))),
217 g.Text(" bytes"),
218 }),
219 g.If(!f.HasBinarySize, g.Text("Binary")),
220 )),
221 g.If(!f.IsBinary, g.Group{
222 g.If(f.Added > 0, Span(Class("diff-stat-add"), g.Text("+"+strconv.Itoa(f.Added)))),
223 g.If(f.Removed > 0, Span(Class("diff-stat-del"), g.Text("-"+strconv.Itoa(f.Removed)))),
224 }),
225 g.If(f.Status != highlight.StatusDeleted, g.Group{
226 g.If(sha != "", A(Href("/"+repo.Name+"/blob/"+sha+"/"+EscapePath(displayPath)),
227 Class("btn btn-xs btn-secondary"), Title("View file at "+shortSha),
228 g.Text("@ "+shortSha))),
229 A(Href("/"+repo.Name+"/blob/"+EscapePath(repo.DefaultBranch)+"/"+EscapePath(displayPath)),
230 Class("btn btn-xs btn-secondary"), Title("View file at "+repo.DefaultBranch),
231 g.Text("@ "+repo.DefaultBranch)),
232 }),
233 ),
234 ),
235 diffBody(f),
236 )
237}
238
239func diffBody(f highlight.RenderedDiffFile) g.Node {
240 if f.IsBinary {
241 return Div(Class("diff-binary-notice"), g.Text("Binary file — not shown"))
242 }
243 if len(f.Hunks) == 0 {
244 return Div(Class("diff-binary-notice"), g.Text("No textual changes."))
245 }
246 // The line-number gutter is CSS generated content with a fixed width, so
247 // the width has to follow the widest line number of the file.
248 digits := 0
249 for _, h := range f.Hunks {
250 for _, r := range h.Rows {
251 digits = max(digits, len(strconv.Itoa(r.OldLine)), len(strconv.Itoa(r.NewLine)))
252 }
253 }
254 return Div(Class("diff-file-body"), Style("--ln-digits:"+strconv.Itoa(digits)),
255 g.Map(f.Hunks, func(hunk highlight.RenderedHunk) g.Node {
256 return Div(Class("diff-hunk"),
257 Div(Class("diff-rows"),
258 Div(Class("diff-hunk-header"), g.Text(hunk.Header)),
259 g.Map(hunk.Rows, diffRow),
260 ),
261 )
262 }),
263 )
264}
265
266// diffRow keeps the element count low on purpose: big diffs are bound by HTML
267// parsing, so line numbers and the +/- sign come from CSS, not from elements.
268func diffRow(row highlight.RenderedRow) g.Node {
269 lineNo := func(name string, n int) g.Node {
270 if n == highlight.NoLine {
271 return nil
272 }
273 return Data(name, strconv.Itoa(n))
274 }
275 return Div(Class("diff-row diff-row-"+row.Type),
276 Span(Class("diff-ln"), lineNo("old", row.OldLine), lineNo("new", row.NewLine)),
277 // The highlighter already escaped this HTML.
278 Span(Class("diff-code"), g.Raw(row.HTML)),
279 )
280}
281