Commit 5ef0a2d

Nick Faro committed on
Add static commit diff pages
commit 5ef0a2d37bc79195850c7f8b7181f7cfb12359f9 parent a8d8eaa
7 changed files +676−20
Modifiedinternal/gitutil/git.go +40−0
@@ -42,6 +42,46 @@ func (g Git) Run(ctx context.Context, args ...string) ([]byte, error) {
42 return out, nil 42 return out, nil
43 } 43 }
44 44
45 // RunLimited captures at most limit bytes while continuing to drain Git's
46 // output. This keeps generated-site features from allocating without bound on
47 // unusually large objects while still allowing Git to exit cleanly.
48 func (g Git) RunLimited(ctx context.Context, limit int, args ...string) ([]byte, bool, error) {
49 cmd := g.command(ctx, args...)
50 output := limitedOutput{limit: limit}
51 var stderr bytes.Buffer
52 cmd.Stdout = &output
53 cmd.Stderr = &stderr
54 if err := cmd.Run(); err != nil {
55 message := strings.TrimSpace(stderr.String())
56 if message == "" {
57 message = err.Error()
58 }
59 return nil, false, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
60 }
61 return output.Bytes(), output.truncated, nil
62 }
63
64 type limitedOutput struct {
65 bytes.Buffer
66 limit int
67 truncated bool
68 }
69
70 func (w *limitedOutput) Write(value []byte) (int, error) {
71 written := len(value)
72 remaining := w.limit - w.Len()
73 if remaining <= 0 {
74 w.truncated = w.truncated || written > 0
75 return written, nil
76 }
77 if len(value) > remaining {
78 value = value[:remaining]
79 w.truncated = true
80 }
81 _, _ = w.Buffer.Write(value)
82 return written, nil
83 }
84
45 func (g Git) RevParse(ctx context.Context, rev string) (string, error) { 85 func (g Git) RevParse(ctx context.Context, rev string) (string, error) {
46 out, err := g.Run(ctx, "rev-parse", "--verify", rev) 86 out, err := g.Run(ctx, "rev-parse", "--verify", rev)
47 if err != nil { 87 if err != nil {
Addedinternal/site/diff.go +391−0
@@ -0,0 +1,391 @@
1 package site
2
3 import (
4 "context"
5 "fmt"
6 "path"
7 "regexp"
8 "strconv"
9 "strings"
10 )
11
12 const (
13 maxCommitPatchBytes = 8 * 1024 * 1024
14 maxCommitDiffRows = 4000
15 maxFileDiffRows = 2000
16 )
17
18 var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
19
20 type CommitParent struct {
21 OID string
22 ShortOID string
23 URL string
24 }
25
26 type CommitView struct {
27 Commit Commit
28 Parents []CommitParent
29 Files []DiffFile
30 Additions int
31 Deletions int
32 Truncated bool
33 }
34
35 type DiffFile struct {
36 Anchor string
37 OldPath string
38 NewPath string
39 DisplayPath string
40 Status string
41 Rows []DiffRow
42 Additions int
43 Deletions int
44 Binary bool
45 Truncated bool
46 }
47
48 type DiffRow struct {
49 Hunk bool
50 Header string
51 OldNumber int
52 NewNumber int
53 OldText string
54 NewText string
55 OldClass string
56 NewClass string
57 OldPresent bool
58 NewPresent bool
59 }
60
61 type numberedLine struct {
62 number int
63 text string
64 }
65
66 func (g *Generator) commitPages(ctx context.Context, branch, oldOID, newOID string, summary branchSummary) ([]Page, error) {
67 wanted := make(map[string]struct{})
68 // A site-version change is also our migration signal: the first push after
69 // installing commit views backfills historical pages once. Ordinary pushes
70 // with current assets remain proportional to newly reachable commits.
71 if oldOID == "" || !g.AssetsCurrent {
72 for _, group := range summary.Groups {
73 for _, commit := range group.Commits {
74 wanted[commit.OID] = struct{}{}
75 }
76 }
77 } else {
78 out, err := g.Git.Run(ctx, "rev-list", newOID, "^"+oldOID)
79 if err != nil {
80 g.progress("could not enumerate new commits from %s; generating reachable commit pages", short(oldOID))
81 for _, group := range summary.Groups {
82 for _, commit := range group.Commits {
83 wanted[commit.OID] = struct{}{}
84 }
85 }
86 } else {
87 for _, oid := range strings.Fields(string(out)) {
88 wanted[oid] = struct{}{}
89 }
90 }
91 }
92
93 var pages []Page
94 for _, group := range summary.Groups {
95 for _, commit := range group.Commits {
96 if _, ok := wanted[commit.OID]; !ok || g.commitGenerated(commit.OID) {
97 continue
98 }
99 page, err := g.commitPage(ctx, branch, commit, summary)
100 if err != nil {
101 return nil, err
102 }
103 pages = append(pages, page)
104 }
105 }
106 if len(pages) > 0 {
107 g.progress("generated %d commit diff page(s)", len(pages))
108 }
109 return pages, nil
110 }
111
112 func (g *Generator) commitGenerated(oid string) bool {
113 g.commitMu.Lock()
114 defer g.commitMu.Unlock()
115 _, ok := g.generated[oid]
116 return ok
117 }
118
119 func (g *Generator) markCommitGenerated(oid string) {
120 g.commitMu.Lock()
121 g.generated[oid] = struct{}{}
122 g.commitMu.Unlock()
123 }
124
125 func (g *Generator) commitPage(ctx context.Context, branch string, commit Commit, summary branchSummary) (Page, error) {
126 view := CommitView{Commit: commit}
127 for _, oid := range commit.Parents {
128 view.Parents = append(view.Parents, CommitParent{
129 OID: oid,
130 ShortOID: short(oid),
131 URL: routeURL(g.BaseURL, path.Join("commit", oid)),
132 })
133 }
134
135 args := []string{"diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3"}
136 if len(commit.Parents) > 0 {
137 args = append(args, commit.Parents[0], commit.OID, "--")
138 } else {
139 args = []string{"diff-tree", "--root", "--no-commit-id", "-r", "-p", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3", commit.OID, "--"}
140 }
141 patch, truncated, err := g.Git.RunLimited(ctx, maxCommitPatchBytes, args...)
142 if err != nil {
143 return Page{}, err
144 }
145 view.Truncated = truncated
146 if !truncated {
147 view.Files = parsePatch(patch)
148 for _, file := range view.Files {
149 view.Additions += file.Additions
150 view.Deletions += file.Deletions
151 }
152 }
153
154 data := g.pageData("commit", commit.Message+" · "+g.RepoName, branch, summary)
155 data.ViewedCommit = view
156 body, err := g.render(data)
157 if err != nil {
158 return Page{}, err
159 }
160 return Page{
161 Route: path.Join("commit", commit.OID),
162 Body: body,
163 ContentType: "text/html; charset=utf-8",
164 Cache: siteCache,
165 }, nil
166 }
167
168 func parsePatch(patch []byte) []DiffFile {
169 var files []DiffFile
170 var current *DiffFile
171 var oldLine, newLine int
172 var oldBlock, newBlock []numberedLine
173 var blockTruncated bool
174 renderedRows := 0
175 fileRows := 0
176 inHunk := false
177 rowAllowance := func() int {
178 return min(maxCommitDiffRows-renderedRows, maxFileDiffRows-fileRows)
179 }
180 appendRow := func(row DiffRow) {
181 if current == nil {
182 return
183 }
184 if rowAllowance() <= 0 {
185 current.Truncated = true
186 return
187 }
188 current.Rows = append(current.Rows, row)
189 renderedRows++
190 fileRows++
191 }
192
193 flushBlock := func() {
194 if current == nil || len(oldBlock)+len(newBlock) == 0 {
195 if current != nil {
196 current.Truncated = current.Truncated || blockTruncated
197 }
198 blockTruncated = false
199 return
200 }
201 count := max(len(oldBlock), len(newBlock))
202 for index := range count {
203 row := DiffRow{}
204 if index < len(oldBlock) {
205 row.OldNumber = oldBlock[index].number
206 row.OldText = oldBlock[index].text
207 row.OldClass = "deletion"
208 row.OldPresent = true
209 }
210 if index < len(newBlock) {
211 row.NewNumber = newBlock[index].number
212 row.NewText = newBlock[index].text
213 row.NewClass = "addition"
214 row.NewPresent = true
215 }
216 appendRow(row)
217 }
218 current.Truncated = current.Truncated || blockTruncated
219 oldBlock = oldBlock[:0]
220 newBlock = newBlock[:0]
221 blockTruncated = false
222 }
223 finishFile := func() {
224 flushBlock()
225 if current == nil {
226 return
227 }
228 if current.NewPath == "" {
229 current.NewPath = current.OldPath
230 }
231 if current.OldPath == "" {
232 current.OldPath = current.NewPath
233 }
234 switch {
235 case current.Status == "Renamed":
236 current.DisplayPath = current.OldPath + " → " + current.NewPath
237 case current.Status == "Deleted":
238 current.DisplayPath = current.OldPath
239 default:
240 current.DisplayPath = current.NewPath
241 }
242 if current.Status == "" {
243 current.Status = "Modified"
244 }
245 current.Anchor = fmt.Sprintf("diff-%d", len(files)+1)
246 files = append(files, *current)
247 current = nil
248 inHunk = false
249 fileRows = 0
250 }
251
252 for _, line := range strings.Split(string(patch), "\n") {
253 if strings.HasPrefix(line, "diff --git ") {
254 finishFile()
255 oldPath, newPath := diffHeaderPaths(strings.TrimPrefix(line, "diff --git "))
256 current = &DiffFile{OldPath: oldPath, NewPath: newPath}
257 continue
258 }
259 if current == nil {
260 continue
261 }
262 if matches := hunkHeaderPattern.FindStringSubmatch(line); matches != nil {
263 flushBlock()
264 oldLine, _ = strconv.Atoi(matches[1])
265 newLine, _ = strconv.Atoi(matches[2])
266 appendRow(DiffRow{Hunk: true, Header: line})
267 inHunk = true
268 continue
269 }
270 if inHunk {
271 if line == `\ No newline at end of file` {
272 continue
273 }
274 if line == "" {
275 continue
276 }
277 switch line[0] {
278 case ' ':
279 flushBlock()
280 appendRow(DiffRow{
281 OldNumber: oldLine, NewNumber: newLine,
282 OldText: line[1:], NewText: line[1:],
283 OldPresent: true, NewPresent: true,
284 })
285 oldLine++
286 newLine++
287 case '-':
288 if len(oldBlock) < rowAllowance() {
289 oldBlock = append(oldBlock, numberedLine{number: oldLine, text: line[1:]})
290 } else {
291 blockTruncated = true
292 }
293 current.Deletions++
294 oldLine++
295 case '+':
296 if len(newBlock) < rowAllowance() {
297 newBlock = append(newBlock, numberedLine{number: newLine, text: line[1:]})
298 } else {
299 blockTruncated = true
300 }
301 current.Additions++
302 newLine++
303 }
304 continue
305 }
306
307 switch {
308 case strings.HasPrefix(line, "new file mode "):
309 current.Status = "Added"
310 case strings.HasPrefix(line, "deleted file mode "):
311 current.Status = "Deleted"
312 case strings.HasPrefix(line, "rename from "):
313 current.Status = "Renamed"
314 current.OldPath = decodeGitPath(strings.TrimPrefix(line, "rename from "))
315 case strings.HasPrefix(line, "rename to "):
316 current.Status = "Renamed"
317 current.NewPath = decodeGitPath(strings.TrimPrefix(line, "rename to "))
318 case strings.HasPrefix(line, "Binary files ") || line == "GIT binary patch":
319 current.Binary = true
320 case strings.HasPrefix(line, "--- "):
321 value := patchPath(strings.TrimPrefix(line, "--- "))
322 if value != "" {
323 current.OldPath = value
324 }
325 case strings.HasPrefix(line, "+++ "):
326 value := patchPath(strings.TrimPrefix(line, "+++ "))
327 if value != "" {
328 current.NewPath = value
329 }
330 }
331 }
332 finishFile()
333 return files
334 }
335
336 func diffHeaderPaths(value string) (string, string) {
337 if strings.HasPrefix(value, `"`) {
338 oldPath, rest := quotedGitField(value)
339 newPath, _ := quotedGitField(strings.TrimSpace(rest))
340 return stripDiffPrefix(oldPath), stripDiffPrefix(newPath)
341 }
342 marker := strings.Index(value, " b/")
343 if marker < 0 {
344 return stripDiffPrefix(value), stripDiffPrefix(value)
345 }
346 return stripDiffPrefix(value[:marker]), stripDiffPrefix(value[marker+1:])
347 }
348
349 func quotedGitField(value string) (string, string) {
350 if !strings.HasPrefix(value, `"`) {
351 field, rest, _ := strings.Cut(value, " ")
352 return decodeGitPath(field), rest
353 }
354 escaped := false
355 for index := 1; index < len(value); index++ {
356 switch {
357 case escaped:
358 escaped = false
359 case value[index] == '\\':
360 escaped = true
361 case value[index] == '"':
362 return decodeGitPath(value[:index+1]), value[index+1:]
363 }
364 }
365 return decodeGitPath(value), ""
366 }
367
368 func patchPath(value string) string {
369 value = strings.TrimSuffix(value, "\t")
370 value = decodeGitPath(value)
371 if value == "/dev/null" {
372 return ""
373 }
374 return stripDiffPrefix(value)
375 }
376
377 func stripDiffPrefix(value string) string {
378 value = decodeGitPath(value)
379 value = strings.TrimPrefix(value, "a/")
380 value = strings.TrimPrefix(value, "b/")
381 return value
382 }
383
384 func decodeGitPath(value string) string {
385 if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) {
386 if decoded, err := strconv.Unquote(value); err == nil {
387 return decoded
388 }
389 }
390 return value
391 }
Modifiedinternal/site/metadata.go +7−4
@@ -83,7 +83,7 @@ func (g *Generator) branchSummary(ctx context.Context, branch, oid string) (bran
83 } 83 }
84 84
85 func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) { 85 func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) {
86 out, err := g.Git.Run(ctx, "log", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%s%x00", oid) 86 out, err := g.Git.Run(ctx, "log", "--format=%H%x00%h%x00%an%x00%ae%x00%aI%x00%s%x00%b%x00%P%x00", oid)
87 if err != nil { 87 if err != nil {
88 return nil, err 88 return nil, err
89 } 89 }
@@ -91,11 +91,11 @@ func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) {
91 if len(fields) > 0 && strings.TrimSpace(fields[len(fields)-1]) == "" { 91 if len(fields) > 0 && strings.TrimSpace(fields[len(fields)-1]) == "" {
92 fields = fields[:len(fields)-1] 92 fields = fields[:len(fields)-1]
93 } 93 }
94 if len(fields)%6 != 0 { 94 if len(fields)%8 != 0 {
95 return nil, fmt.Errorf("unexpected git log output") 95 return nil, fmt.Errorf("unexpected git log output")
96 } 96 }
97 result := make([]Commit, 0, len(fields)/6) 97 result := make([]Commit, 0, len(fields)/8)
98 for i := 0; i < len(fields); i += 6 { 98 for i := 0; i < len(fields); i += 8 {
99 author := strings.TrimSpace(fields[i+2]) 99 author := strings.TrimSpace(fields[i+2])
100 email := strings.TrimSpace(fields[i+3]) 100 email := strings.TrimSpace(fields[i+3])
101 dateTime := strings.TrimSpace(fields[i+4]) 101 dateTime := strings.TrimSpace(fields[i+4])
@@ -107,8 +107,11 @@ func (g *Generator) commits(ctx context.Context, oid string) ([]Commit, error) {
107 Date: displayDate(dateTime), 107 Date: displayDate(dateTime),
108 DateTime: dateTime, 108 DateTime: dateTime,
109 Message: strings.TrimSpace(fields[i+5]), 109 Message: strings.TrimSpace(fields[i+5]),
110 Body: strings.TrimSpace(fields[i+6]),
110 Initials: initials(author), 111 Initials: initials(author),
111 AvatarURL: gravatarURL(email, author), 112 AvatarURL: gravatarURL(email, author),
113 URL: routeURL(g.BaseURL, path.Join("commit", strings.TrimSpace(fields[i]))),
114 Parents: strings.Fields(fields[i+7]),
112 }) 115 })
113 } 116 }
114 return result, nil 117 return result, nil
Modifiedinternal/site/site.go +20−3
@@ -58,6 +58,8 @@ type Generator struct {
58 templates *template.Template 58 templates *template.Template
59 submoduleMu sync.Mutex 59 submoduleMu sync.Mutex
60 submoduleCache map[string]map[string]string 60 submoduleCache map[string]map[string]string
61 commitMu sync.Mutex
62 generated map[string]struct{}
61 } 63 }
62 64
63 type Commit struct { 65 type Commit struct {
@@ -68,8 +70,11 @@ type Commit struct {
68 Date string 70 Date string
69 DateTime string 71 DateTime string
70 Message string 72 Message string
73 Body string
71 Initials string 74 Initials string
72 AvatarURL string 75 AvatarURL string
76 URL string
77 Parents []string
73 } 78 }
74 79
75 type TreeEntry struct { 80 type TreeEntry struct {
@@ -105,6 +110,7 @@ type pageData struct {
105 CommitCount int 110 CommitCount int
106 CommitsURL string 111 CommitsURL string
107 CommitGroups []CommitGroup 112 CommitGroups []CommitGroup
113 ViewedCommit CommitView
108 BranchesURL string 114 BranchesURL string
109 FilesURL string 115 FilesURL string
110 DefaultBranch string 116 DefaultBranch string
@@ -148,6 +154,7 @@ func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) {
148 Owner: owner, 154 Owner: owner,
149 CloneURL: strings.TrimRight(baseURL, "/"), 155 CloneURL: strings.TrimRight(baseURL, "/"),
150 templates: tmpl, 156 templates: tmpl,
157 generated: make(map[string]struct{}),
151 }, nil 158 }, nil
152 } 159 }
153 160
@@ -298,6 +305,11 @@ func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID stri
298 } 305 }
299 result.Pages = append(result.Pages, landing) 306 result.Pages = append(result.Pages, landing)
300 } 307 }
308 commitPages, err := g.commitPages(ctx, branch, oldOID, newOID, summary)
309 if err != nil {
310 return Build{}, err
311 }
312 result.Pages = append(result.Pages, commitPages...)
301 result.Deletes = uniqueStrings(result.Deletes) 313 result.Deletes = uniqueStrings(result.Deletes)
302 return result, nil 314 return result, nil
303 } 315 }
@@ -367,6 +379,11 @@ sendPages:
367 return err 379 return err
368 default: 380 default:
369 } 381 }
382 for _, page := range build.Pages {
383 if strings.HasPrefix(page.Route, "commit/") {
384 g.markCommitGenerated(strings.TrimPrefix(page.Route, "commit/"))
385 }
386 }
370 g.progress("published %d site object(s), removed %d", len(build.Pages), len(build.Deletes)) 387 g.progress("published %d site object(s), removed %d", len(build.Pages), len(build.Deletes))
371 return nil 388 return nil
372 } 389 }
@@ -415,7 +432,7 @@ func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, summa
415 entries[i].URL = routeURL(g.BaseURL, path.Join("blob", branchRouteSegment(branch), fullPath)) 432 entries[i].URL = routeURL(g.BaseURL, path.Join("blob", branchRouteSegment(branch), fullPath))
416 } 433 }
417 } 434 }
418 if err := g.populateEntryCommits(ctx, oid, dir, entries, summary.CommitsURL); err != nil { 435 if err := g.populateEntryCommits(ctx, oid, dir, entries); err != nil {
419 return Page{}, err 436 return Page{}, err
420 } 437 }
421 data := g.pageData("tree", g.RepoName, branch, summary) 438 data := g.pageData("tree", g.RepoName, branch, summary)
@@ -450,7 +467,7 @@ func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, summa
450 return Page{Route: path.Join("tree", branchRouteSegment(branch), dir), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, nil 467 return Page{Route: path.Join("tree", branchRouteSegment(branch), dir), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, nil
451 } 468 }
452 469
453 func (g *Generator) populateEntryCommits(ctx context.Context, oid, dir string, entries []TreeEntry, commitsURL string) error { 470 func (g *Generator) populateEntryCommits(ctx context.Context, oid, dir string, entries []TreeEntry) error {
454 if len(entries) == 0 { 471 if len(entries) == 0 {
455 return nil 472 return nil
456 } 473 }
@@ -475,7 +492,7 @@ func (g *Generator) populateEntryCommits(ctx context.Context, oid, dir string, e
475 continue 492 continue
476 } 493 }
477 entries[index].CommitMessage = commit.Message 494 entries[index].CommitMessage = commit.Message
478 entries[index].CommitURL = commitsURL + "#" + commit.OID 495 entries[index].CommitURL = routeURL(g.BaseURL, path.Join("commit", commit.OID))
479 } 496 }
480 }() 497 }()
481 } 498 }
Modifiedinternal/site/site_test.go +73−6
@@ -26,7 +26,7 @@ func TestFullAndIncrementalBuild(t *testing.T) {
26 write(t, dir, "src/main.go", "package main\n\nfunc main() {}\n") 26 write(t, dir, "src/main.go", "package main\n\nfunc main() {}\n")
27 write(t, dir, "vector.svg", `<svg xmlns="http://www.w3.org/2000/svg"><script>alert('nope')</script></svg>`) 27 write(t, dir, "vector.svg", `<svg xmlns="http://www.w3.org/2000/svg"><script>alert('nope')</script></svg>`)
28 runGit(t, dir, "add", ".") 28 runGit(t, dir, "add", ".")
29 runGit(t, dir, "commit", "-m", "initial") 29 runGit(t, dir, "commit", "-m", "initial", "-m", "A longer explanation of the initial change.")
30 first := outputGit(t, dir, "rev-parse", "HEAD") 30 first := outputGit(t, dir, "rev-parse", "HEAD")
31 31
32 generator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner") 32 generator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner")
@@ -71,9 +71,14 @@ func TestFullAndIncrementalBuild(t *testing.T) {
71 assertPageDoesNotContain(t, full, "tree/main/src", `<a class="tree-row"`) 71 assertPageDoesNotContain(t, full, "tree/main/src", `<a class="tree-row"`)
72 assertPage(t, full, "tree/main/src", `<a class="file-icon parent-icon" href="https://example.invalid/repo.git" aria-label="Parent directory">`) 72 assertPage(t, full, "tree/main/src", `<a class="file-icon parent-icon" href="https://example.invalid/repo.git" aria-label="Parent directory">`)
73 assertPage(t, full, "tree/main/src", `<a class="file-name" href="https://example.invalid/repo.git">..</a>`) 73 assertPage(t, full, "tree/main/src", `<a class="file-name" href="https://example.invalid/repo.git">..</a>`)
74 assertPage(t, full, "tree/main/src", `commits/main#`+first+`" title="initial">initial</a>`) 74 assertPage(t, full, "tree/main/src", `commit/`+first+`" title="initial">initial</a>`)
75 assertPage(t, full, "commits/main", "Commit history") 75 assertPage(t, full, "commits/main", "Commit history")
76 assertPage(t, full, "commits/main", "gravatar.com/avatar") 76 assertPage(t, full, "commits/main", "gravatar.com/avatar")
77 assertPage(t, full, "commits/main", `class="commit-list-message" href="https://example.invalid/repo.git/commit/`+first+`">initial</a>`)
78 assertPage(t, full, "commit/"+first, "changed files")
79 assertPage(t, full, "commit/"+first, `aria-label="Side-by-side diff for README.md"`)
80 assertPage(t, full, "commit/"+first, `class="diff-code new addition`)
81 assertPage(t, full, "commit/"+first, "A longer explanation of the initial change.")
77 assertPage(t, full, ".dumbforge/files/main.json", `"path":"src/main.go"`) 82 assertPage(t, full, ".dumbforge/files/main.json", `"path":"src/main.go"`)
78 assertPageDoesNotContain(t, full, "", "Search this repository") 83 assertPageDoesNotContain(t, full, "", "Search this repository")
79 assertPageDoesNotContain(t, full, "", ">Docs<") 84 assertPageDoesNotContain(t, full, "", ">Docs<")
@@ -88,8 +93,8 @@ func TestFullAndIncrementalBuild(t *testing.T) {
88 if err != nil { 93 if err != nil {
89 t.Fatal(err) 94 t.Fatal(err)
90 } 95 }
91 if len(incremental.Pages) != 5 { 96 if len(incremental.Pages) != 6 {
92 t.Fatalf("incremental build generated %d pages, want 5", len(incremental.Pages)) 97 t.Fatalf("incremental build generated %d pages, want 6", len(incremental.Pages))
93 } 98 }
94 for _, page := range incremental.Pages { 99 for _, page := range incremental.Pages {
95 if strings.HasPrefix(page.Route, ".dumbforge/assets/") || page.Route == ".dumbforge/site-version" { 100 if strings.HasPrefix(page.Route, ".dumbforge/assets/") || page.Route == ".dumbforge/site-version" {
@@ -98,9 +103,25 @@ func TestFullAndIncrementalBuild(t *testing.T) {
98 } 103 }
99 assertPage(t, incremental, "blob/main/src/main.go", "changed") 104 assertPage(t, incremental, "blob/main/src/main.go", "changed")
100 assertPage(t, incremental, "tree/main/src", "change one file") 105 assertPage(t, incremental, "tree/main/src", "change one file")
101 assertPage(t, incremental, "tree/main/src", `commits/main#`+second+`" title="change one file">change one file</a>`) 106 assertPage(t, incremental, "tree/main/src", `commit/`+second+`" title="change one file">change one file</a>`)
102 assertPage(t, incremental, "", `commits/main#`+first+`" title="initial">initial</a>`) 107 assertPage(t, incremental, "", `commit/`+first+`" title="initial">initial</a>`)
103 assertPage(t, incremental, "commits/main", "change one file") 108 assertPage(t, incremental, "commits/main", "change one file")
109 assertPage(t, incremental, "commit/"+second, `class="diff-code old deletion`)
110 assertPage(t, incremental, "commit/"+second, `println(&#34;changed&#34;)`)
111 assertPage(t, incremental, "commit/"+second, `parent <a href="https://example.invalid/repo.git/commit/`+first+`"><code>`)
112 assertNoPage(t, incremental, "commit/"+first)
113
114 upgradeGenerator, err := New(gitutil.Git{Dir: dir}, "https://example.invalid/repo.git", "repo.git", "owner")
115 if err != nil {
116 t.Fatal(err)
117 }
118 upgradeGenerator.SetBranches([]string{"main"}, "main")
119 backfill, err := upgradeGenerator.BuildBranch(context.Background(), "main", first, second, true)
120 if err != nil {
121 t.Fatal(err)
122 }
123 assertPage(t, backfill, "commit/"+first, "initial")
124 assertPage(t, backfill, "commit/"+second, "change one file")
104 } 125 }
105 126
106 func TestSlashBranchUsesUnambiguousRoutes(t *testing.T) { 127 func TestSlashBranchUsesUnambiguousRoutes(t *testing.T) {
@@ -262,6 +283,43 @@ func TestFileNavigationChanged(t *testing.T) {
262 } 283 }
263 } 284 }
264 285
286 func TestParsePatchBuildsAlignedSplitRows(t *testing.T) {
287 patch := []byte("diff --git a/a b.txt b/a b.txt\nindex 1111111..2222222 100644\n--- a/a b.txt\t\n+++ b/a b.txt\t\n@@ -1,3 +1,4 @@\n same\n-old one\n-old two\n+new one\n+new two\n+new three\n")
288 files := parsePatch(patch)
289 if len(files) != 1 {
290 t.Fatalf("parsed %d files, want 1", len(files))
291 }
292 file := files[0]
293 if file.DisplayPath != "a b.txt" || file.Additions != 3 || file.Deletions != 2 {
294 t.Fatalf("unexpected parsed file: %+v", file)
295 }
296 if len(file.Rows) != 5 {
297 t.Fatalf("parsed %d rows, want hunk + context + 3 aligned changes", len(file.Rows))
298 }
299 last := file.Rows[len(file.Rows)-1]
300 if last.OldPresent || !last.NewPresent || last.NewText != "new three" {
301 t.Fatalf("last aligned row = %+v", last)
302 }
303 }
304
305 func TestParsePatchLimitsGeneratedRows(t *testing.T) {
306 var patch strings.Builder
307 patch.WriteString("diff --git a/generated.txt b/generated.txt\n--- a/generated.txt\n+++ b/generated.txt\n@@ -0,0 +1,3000 @@\n")
308 for range 3000 {
309 patch.WriteString("+x\n")
310 }
311 files := parsePatch([]byte(patch.String()))
312 if len(files) != 1 || !files[0].Truncated {
313 t.Fatalf("large patch was not marked truncated: %+v", files)
314 }
315 if len(files[0].Rows) > maxFileDiffRows {
316 t.Fatalf("large patch rendered %d rows, limit is %d", len(files[0].Rows), maxFileDiffRows)
317 }
318 if files[0].Additions != 3000 {
319 t.Fatalf("large patch counted %d additions, want 3000", files[0].Additions)
320 }
321 }
322
265 func assertPage(t *testing.T, build Build, route, contains string) { 323 func assertPage(t *testing.T, build Build, route, contains string) {
266 t.Helper() 324 t.Helper()
267 for _, page := range build.Pages { 325 for _, page := range build.Pages {
@@ -288,6 +346,15 @@ func assertPageDoesNotContain(t *testing.T, build Build, route, unwanted string)
288 t.Fatalf("page %q was not generated", route) 346 t.Fatalf("page %q was not generated", route)
289 } 347 }
290 348
349 func assertNoPage(t *testing.T, build Build, route string) {
350 t.Helper()
351 for _, page := range build.Pages {
352 if page.Route == route {
353 t.Fatalf("page %q was unexpectedly generated", route)
354 }
355 }
356 }
357
291 func assertContentType(t *testing.T, build Build, route, want string) { 358 func assertContentType(t *testing.T, build Build, route, want string) {
292 t.Helper() 359 t.Helper()
293 for _, page := range build.Pages { 360 for _, page := range build.Pages {
Modifiedinternal/site/style.go +82−0
@@ -11,6 +11,8 @@ const siteCSS = `:root {
11 --blue: #0969da; 11 --blue: #0969da;
12 --green: #1f883d; 12 --green: #1f883d;
13 --green-hover: #1a7f37; 13 --green-hover: #1a7f37;
14 --success-text: #1a7f37;
15 --danger-text: #cf222e;
14 --shadow: 0 1px 0 rgba(31,35,40,.04); 16 --shadow: 0 1px 0 rgba(31,35,40,.04);
15 --button-border: rgba(31,35,40,.15); 17 --button-border: rgba(31,35,40,.15);
16 --button-hover: #eff2f5; 18 --button-hover: #eff2f5;
@@ -20,6 +22,12 @@ const siteCSS = `:root {
20 --neutral-muted: #eaeef2; 22 --neutral-muted: #eaeef2;
21 --selected-bg: #ddf4ff; 23 --selected-bg: #ddf4ff;
22 --attention-bg: #fff8c5; 24 --attention-bg: #fff8c5;
25 --diff-add: #dafbe1;
26 --diff-add-number: #aceebb;
27 --diff-delete: #ffebe9;
28 --diff-delete-number: #ffc1ba;
29 --diff-hunk: #ddf4ff;
30 --diff-empty: #f6f8fa;
23 --checker-a: #f6f8fa; 31 --checker-a: #f6f8fa;
24 --checker-b: #ffffff; 32 --checker-b: #ffffff;
25 --dialog-backdrop: rgba(31,35,40,.45); 33 --dialog-backdrop: rgba(31,35,40,.45);
@@ -41,6 +49,8 @@ const siteCSS = `:root {
41 --blue: #58a6ff; 49 --blue: #58a6ff;
42 --green: #238636; 50 --green: #238636;
43 --green-hover: #2ea043; 51 --green-hover: #2ea043;
52 --success-text: #3fb950;
53 --danger-text: #f85149;
44 --shadow: 0 0 transparent; 54 --shadow: 0 0 transparent;
45 --button-border: rgba(240,246,252,.1); 55 --button-border: rgba(240,246,252,.1);
46 --button-hover: #21262d; 56 --button-hover: #21262d;
@@ -50,6 +60,12 @@ const siteCSS = `:root {
50 --neutral-muted: #21262d; 60 --neutral-muted: #21262d;
51 --selected-bg: rgba(56,139,253,.15); 61 --selected-bg: rgba(56,139,253,.15);
52 --attention-bg: rgba(187,128,9,.15); 62 --attention-bg: rgba(187,128,9,.15);
63 --diff-add: #12261e;
64 --diff-add-number: #1b4721;
65 --diff-delete: #2d1715;
66 --diff-delete-number: #5d1f1a;
67 --diff-hunk: #121d2f;
68 --diff-empty: #161b22;
53 --checker-a: #161b22; 69 --checker-a: #161b22;
54 --checker-b: #21262d; 70 --checker-b: #21262d;
55 --dialog-backdrop: rgba(1,4,9,.8); 71 --dialog-backdrop: rgba(1,4,9,.8);
@@ -140,6 +156,7 @@ button, input { font: inherit; }
140 .latest-commit { min-width: 0; display: flex; align-items: baseline; gap: 9px; } 156 .latest-commit { min-width: 0; display: flex; align-items: baseline; gap: 9px; }
141 .latest-commit strong { flex: none; } 157 .latest-commit strong { flex: none; }
142 .commit-message { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } 158 .commit-message { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; }
159 .commit-message:hover { color: var(--blue); }
143 .commit-hash, .commit-row time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; } 160 .commit-hash, .commit-row time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
144 .commit-row time { font-family: inherit; white-space: nowrap; } 161 .commit-row time { font-family: inherit; white-space: nowrap; }
145 .commit-count-link { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 0 4px 10px; border-left: 1px solid var(--border-muted); color: var(--text); white-space: nowrap; } 162 .commit-count-link { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; padding: 4px 0 4px 10px; border-left: 1px solid var(--border-muted); color: var(--text); white-space: nowrap; }
@@ -269,6 +286,61 @@ button, input { font: inherit; }
269 .commit-sha { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--canvas); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; } 286 .commit-sha { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); background: var(--canvas); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
270 .commit-sha:hover { color: var(--blue); text-decoration: none; } 287 .commit-sha:hover { color: var(--blue); text-decoration: none; }
271 288
289 .commit-view { max-width: 100%; }
290 .commit-page-heading { min-height: 42px; margin-bottom: 12px; display: flex; align-items: center; }
291 .commit-page-heading h1 { margin: 0; font-size: 24px; font-weight: 500; }
292 .commit-page-heading code { padding: 3px 7px; border-radius: 6px; background: var(--neutral-muted); font: 20px ui-monospace, SFMono-Regular, Consolas, monospace; }
293 .commit-summary-card { overflow: hidden; margin-bottom: 20px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); }
294 .commit-summary-title { min-height: 58px; padding: 12px 16px; display: flex; align-items: flex-start; gap: 16px; background: var(--canvas); border-bottom: 1px solid var(--border); }
295 .commit-summary-title > strong { min-width: 0; flex: 1; font: 14px/32px ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
296 .commit-copy { flex: none; }
297 .commit-copy.is-copied { color: var(--green); background: var(--success-bg); }
298 .commit-body { margin: 0; padding: 14px 16px; overflow: auto; border-bottom: 1px solid var(--border-muted); color: var(--text); background: var(--bg); font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
299 .commit-author-row { min-height: 48px; padding: 9px 16px; display: flex; align-items: center; gap: 9px; }
300 .commit-author-row > span { color: var(--muted); }
301 .commit-author-row strong { color: var(--text); }
302 .commit-identifiers { padding: 9px 16px; display: flex; flex-wrap: wrap; gap: 8px 20px; border-top: 1px solid var(--border-muted); color: var(--muted); font-size: 12px; }
303 .commit-identifiers code { color: var(--text); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
304 .diff-overview { min-height: 44px; margin-bottom: 12px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }
305 .diff-overview > strong { font-size: 16px; }
306 .diff-overview .muted { color: var(--muted); font-size: 12px; }
307 .diff-totals, .diff-file-stats { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
308 .addition { color: var(--success-text); }
309 .deletion { color: var(--danger-text); }
310 .diff-layout { display: grid; grid-template-columns: 260px minmax(0,1fr); align-items: start; gap: 20px; }
311 .diff-sidebar { position: sticky; top: 12px; min-width: 0; }
312 .diff-sidebar h2 { margin: 0 0 8px; font-size: 14px; }
313 .diff-file-nav { padding: 7px 0; overflow: hidden; border: 1px solid var(--border); border-radius: 7px; }
314 .diff-file-nav a { min-height: 31px; padding: 5px 12px; display: block; overflow: hidden; color: var(--text); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
315 .diff-file-nav a:hover { background: var(--canvas); text-decoration: none; }
316 .diff-file-nav a span { width: 68px; display: inline-block; color: var(--muted); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
317 .diff-files { display: flex; flex-direction: column; gap: 24px; }
318 .diff-file { scroll-margin-top: 12px; overflow: hidden; border: 1px solid var(--border); border-radius: 7px; background: var(--bg); }
319 .diff-file:target { border-color: var(--blue); box-shadow: 0 0 0 1px var(--blue); }
320 .diff-file-header { min-height: 44px; padding: 8px 12px; display: flex; align-items: center; gap: 9px; border-bottom: 1px solid var(--border); background: var(--canvas); }
321 .diff-file-header strong { min-width: 0; overflow: hidden; font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
322 .diff-status { flex: none; padding: 1px 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 600; }
323 .split-diff { width: 100%; overflow: auto; }
324 .split-diff table { width: 100%; min-width: 960px; border-spacing: 0; border-collapse: collapse; table-layout: fixed; font: 12px/20px ui-monospace, SFMono-Regular, Consolas, monospace; }
325 .split-diff col { width: auto; }
326 .diff-line-number { width: 50px; padding: 0 8px; color: var(--muted); background: var(--bg); border-right: 1px solid var(--border-muted); text-align: right; vertical-align: top; user-select: none; }
327 .diff-line-number:nth-child(3) { border-left: 1px solid var(--border); }
328 .diff-line-number.addition { background: var(--diff-add-number); }
329 .diff-line-number.deletion { background: var(--diff-delete-number); }
330 .diff-code { width: calc(50% - 50px); padding: 0 10px; overflow: hidden; color: var(--text); background: var(--bg); vertical-align: top; white-space: pre; }
331 .diff-code code { display: block; min-height: 20px; font: inherit; }
332 .diff-code.addition { background: var(--diff-add); }
333 .diff-code.deletion { background: var(--diff-delete); }
334 .diff-code.empty { background: var(--diff-empty); }
335 .diff-hunk td { padding: 4px 12px; color: var(--muted); background: var(--diff-hunk); border-top: 1px solid var(--border-muted); border-bottom: 1px solid var(--border-muted); }
336 .diff-hunk:first-child td { border-top: 0; }
337 .diff-hunk code { font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
338 .diff-file-notice, .diff-limit-notice { padding: 42px 20px; color: var(--muted); text-align: center; }
339 .diff-truncated-notice { padding: 10px 14px; color: var(--muted); background: var(--canvas); border-top: 1px solid var(--border); font-size: 12px; text-align: center; }
340 .diff-limit-notice { margin-top: 16px; border: 1px solid var(--border); border-radius: 7px; }
341 .diff-limit-notice strong { color: var(--text); font-size: 16px; }
342 .diff-limit-notice p { margin: 4px 0 0; }
343
272 .dialog-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; align-items: start; justify-items: center; padding: 12vh 20px 20px; background: var(--dialog-backdrop); } 344 .dialog-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; align-items: start; justify-items: center; padding: 12vh 20px 20px; background: var(--dialog-backdrop); }
273 .file-dialog { width: min(680px, 100%); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: var(--dialog-shadow); } 345 .file-dialog { width: min(680px, 100%); max-height: 70vh; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border); border-radius: 12px; background: var(--bg); box-shadow: var(--dialog-shadow); }
274 .file-dialog > header { min-height: 48px; padding: 8px 10px 8px 16px; display: flex; align-items: center; border-bottom: 1px solid var(--border-muted); } 346 .file-dialog > header { min-height: 48px; padding: 8px 10px 8px 16px; display: flex; align-items: center; border-bottom: 1px solid var(--border-muted); }
@@ -287,6 +359,8 @@ button, input { font: inherit; }
287 .repo-grid { grid-template-columns: 1fr; } 359 .repo-grid { grid-template-columns: 1fr; }
288 .blob-layout { grid-template-columns: 1fr; } 360 .blob-layout { grid-template-columns: 1fr; }
289 .file-browser { display: none; } 361 .file-browser { display: none; }
362 .diff-layout { grid-template-columns: 1fr; }
363 .diff-sidebar { position: static; }
290 .sidebar { padding-top: 0; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 24px; } 364 .sidebar { padding-top: 0; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 24px; }
291 .about-section { grid-column: 1 / -1; } 365 .about-section { grid-column: 1 / -1; }
292 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; } 366 .commit-row { grid-template-columns: 32px minmax(0,1fr) auto; }
@@ -313,6 +387,14 @@ button, input { font: inherit; }
313 .branch-popover, .clone-popover { position: fixed; top: 74px; left: 12px; right: 12px; width: auto; } 387 .branch-popover, .clone-popover { position: fixed; top: 74px; left: 12px; right: 12px; width: auto; }
314 .commit-list-row { grid-template-columns: 34px minmax(0,1fr); } 388 .commit-list-row { grid-template-columns: 34px minmax(0,1fr); }
315 .commit-sha { display: none; } 389 .commit-sha { display: none; }
390 .commit-summary-title { align-items: center; }
391 .commit-page-heading h1 { font-size: 20px; }
392 .commit-page-heading code { font-size: 17px; }
393 .commit-summary-title > strong { line-height: 1.4; }
394 .commit-copy [data-copy-label] { display: none; }
395 .commit-identifiers code { word-break: break-all; }
396 .diff-overview { flex-wrap: wrap; padding-bottom: 10px; }
397 .diff-overview .muted { width: 100%; order: 3; }
316 .dialog-backdrop { padding: 7vh 10px 10px; } 398 .dialog-backdrop { padding: 7vh 10px 10px; }
317 .file-dialog { max-height: 82vh; } 399 .file-dialog { max-height: 82vh; }
318 }` 400 }`
Modifiedinternal/site/template.go +63−7
@@ -20,13 +20,13 @@ const pageTemplate = `<!doctype html>
20 </header> 20 </header>
21 <section class="repo-nav"> 21 <section class="repo-nav">
22 <nav class="tabs" aria-label="Repository"> 22 <nav class="tabs" aria-label="Repository">
23 <a class="tab {{if ne .Kind "commits"}}active{{end}}" href="{{.BaseURL}}">{{icon "code"}} Code</a> 23 <a class="tab {{if and (ne .Kind "commits") (ne .Kind "commit")}}active{{end}}" href="{{.BaseURL}}">{{icon "code"}} Code</a>
24 <a class="tab {{if eq .Kind "commits"}}active{{end}}" href="{{.CommitsURL}}">{{icon "history"}} Commits <span class="counter">{{.CommitCount}}</span></a> 24 <a class="tab {{if or (eq .Kind "commits") (eq .Kind "commit")}}active{{end}}" href="{{.CommitsURL}}">{{icon "history"}} Commits <span class="counter">{{.CommitCount}}</span></a>
25 </nav> 25 </nav>
26 </section> 26 </section>
27 27
28 <main> 28 <main>
29 <div class="content-shell {{if eq .Kind "blob"}}blob-content-shell{{end}}"> 29 <div class="content-shell {{if or (eq .Kind "blob") (eq .Kind "commit")}}blob-content-shell{{end}}">
30 {{if eq .Kind "repo"}} 30 {{if eq .Kind "repo"}}
31 <div class="repo-overview-head"> 31 <div class="repo-overview-head">
32 <div class="repo-title"><span class="repo-icon">{{icon "repo"}}</span><a href="{{.BaseURL}}">{{.RepoName}}</a><span class="visibility">Public</span></div> 32 <div class="repo-title"><span class="repo-icon">{{icon "repo"}}</span><a href="{{.BaseURL}}">{{.RepoName}}</a><span class="visibility">Public</span></div>
@@ -106,14 +106,70 @@ const pageTemplate = `<!doctype html>
106 {{range .Commits}}<article class="commit-list-row" id="{{.OID}}"> 106 {{range .Commits}}<article class="commit-list-row" id="{{.OID}}">
107 <img class="avatar commit-avatar" src="{{.AvatarURL}}" alt="" loading="lazy" referrerpolicy="no-referrer"> 107 <img class="avatar commit-avatar" src="{{.AvatarURL}}" alt="" loading="lazy" referrerpolicy="no-referrer">
108 <div class="commit-list-body"> 108 <div class="commit-list-body">
109 <strong class="commit-list-message">{{.Message}}</strong> 109 <a class="commit-list-message" href="{{.URL}}">{{.Message}}</a>
110 <span><strong>{{.Author}}</strong> committed on <time datetime="{{.DateTime}}">{{.Date}}</time></span> 110 <span><strong>{{.Author}}</strong> committed on <time datetime="{{.DateTime}}">{{.Date}}</time></span>
111 </div> 111 </div>
112 <a class="commit-sha" href="#{{.OID}}">{{.ShortOID}}</a> 112 <a class="commit-sha" href="{{.URL}}">{{.ShortOID}}</a>
113 </article>{{end}} 113 </article>{{end}}
114 </div> 114 </div>
115 </section>{{end}} 115 </section>{{end}}
116 </section> 116 </section>
117 {{else if eq .Kind "commit"}}
118 <section class="primary full-width commit-view">
119 <div class="commit-page-heading"><h1>Commit <code>{{.ViewedCommit.Commit.ShortOID}}</code></h1></div>
120 <article class="commit-summary-card">
121 <div class="commit-summary-title">
122 <strong>{{.ViewedCommit.Commit.Message}}</strong>
123 <button class="secondary-button commit-copy" type="button" data-copy="{{.ViewedCommit.Commit.OID}}" aria-label="Copy commit ID"><span data-copy-icon>{{icon "copy"}}</span><span data-copied-icon hidden>{{icon "check"}}</span><span data-copy-label>Copy SHA</span></button>
124 </div>
125 {{if .ViewedCommit.Commit.Body}}<pre class="commit-body">{{.ViewedCommit.Commit.Body}}</pre>{{end}}
126 <div class="commit-author-row">
127 <img class="avatar commit-avatar" src="{{.ViewedCommit.Commit.AvatarURL}}" alt="" referrerpolicy="no-referrer">
128 <span><strong>{{.ViewedCommit.Commit.Author}}</strong> committed on <time datetime="{{.ViewedCommit.Commit.DateTime}}">{{.ViewedCommit.Commit.Date}}</time></span>
129 </div>
130 <div class="commit-identifiers">
131 <span>commit <code>{{.ViewedCommit.Commit.OID}}</code></span>
132 {{range .ViewedCommit.Parents}}<span>parent <a href="{{.URL}}"><code>{{.ShortOID}}</code></a></span>{{end}}
133 </div>
134 </article>
135
136 <div class="diff-overview">
137 {{if .ViewedCommit.Truncated}}<strong>Large commit</strong>{{else}}<strong>{{len .ViewedCommit.Files}} changed files</strong>{{end}}
138 {{if gt (len .ViewedCommit.Parents) 1}}<span class="muted">Diffed against the first of {{len .ViewedCommit.Parents}} parents.</span>{{end}}
139 <span class="diff-totals"><b class="addition">+{{.ViewedCommit.Additions}}</b><b class="deletion">{{.ViewedCommit.Deletions}}</b></span>
140 </div>
141
142 {{if .ViewedCommit.Truncated}}
143 <div class="diff-limit-notice"><strong>Diff too large to render</strong><p>This commit's patch exceeds the 8 MB static-page limit. Clone the repository to inspect the complete change.</p></div>
144 {{else if .ViewedCommit.Files}}
145 <div class="diff-layout">
146 <aside class="diff-sidebar">
147 <h2>Files changed</h2>
148 <nav class="diff-file-nav" aria-label="Changed files">
149 {{range .ViewedCommit.Files}}<a href="#{{.Anchor}}"><span>{{.Status}}</span>{{.DisplayPath}}</a>{{end}}
150 </nav>
151 </aside>
152 <div class="diff-files">
153 {{range .ViewedCommit.Files}}<article class="diff-file" id="{{.Anchor}}">
154 <header class="diff-file-header">
155 <span class="diff-status">{{.Status}}</span><strong title="{{.DisplayPath}}">{{.DisplayPath}}</strong>
156 <span class="diff-file-stats"><b class="addition">+{{.Additions}}</b><b class="deletion">{{.Deletions}}</b></span>
157 </header>
158 {{if .Binary}}<div class="diff-file-notice">Binary file changed.</div>
159 {{else if not .Rows}}<div class="diff-file-notice">{{if .Truncated}}Diff omitted because this commit exceeds the static-page render limit.{{else}}File metadata changed without textual changes.{{end}}</div>
160 {{else}}<div class="split-diff"><table aria-label="Side-by-side diff for {{.DisplayPath}}"><tbody>
161 {{range .Rows}}{{if .Hunk}}<tr class="diff-hunk"><td colspan="4"><code>{{.Header}}</code></td></tr>{{else}}<tr>
162 <td class="diff-line-number {{.OldClass}}">{{if .OldPresent}}{{.OldNumber}}{{end}}</td>
163 <td class="diff-code old {{.OldClass}} {{if not .OldPresent}}empty{{end}}"><code>{{if .OldPresent}}{{.OldText}}{{end}}</code></td>
164 <td class="diff-line-number {{.NewClass}}">{{if .NewPresent}}{{.NewNumber}}{{end}}</td>
165 <td class="diff-code new {{.NewClass}} {{if not .NewPresent}}empty{{end}}"><code>{{if .NewPresent}}{{.NewText}}{{end}}</code></td>
166 </tr>{{end}}{{end}}
167 </tbody></table></div>{{if .Truncated}}<div class="diff-truncated-notice">Diff truncated after the static-page render limit. Clone the repository to inspect the complete change.</div>{{end}}{{end}}
168 </article>{{end}}
169 </div>
170 </div>
171 {{else}}<div class="diff-limit-notice"><strong>No file changes</strong><p>This commit has no changes relative to its first parent.</p></div>{{end}}
172 </section>
117 {{else}} 173 {{else}}
118 <div class="blob-layout"> 174 <div class="blob-layout">
119 <aside class="file-browser" aria-label="Repository files"> 175 <aside class="file-browser" aria-label="Repository files">
@@ -473,8 +529,8 @@ const pageTemplate = `<!doctype html>
473 <div class="tree-card"> 529 <div class="tree-card">
474 <div class="commit-row"> 530 <div class="commit-row">
475 <img class="avatar commit-avatar" src="{{.Commit.AvatarURL}}" alt="" referrerpolicy="no-referrer"> 531 <img class="avatar commit-avatar" src="{{.Commit.AvatarURL}}" alt="" referrerpolicy="no-referrer">
476 <div class="latest-commit"><strong>{{.Commit.Author}}</strong><span class="commit-message">{{.Commit.Message}}</span></div> 532 <div class="latest-commit"><strong>{{.Commit.Author}}</strong><a class="commit-message" href="{{.Commit.URL}}">{{.Commit.Message}}</a></div>
477 <a class="commit-hash" href="{{.CommitsURL}}#{{.Commit.OID}}">{{.Commit.ShortOID}}</a> 533 <a class="commit-hash" href="{{.Commit.URL}}">{{.Commit.ShortOID}}</a>
478 <time datetime="{{.Commit.DateTime}}">{{.Commit.Date}}</time> 534 <time datetime="{{.Commit.DateTime}}">{{.Commit.Date}}</time>
479 <a class="commit-count-link" href="{{.CommitsURL}}">{{icon "history"}}<strong>{{.CommitCount}}</strong> commits</a> 535 <a class="commit-count-link" href="{{.CommitsURL}}">{{icon "history"}}<strong>{{.CommitCount}}</strong> commits</a>
480 </div> 536 </div>