detached 333c710

feat(web): home rows show commits, last 30 days, latest tag and last activity (#62)

commit
333c71081622ec84d1a304246618ab0f31ccc1f2
author
brice · · 2026-10-18 09:52 UTC
committer
bnema · · 2026-10-18 09:52 UTC
parent
4c072b1
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.23.0
32 files · +1328 -572
M internal/adapters/ermitegit/ermitegit.go +11 -0
@@ -415,3 +415,14 @@ func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports
415415 func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
416416 return a.cli.GetCommit(ctx, repo, rev, limits)
417417 }
418+
419+// CountCommits is served by the wrapped reader: git counts from the commit
420+// graph.
421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
422+ return a.cli.CountCommits(ctx, repo, head, since)
423+}
424+
425+// LatestTag is served by the wrapped reader.
426+func (a *Adapter) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
427+ return a.cli.LatestTag(ctx, repo)
428+}
@@ -415,3 +415,14 @@ func (a *Adapter) ListCommits(ctx context.Context, repo domain.RepoName, q ports
415 func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {415 func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
416 return a.cli.GetCommit(ctx, repo, rev, limits)416 return a.cli.GetCommit(ctx, repo, rev, limits)
417 }417 }
418+
419+// CountCommits is served by the wrapped reader: git counts from the commit
420+// graph.
421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
422+ return a.cli.CountCommits(ctx, repo, head, since)
423+}
424+
425+// LatestTag is served by the wrapped reader.
426+func (a *Adapter) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
427+ return a.cli.LatestTag(ctx, repo)
428+}
A internal/adapters/gitcli/stats.go +88 -0
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+ "strings"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// CountCommits runs `git rev-list --count`, from the commit graph when the
16+// repository has one. A bound uses --since-as-filter, which looks at every
17+// reachable commit: --since stops the walk at the first older one, and
18+// misses newer commits behind it when dates are out of order.
19+//
20+//hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
21+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
22+ if head.IsZero() {
23+ return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
24+ }
25+ dir, err := g.repoDir(repo)
26+ if err != nil {
27+ return 0, err
28+ }
29+ args := []string{"rev-list", "--count"}
30+ if !since.IsZero() {
31+ args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10))
32+ }
33+ out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...)
34+ if err != nil {
35+ if exitCode(err) == 128 {
36+ return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
37+ }
38+ return 0, fmt.Errorf("count commits of %q: %w", repo, err)
39+ }
40+ n, err := strconv.Atoi(strings.TrimSpace(out))
41+ if err != nil {
42+ return 0, fmt.Errorf("count commits of %q: git printed %q", repo, strings.TrimSpace(out))
43+ }
44+ return n, nil
45+}
46+
47+// LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
50+func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
51+ dir, err := g.repoDir(repo)
52+ if err != nil {
53+ return domain.TagInfo{}, false, err
54+ }
55+ out, err := g.run(ctx, readArgs(dir, "for-each-ref", "--sort=-creatordate", "--count=1",
56+ "--format=%(refname:strip=2)%00%(creatordate:unix)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)", tagsRefPrefix)...)
57+ if err != nil {
58+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
59+ }
60+ line := strings.TrimSuffix(out, "\n")
61+ if line == "" {
62+ return domain.TagInfo{}, false, nil
63+ }
64+ f := strings.Split(line, "\x00")
65+ if len(f) != 6 || f[0] == "" {
66+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
67+ }
68+ sec, err := strconv.ParseInt(f[1], 10, 64)
69+ if err != nil {
70+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
71+ }
72+ tag := domain.TagInfo{Name: domain.ValidGitText(f[0]), At: time.Unix(sec, 0).UTC()}
73+ // A lightweight tag names its commit; an annotated one is peeled to the
74+ // end of its chain of tags.
75+ commit := ""
76+ switch {
77+ case f[2] == "commit":
78+ commit = f[3]
79+ case f[4] == "commit":
80+ commit = f[5]
81+ }
82+ if commit != "" {
83+ if tag.CommitID, err = domain.ParseObjectID(commit); err != nil {
84+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
85+ }
86+ }
87+ return tag, true, nil
88+}
@@ -0,0 +1,88 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "strconv"
9+ "strings"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+// CountCommits runs `git rev-list --count`, from the commit graph when the
16+// repository has one. A bound uses --since-as-filter, which looks at every
17+// reachable commit: --since stops the walk at the first older one, and
18+// misses newer commits behind it when dates are out of order.
19+//
20+//hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
21+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
22+ if head.IsZero() {
23+ return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
24+ }
25+ dir, err := g.repoDir(repo)
26+ if err != nil {
27+ return 0, err
28+ }
29+ args := []string{"rev-list", "--count"}
30+ if !since.IsZero() {
31+ args = append(args, "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10))
32+ }
33+ out, err := g.run(ctx, readArgs(dir, append(args, "--end-of-options", head.String())...)...)
34+ if err != nil {
35+ if exitCode(err) == 128 {
36+ return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
37+ }
38+ return 0, fmt.Errorf("count commits of %q: %w", repo, err)
39+ }
40+ n, err := strconv.Atoi(strings.TrimSpace(out))
41+ if err != nil {
42+ return 0, fmt.Errorf("count commits of %q: git printed %q", repo, strings.TrimSpace(out))
43+ }
44+ return n, nil
45+}
46+
47+// LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
50+func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
51+ dir, err := g.repoDir(repo)
52+ if err != nil {
53+ return domain.TagInfo{}, false, err
54+ }
55+ out, err := g.run(ctx, readArgs(dir, "for-each-ref", "--sort=-creatordate", "--count=1",
56+ "--format=%(refname:strip=2)%00%(creatordate:unix)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)", tagsRefPrefix)...)
57+ if err != nil {
58+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
59+ }
60+ line := strings.TrimSuffix(out, "\n")
61+ if line == "" {
62+ return domain.TagInfo{}, false, nil
63+ }
64+ f := strings.Split(line, "\x00")
65+ if len(f) != 6 || f[0] == "" {
66+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
67+ }
68+ sec, err := strconv.ParseInt(f[1], 10, 64)
69+ if err != nil {
70+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
71+ }
72+ tag := domain.TagInfo{Name: domain.ValidGitText(f[0]), At: time.Unix(sec, 0).UTC()}
73+ // A lightweight tag names its commit; an annotated one is peeled to the
74+ // end of its chain of tags.
75+ commit := ""
76+ switch {
77+ case f[2] == "commit":
78+ commit = f[3]
79+ case f[4] == "commit":
80+ commit = f[5]
81+ }
82+ if commit != "" {
83+ if tag.CommitID, err = domain.ParseObjectID(commit); err != nil {
84+ return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
85+ }
86+ }
87+ return tag, true, nil
88+}
M internal/adapters/web/components.templ +23 -10
@@ -52,16 +52,30 @@ templ HomeRowView(r HomeRow) {
5252 } else {
5353 <p class="repo-desc mute">no description</p>
5454 }
55- <p class="repo-meta">
56- if r.Branch != "" {
57- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>
58- }
59- if r.PushedAt != "" {
60- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>
61- } else {
62- <span>never pushed</span>
55+ <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
56+ if s := r.Stats; s != nil {
57+ <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
58+ <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
59+ if s.Tag != "" {
60+ <div>
61+ <dt>latest tag</dt>
62+ if s.TagHref != "" {
63+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>
64+ } else {
65+ <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>
66+ }
67+ </div>
68+ }
6369 }
64- </p>
70+ <div>
71+ <dt>last activity</dt>
72+ if r.ActiveAt != "" {
73+ <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
74+ } else {
75+ <dd class="mute">never</dd>
76+ }
77+ </div>
78+ </dl>
6579 </div>
6680 if r.CI != nil {
6781 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
@@ -131,7 +145,6 @@ templ CommitRows(rows []CommitRow) {
131145 }
132146 data-cursor?={ c.Cursor }
133147 >
134- <td class="cur" aria-hidden="true"></td>
135148 <td class="c-sha mute">{ c.Short }</td>
136149 <td class="c-subject">
137150 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
@@ -52,16 +52,30 @@ templ HomeRowView(r HomeRow) {
52 } else {52 } else {
53 <p class="repo-desc mute">no description</p>53 <p class="repo-desc mute">no description</p>
54 }54 }
55- <p class="repo-meta">55+ <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
56- if r.Branch != "" {56+ if s := r.Stats; s != nil {
57- <span class="repo-branch"><span class="ico i-branch" aria-hidden="true"></span>{ r.Branch }</span>57+ <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
58- }58+ <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
59- if r.PushedAt != "" {59+ if s.Tag != "" {
60- <span>pushed <time datetime={ r.PushedAt } title={ r.PushedAt }>{ r.Pushed }</time></span>60+ <div>
61- } else {61+ <dt>latest tag</dt>
62- <span>never pushed</span>62+ if s.TagHref != "" {
63+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>
64+ } else {
65+ <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>
66+ }
67+ </div>
68+ }
63 }69 }
64- </p>70+ <div>
71+ <dt>last activity</dt>
72+ if r.ActiveAt != "" {
73+ <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
74+ } else {
75+ <dd class="mute">never</dd>
76+ }
77+ </div>
78+ </dl>
65 </div>79 </div>
66 if r.CI != nil {80 if r.CI != nil {
67 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>81 <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
@@ -131,7 +145,6 @@ templ CommitRows(rows []CommitRow) {
131 }145 }
132 data-cursor?={ c.Cursor }146 data-cursor?={ c.Cursor }
133 >147 >
134- <td class="cur" aria-hidden="true"></td>
135 <td class="c-sha mute">{ c.Short }</td>148 <td class="c-sha mute">{ c.Short }</td>
136 <td class="c-subject">149 <td class="c-subject">
137 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>150 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
M internal/adapters/web/components_templ.go +325 -209
@@ -264,286 +264,402 @@ func HomeRowView(r HomeRow) templ.Component {
264264 return templ_7745c5c3_Err
265265 }
266266 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"repo-meta\">")
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<dl class=\"repo-stats\" aria-label=\"")
268268 if templ_7745c5c3_Err != nil {
269269 return templ_7745c5c3_Err
270270 }
271- if r.Branch != "" {
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"repo-branch\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span>")
271+ var templ_7745c5c3_Var12 string
272+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
273+ if templ_7745c5c3_Err != nil {
274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 61}
275+ }
276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
277+ if templ_7745c5c3_Err != nil {
278+ return templ_7745c5c3_Err
279+ }
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
281+ if templ_7745c5c3_Err != nil {
282+ return templ_7745c5c3_Err
283+ }
284+ if s := r.Stats; s != nil {
285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div><dt>commits</dt><dd>")
286+ if templ_7745c5c3_Err != nil {
287+ return templ_7745c5c3_Err
288+ }
289+ var templ_7745c5c3_Var13 string
290+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
291+ if templ_7745c5c3_Err != nil {
292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 41}
293+ }
294+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
295+ if templ_7745c5c3_Err != nil {
296+ return templ_7745c5c3_Err
297+ }
298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</dd></div><div><dt>last 30 days</dt><dd>")
273299 if templ_7745c5c3_Err != nil {
274300 return templ_7745c5c3_Err
275301 }
276- var templ_7745c5c3_Var12 string
277- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
302+ var templ_7745c5c3_Var14 string
303+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
278304 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 94}
305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 45}
280306 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
307+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
282308 if templ_7745c5c3_Err != nil {
283309 return templ_7745c5c3_Err
284310 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> ")
311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</dd></div>")
286312 if templ_7745c5c3_Err != nil {
287313 return templ_7745c5c3_Err
288314 }
315+ if s.Tag != "" {
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div><dt>latest tag</dt>")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ if s.TagHref != "" {
321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<dd><a href=\"")
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ var templ_7745c5c3_Var15 templ.SafeURL
326+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
327+ if templ_7745c5c3_Err != nil {
328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 46}
329+ }
330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
331+ if templ_7745c5c3_Err != nil {
332+ return templ_7745c5c3_Err
333+ }
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
335+ if templ_7745c5c3_Err != nil {
336+ return templ_7745c5c3_Err
337+ }
338+ var templ_7745c5c3_Var16 string
339+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
340+ if templ_7745c5c3_Err != nil {
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 76}
342+ }
343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
344+ if templ_7745c5c3_Err != nil {
345+ return templ_7745c5c3_Err
346+ }
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var17 string
352+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 86}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a></dd>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ } else {
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<dd title=\"")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var18 string
370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 39}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ var templ_7745c5c3_Var19 string
383+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
384+ if templ_7745c5c3_Err != nil {
385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 49}
386+ }
387+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
388+ if templ_7745c5c3_Err != nil {
389+ return templ_7745c5c3_Err
390+ }
391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd>")
392+ if templ_7745c5c3_Err != nil {
393+ return templ_7745c5c3_Err
394+ }
395+ }
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
397+ if templ_7745c5c3_Err != nil {
398+ return templ_7745c5c3_Err
399+ }
400+ }
289401 }
290- if r.PushedAt != "" {
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>pushed <time datetime=\"")
402+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div><dt>last activity</dt>")
403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ if r.ActiveAt != "" {
407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd><time datetime=\"")
292408 if templ_7745c5c3_Err != nil {
293409 return templ_7745c5c3_Err
294410 }
295- var templ_7745c5c3_Var13 string
296- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
411+ var templ_7745c5c3_Var20 string
412+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
297413 if templ_7745c5c3_Err != nil {
298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 45}
414+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 37}
299415 }
300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
416+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
301417 if templ_7745c5c3_Err != nil {
302418 return templ_7745c5c3_Err
303419 }
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"")
420+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"")
305421 if templ_7745c5c3_Err != nil {
306422 return templ_7745c5c3_Err
307423 }
308- var templ_7745c5c3_Var14 string
309- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
424+ var templ_7745c5c3_Var21 string
425+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
310426 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 66}
427+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 58}
312428 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
429+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
314430 if templ_7745c5c3_Err != nil {
315431 return templ_7745c5c3_Err
316432 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
433+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
318434 if templ_7745c5c3_Err != nil {
319435 return templ_7745c5c3_Err
320436 }
321- var templ_7745c5c3_Var15 string
322- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
437+ var templ_7745c5c3_Var22 string
438+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
323439 if templ_7745c5c3_Err != nil {
324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}
440+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 71}
325441 }
326- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
442+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
327443 if templ_7745c5c3_Err != nil {
328444 return templ_7745c5c3_Err
329445 }
330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time></span>")
446+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</time></dd>")
331447 if templ_7745c5c3_Err != nil {
332448 return templ_7745c5c3_Err
333449 }
334450 } else {
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>never pushed</span>")
451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<dd class=\"mute\">never</dd>")
336452 if templ_7745c5c3_Err != nil {
337453 return templ_7745c5c3_Err
338454 }
339455 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></dl></div>")
341457 if templ_7745c5c3_Err != nil {
342458 return templ_7745c5c3_Err
343459 }
344460 if r.CI != nil {
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"repo-ci\" href=\"")
461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"repo-ci\" href=\"")
346462 if templ_7745c5c3_Err != nil {
347463 return templ_7745c5c3_Err
348464 }
349- var templ_7745c5c3_Var16 templ.SafeURL
350- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
465+ var templ_7745c5c3_Var23 templ.SafeURL
466+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
351467 if templ_7745c5c3_Err != nil {
352- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 53}
468+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 53}
353469 }
354- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
470+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
355471 if templ_7745c5c3_Err != nil {
356472 return templ_7745c5c3_Err
357473 }
358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" title=\"")
474+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
359475 if templ_7745c5c3_Err != nil {
360476 return templ_7745c5c3_Err
361477 }
362- var templ_7745c5c3_Var17 string
363- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
478+ var templ_7745c5c3_Var24 string
479+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
364480 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 74}
481+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 74}
366482 }
367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
483+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
368484 if templ_7745c5c3_Err != nil {
369485 return templ_7745c5c3_Err
370486 }
371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
372488 if templ_7745c5c3_Err != nil {
373489 return templ_7745c5c3_Err
374490 }
375- var templ_7745c5c3_Var18 = []any{"ck", "ck-" + r.CI.Kind}
376- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
491+ var templ_7745c5c3_Var25 = []any{"ck", "ck-" + r.CI.Kind}
492+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
377493 if templ_7745c5c3_Err != nil {
378494 return templ_7745c5c3_Err
379495 }
380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"")
496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"")
381497 if templ_7745c5c3_Err != nil {
382498 return templ_7745c5c3_Err
383499 }
384- var templ_7745c5c3_Var19 string
385- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
500+ var templ_7745c5c3_Var26 string
501+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
386502 if templ_7745c5c3_Err != nil {
387503 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
388504 }
389- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
505+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
390506 if templ_7745c5c3_Err != nil {
391507 return templ_7745c5c3_Err
392508 }
393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
394510 if templ_7745c5c3_Err != nil {
395511 return templ_7745c5c3_Err
396512 }
397- var templ_7745c5c3_Var20 string
398- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
513+ var templ_7745c5c3_Var27 string
514+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
399515 if templ_7745c5c3_Err != nil {
400- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 69, Col: 44}
516+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 83, Col: 44}
401517 }
402- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
518+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
403519 if templ_7745c5c3_Err != nil {
404520 return templ_7745c5c3_Err
405521 }
406- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a> ")
522+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")
407523 if templ_7745c5c3_Err != nil {
408524 return templ_7745c5c3_Err
409525 }
410526 }
411527 if len(r.Commits) > 0 {
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<ul class=\"repo-commits\" aria-label=\"")
528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<ul class=\"repo-commits\" aria-label=\"")
413529 if templ_7745c5c3_Err != nil {
414530 return templ_7745c5c3_Err
415531 }
416- var templ_7745c5c3_Var21 string
417- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
532+ var templ_7745c5c3_Var28 string
533+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
418534 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 70}
535+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 87, Col: 70}
420536 }
421- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
537+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
422538 if templ_7745c5c3_Err != nil {
423539 return templ_7745c5c3_Err
424540 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
541+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
426542 if templ_7745c5c3_Err != nil {
427543 return templ_7745c5c3_Err
428544 }
429545 for _, c := range r.Commits {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li><a class=\"rc-sha\" href=\"")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<li><a class=\"rc-sha\" href=\"")
431547 if templ_7745c5c3_Err != nil {
432548 return templ_7745c5c3_Err
433549 }
434- var templ_7745c5c3_Var22 templ.SafeURL
435- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
550+ var templ_7745c5c3_Var29 templ.SafeURL
551+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
436552 if templ_7745c5c3_Err != nil {
437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 53}
553+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 53}
438554 }
439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
555+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
440556 if templ_7745c5c3_Err != nil {
441557 return templ_7745c5c3_Err
442558 }
443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")
559+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
444560 if templ_7745c5c3_Err != nil {
445561 return templ_7745c5c3_Err
446562 }
447- var templ_7745c5c3_Var23 string
448- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
563+ var templ_7745c5c3_Var30 string
564+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
449565 if templ_7745c5c3_Err != nil {
450- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 65}
566+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 65}
451567 }
452- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
568+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
453569 if templ_7745c5c3_Err != nil {
454570 return templ_7745c5c3_Err
455571 }
456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> <span class=\"rc-subject\" title=\"")
572+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> <span class=\"rc-subject\" title=\"")
457573 if templ_7745c5c3_Err != nil {
458574 return templ_7745c5c3_Err
459575 }
460- var templ_7745c5c3_Var24 string
461- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
576+ var templ_7745c5c3_Var31 string
577+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
462578 if templ_7745c5c3_Err != nil {
463- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 48}
579+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 48}
464580 }
465- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
581+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
466582 if templ_7745c5c3_Err != nil {
467583 return templ_7745c5c3_Err
468584 }
469- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")
585+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
470586 if templ_7745c5c3_Err != nil {
471587 return templ_7745c5c3_Err
472588 }
473- var templ_7745c5c3_Var25 string
474- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
589+ var templ_7745c5c3_Var32 string
590+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
475591 if templ_7745c5c3_Err != nil {
476- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 62}
592+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 62}
477593 }
478- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
594+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
479595 if templ_7745c5c3_Err != nil {
480596 return templ_7745c5c3_Err
481597 }
482- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")
483599 if templ_7745c5c3_Err != nil {
484600 return templ_7745c5c3_Err
485601 }
486602 if c.WhenAt != "" {
487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<time class=\"dim\" datetime=\"")
603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<time class=\"dim\" datetime=\"")
488604 if templ_7745c5c3_Err != nil {
489605 return templ_7745c5c3_Err
490606 }
491- var templ_7745c5c3_Var26 string
492- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
607+ var templ_7745c5c3_Var33 string
608+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
493609 if templ_7745c5c3_Err != nil {
494- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 44}
610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 44}
495611 }
496- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
497613 if templ_7745c5c3_Err != nil {
498614 return templ_7745c5c3_Err
499615 }
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" title=\"")
501617 if templ_7745c5c3_Err != nil {
502618 return templ_7745c5c3_Err
503619 }
504- var templ_7745c5c3_Var27 string
505- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
620+ var templ_7745c5c3_Var34 string
621+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
506622 if templ_7745c5c3_Err != nil {
507- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 63}
623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 63}
508624 }
509- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
510626 if templ_7745c5c3_Err != nil {
511627 return templ_7745c5c3_Err
512628 }
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")
629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
514630 if templ_7745c5c3_Err != nil {
515631 return templ_7745c5c3_Err
516632 }
517- var templ_7745c5c3_Var28 string
518- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
633+ var templ_7745c5c3_Var35 string
634+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
519635 if templ_7745c5c3_Err != nil {
520- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 74}
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 74}
521637 }
522- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
523639 if templ_7745c5c3_Err != nil {
524640 return templ_7745c5c3_Err
525641 }
526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time>")
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time>")
527643 if templ_7745c5c3_Err != nil {
528644 return templ_7745c5c3_Err
529645 }
530646 }
531- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")
647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>")
532648 if templ_7745c5c3_Err != nil {
533649 return templ_7745c5c3_Err
534650 }
535651 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul>")
652+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>")
537653 if templ_7745c5c3_Err != nil {
538654 return templ_7745c5c3_Err
539655 }
540656 } else if r.NoCommits {
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<p class=\"repo-commits mute\">no commits yet</p>")
657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"repo-commits mute\">no commits yet</p>")
542658 if templ_7745c5c3_Err != nil {
543659 return templ_7745c5c3_Err
544660 }
545661 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</li>")
662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
547663 if templ_7745c5c3_Err != nil {
548664 return templ_7745c5c3_Err
549665 }
@@ -568,66 +684,66 @@ func Outline(headings []Heading) templ.Component {
568684 }()
569685 }
570686 ctx = templ.InitializeContext(ctx)
571- templ_7745c5c3_Var29 := templ.GetChildren(ctx)
572- if templ_7745c5c3_Var29 == nil {
573- templ_7745c5c3_Var29 = templ.NopComponent
687+ templ_7745c5c3_Var36 := templ.GetChildren(ctx)
688+ if templ_7745c5c3_Var36 == nil {
689+ templ_7745c5c3_Var36 = templ.NopComponent
574690 }
575691 ctx = templ.ClearChildren(ctx)
576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<nav class=\"outline\" aria-label=\"Outline\">")
692+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<nav class=\"outline\" aria-label=\"Outline\">")
577693 if templ_7745c5c3_Err != nil {
578694 return templ_7745c5c3_Err
579695 }
580696 for _, h := range headings {
581- var templ_7745c5c3_Var30 = []any{"l" + itoa(h.Level)}
582- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)
697+ var templ_7745c5c3_Var37 = []any{"l" + itoa(h.Level)}
698+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
583699 if templ_7745c5c3_Err != nil {
584700 return templ_7745c5c3_Err
585701 }
586- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a class=\"")
702+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"")
587703 if templ_7745c5c3_Err != nil {
588704 return templ_7745c5c3_Err
589705 }
590- var templ_7745c5c3_Var31 string
591- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
706+ var templ_7745c5c3_Var38 string
707+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String())
592708 if templ_7745c5c3_Err != nil {
593709 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
594710 }
595- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
711+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
596712 if templ_7745c5c3_Err != nil {
597713 return templ_7745c5c3_Err
598714 }
599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" href=\"")
715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" href=\"")
600716 if templ_7745c5c3_Err != nil {
601717 return templ_7745c5c3_Err
602718 }
603- var templ_7745c5c3_Var32 templ.SafeURL
604- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
719+ var templ_7745c5c3_Var39 templ.SafeURL
720+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
605721 if templ_7745c5c3_Err != nil {
606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 68}
722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 68}
607723 }
608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
609725 if templ_7745c5c3_Err != nil {
610726 return templ_7745c5c3_Err
611727 }
612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")
728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
613729 if templ_7745c5c3_Err != nil {
614730 return templ_7745c5c3_Err
615731 }
616- var templ_7745c5c3_Var33 string
617- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
732+ var templ_7745c5c3_Var40 string
733+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
618734 if templ_7745c5c3_Err != nil {
619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 79}
735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 79}
620736 }
621- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
622738 if templ_7745c5c3_Err != nil {
623739 return templ_7745c5c3_Err
624740 }
625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a>")
741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")
626742 if templ_7745c5c3_Err != nil {
627743 return templ_7745c5c3_Err
628744 }
629745 }
630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</nav>")
746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav>")
631747 if templ_7745c5c3_Err != nil {
632748 return templ_7745c5c3_Err
633749 }
@@ -652,25 +768,25 @@ func ReadmeCard(r Readme) templ.Component {
652768 }()
653769 }
654770 ctx = templ.InitializeContext(ctx)
655- templ_7745c5c3_Var34 := templ.GetChildren(ctx)
656- if templ_7745c5c3_Var34 == nil {
657- templ_7745c5c3_Var34 = templ.NopComponent
771+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
772+ if templ_7745c5c3_Var41 == nil {
773+ templ_7745c5c3_Var41 = templ.NopComponent
658774 }
659775 ctx = templ.ClearChildren(ctx)
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
661777 if templ_7745c5c3_Err != nil {
662778 return templ_7745c5c3_Err
663779 }
664- var templ_7745c5c3_Var35 string
665- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
780+ var templ_7745c5c3_Var42 string
781+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
666782 if templ_7745c5c3_Err != nil {
667- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 11}
783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 11}
668784 }
669- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
670786 if templ_7745c5c3_Err != nil {
671787 return templ_7745c5c3_Err
672788 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h2>")
789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</h2>")
674790 if templ_7745c5c3_Err != nil {
675791 return templ_7745c5c3_Err
676792 }
@@ -678,7 +794,7 @@ func ReadmeCard(r Readme) templ.Component {
678794 if templ_7745c5c3_Err != nil {
679795 return templ_7745c5c3_Err
680796 }
681- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")
797+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</section>")
682798 if templ_7745c5c3_Err != nil {
683799 return templ_7745c5c3_Err
684800 }
@@ -704,43 +820,43 @@ func ReadmeView(r Readme) templ.Component {
704820 }()
705821 }
706822 ctx = templ.InitializeContext(ctx)
707- templ_7745c5c3_Var36 := templ.GetChildren(ctx)
708- if templ_7745c5c3_Var36 == nil {
709- templ_7745c5c3_Var36 = templ.NopComponent
823+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
824+ if templ_7745c5c3_Var43 == nil {
825+ templ_7745c5c3_Var43 = templ.NopComponent
710826 }
711827 ctx = templ.ClearChildren(ctx)
712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"prose\" aria-label=\"")
828+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<article class=\"prose\" aria-label=\"")
713829 if templ_7745c5c3_Err != nil {
714830 return templ_7745c5c3_Err
715831 }
716- var templ_7745c5c3_Var37 string
717- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
832+ var templ_7745c5c3_Var44 string
833+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
718834 if templ_7745c5c3_Err != nil {
719- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 43}
835+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 127, Col: 43}
720836 }
721- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
837+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
722838 if templ_7745c5c3_Err != nil {
723839 return templ_7745c5c3_Err
724840 }
725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-ignore>")
841+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-ignore>")
726842 if templ_7745c5c3_Err != nil {
727843 return templ_7745c5c3_Err
728844 }
729845 if r.Note != "" {
730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"dim\">")
846+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"dim\">")
731847 if templ_7745c5c3_Err != nil {
732848 return templ_7745c5c3_Err
733849 }
734- var templ_7745c5c3_Var38 string
735- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
850+ var templ_7745c5c3_Var45 string
851+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
736852 if templ_7745c5c3_Err != nil {
737- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 26}
853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 129, Col: 26}
738854 }
739- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
855+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
740856 if templ_7745c5c3_Err != nil {
741857 return templ_7745c5c3_Err
742858 }
743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</p>")
859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</p>")
744860 if templ_7745c5c3_Err != nil {
745861 return templ_7745c5c3_Err
746862 }
@@ -750,7 +866,7 @@ func ReadmeView(r Readme) templ.Component {
750866 return templ_7745c5c3_Err
751867 }
752868 }
753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")
869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</article>")
754870 if templ_7745c5c3_Err != nil {
755871 return templ_7745c5c3_Err
756872 }
@@ -776,133 +892,133 @@ func CommitRows(rows []CommitRow) templ.Component {
776892 }()
777893 }
778894 ctx = templ.InitializeContext(ctx)
779- templ_7745c5c3_Var39 := templ.GetChildren(ctx)
780- if templ_7745c5c3_Var39 == nil {
781- templ_7745c5c3_Var39 = templ.NopComponent
895+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
896+ if templ_7745c5c3_Var46 == nil {
897+ templ_7745c5c3_Var46 = templ.NopComponent
782898 }
783899 ctx = templ.ClearChildren(ctx)
784- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
785901 if templ_7745c5c3_Err != nil {
786902 return templ_7745c5c3_Err
787903 }
788904 for _, c := range rows {
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<tr")
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<tr")
790906 if templ_7745c5c3_Err != nil {
791907 return templ_7745c5c3_Err
792908 }
793909 if c.ID != "" {
794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " id=\"")
910+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " id=\"")
795911 if templ_7745c5c3_Err != nil {
796912 return templ_7745c5c3_Err
797913 }
798- var templ_7745c5c3_Var40 string
799- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
914+ var templ_7745c5c3_Var47 string
915+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
800916 if templ_7745c5c3_Err != nil {
801- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 130, Col: 15}
917+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 144, Col: 15}
802918 }
803- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
919+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
804920 if templ_7745c5c3_Err != nil {
805921 return templ_7745c5c3_Err
806922 }
807- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"")
808924 if templ_7745c5c3_Err != nil {
809925 return templ_7745c5c3_Err
810926 }
811927 }
812928 if c.Cursor {
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-cursor")
929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " data-cursor")
814930 if templ_7745c5c3_Err != nil {
815931 return templ_7745c5c3_Err
816932 }
817933 }
818- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "><td class=\"c-sha mute\">")
819935 if templ_7745c5c3_Err != nil {
820936 return templ_7745c5c3_Err
821937 }
822- var templ_7745c5c3_Var41 string
823- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
938+ var templ_7745c5c3_Var48 string
939+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
824940 if templ_7745c5c3_Err != nil {
825- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 37}
941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 148, Col: 37}
826942 }
827- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
943+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
828944 if templ_7745c5c3_Err != nil {
829945 return templ_7745c5c3_Err
830946 }
831- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td><td class=\"c-subject\"><a href=\"")
947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</td><td class=\"c-subject\"><a href=\"")
832948 if templ_7745c5c3_Err != nil {
833949 return templ_7745c5c3_Err
834950 }
835- var templ_7745c5c3_Var42 templ.SafeURL
836- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
951+ var templ_7745c5c3_Var49 templ.SafeURL
952+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
837953 if templ_7745c5c3_Err != nil {
838- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 38}
954+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 38}
839955 }
840- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
956+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
841957 if templ_7745c5c3_Err != nil {
842958 return templ_7745c5c3_Err
843959 }
844- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")
960+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
845961 if templ_7745c5c3_Err != nil {
846962 return templ_7745c5c3_Err
847963 }
848- var templ_7745c5c3_Var43 string
849- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
964+ var templ_7745c5c3_Var50 string
965+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
850966 if templ_7745c5c3_Err != nil {
851- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 52}
967+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 52}
852968 }
853- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
969+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
854970 if templ_7745c5c3_Err != nil {
855971 return templ_7745c5c3_Err
856972 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</a> ")
973+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a> ")
858974 if templ_7745c5c3_Err != nil {
859975 return templ_7745c5c3_Err
860976 }
861977 if c.Merge {
862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"pill\">merge</span> ")
978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"pill\">merge</span> ")
863979 if templ_7745c5c3_Err != nil {
864980 return templ_7745c5c3_Err
865981 }
866982 }
867983 for _, b := range c.Branches {
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"pill\">")
984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"pill\">")
869985 if templ_7745c5c3_Err != nil {
870986 return templ_7745c5c3_Err
871987 }
872- var templ_7745c5c3_Var44 string
873- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b)
988+ var templ_7745c5c3_Var51 string
989+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(b)
874990 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 142, Col: 29}
991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 155, Col: 29}
876992 }
877- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
993+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
878994 if templ_7745c5c3_Err != nil {
879995 return templ_7745c5c3_Err
880996 }
881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ")
997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")
882998 if templ_7745c5c3_Err != nil {
883999 return templ_7745c5c3_Err
8841000 }
8851001 }
8861002 for _, t := range c.Tags {
887- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
1003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
8881004 if templ_7745c5c3_Err != nil {
8891005 return templ_7745c5c3_Err
8901006 }
891- var templ_7745c5c3_Var45 string
892- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(t)
1007+ var templ_7745c5c3_Var52 string
1008+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(t)
8931009 if templ_7745c5c3_Err != nil {
894- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 61}
1010+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 158, Col: 61}
8951011 }
896- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
1012+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
8971013 if templ_7745c5c3_Err != nil {
8981014 return templ_7745c5c3_Err
8991015 }
900- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span>")
1016+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span>")
9011017 if templ_7745c5c3_Err != nil {
9021018 return templ_7745c5c3_Err
9031019 }
9041020 }
905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</td><td class=\"c-ck\">")
1021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td class=\"c-ck\">")
9061022 if templ_7745c5c3_Err != nil {
9071023 return templ_7745c5c3_Err
9081024 }
@@ -910,64 +1026,64 @@ func CommitRows(rows []CommitRow) templ.Component {
9101026 if templ_7745c5c3_Err != nil {
9111027 return templ_7745c5c3_Err
9121028 }
913- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</td><td class=\"c-who dim\">")
1029+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td class=\"c-who dim\">")
9141030 if templ_7745c5c3_Err != nil {
9151031 return templ_7745c5c3_Err
9161032 }
917- var templ_7745c5c3_Var46 string
918- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1033+ var templ_7745c5c3_Var53 string
1034+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
9191035 if templ_7745c5c3_Err != nil {
920- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 149, Col: 37}
1036+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 162, Col: 37}
9211037 }
922- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
1038+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
9231039 if templ_7745c5c3_Err != nil {
9241040 return templ_7745c5c3_Err
9251041 }
926- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
1042+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
9271043 if templ_7745c5c3_Err != nil {
9281044 return templ_7745c5c3_Err
9291045 }
930- var templ_7745c5c3_Var47 string
931- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1046+ var templ_7745c5c3_Var54 string
1047+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
9321048 if templ_7745c5c3_Err != nil {
933- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 60}
1049+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 60}
9341050 }
935- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
1051+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
9361052 if templ_7745c5c3_Err != nil {
9371053 return templ_7745c5c3_Err
9381054 }
939- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")
1055+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" title=\"")
9401056 if templ_7745c5c3_Err != nil {
9411057 return templ_7745c5c3_Err
9421058 }
943- var templ_7745c5c3_Var48 string
944- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1059+ var templ_7745c5c3_Var55 string
1060+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
9451061 if templ_7745c5c3_Err != nil {
946- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}
1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 79}
9471063 }
948- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
1064+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
9491065 if templ_7745c5c3_Err != nil {
9501066 return templ_7745c5c3_Err
9511067 }
952- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")
1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\">")
9531069 if templ_7745c5c3_Err != nil {
9541070 return templ_7745c5c3_Err
9551071 }
956- var templ_7745c5c3_Var49 string
957- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1072+ var templ_7745c5c3_Var56 string
1073+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
9581074 if templ_7745c5c3_Err != nil {
959- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 90}
1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 90}
9601076 }
961- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1077+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
9621078 if templ_7745c5c3_Err != nil {
9631079 return templ_7745c5c3_Err
9641080 }
965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</time></td></tr>")
1081+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</time></td></tr>")
9661082 if templ_7745c5c3_Err != nil {
9671083 return templ_7745c5c3_Err
9681084 }
9691085 }
970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</tbody></table>")
1086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</tbody></table>")
9711087 if templ_7745c5c3_Err != nil {
9721088 return templ_7745c5c3_Err
9731089 }
@@ -264,286 +264,402 @@ func HomeRowView(r HomeRow) templ.Component {
264 return templ_7745c5c3_Err264 return templ_7745c5c3_Err
265 }265 }
266 }266 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"repo-meta\">")267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<dl class=\"repo-stats\" aria-label=\"")
268 if templ_7745c5c3_Err != nil {268 if templ_7745c5c3_Err != nil {
269 return templ_7745c5c3_Err269 return templ_7745c5c3_Err
270 }270 }
271- if r.Branch != "" {271+ var templ_7745c5c3_Var12 string
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<span class=\"repo-branch\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span>")272+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
273+ if templ_7745c5c3_Err != nil {
274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 61}
275+ }
276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
277+ if templ_7745c5c3_Err != nil {
278+ return templ_7745c5c3_Err
279+ }
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
281+ if templ_7745c5c3_Err != nil {
282+ return templ_7745c5c3_Err
283+ }
284+ if s := r.Stats; s != nil {
285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div><dt>commits</dt><dd>")
286+ if templ_7745c5c3_Err != nil {
287+ return templ_7745c5c3_Err
288+ }
289+ var templ_7745c5c3_Var13 string
290+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
291+ if templ_7745c5c3_Err != nil {
292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 41}
293+ }
294+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
295+ if templ_7745c5c3_Err != nil {
296+ return templ_7745c5c3_Err
297+ }
298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</dd></div><div><dt>last 30 days</dt><dd>")
273 if templ_7745c5c3_Err != nil {299 if templ_7745c5c3_Err != nil {
274 return templ_7745c5c3_Err300 return templ_7745c5c3_Err
275 }301 }
276- var templ_7745c5c3_Var12 string302+ var templ_7745c5c3_Var14 string
277- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)303+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
278 if templ_7745c5c3_Err != nil {304 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 94}305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 45}
280 }306 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))307+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
282 if templ_7745c5c3_Err != nil {308 if templ_7745c5c3_Err != nil {
283 return templ_7745c5c3_Err309 return templ_7745c5c3_Err
284 }310 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> ")311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</dd></div>")
286 if templ_7745c5c3_Err != nil {312 if templ_7745c5c3_Err != nil {
287 return templ_7745c5c3_Err313 return templ_7745c5c3_Err
288 }314 }
315+ if s.Tag != "" {
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div><dt>latest tag</dt>")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ if s.TagHref != "" {
321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<dd><a href=\"")
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ var templ_7745c5c3_Var15 templ.SafeURL
326+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
327+ if templ_7745c5c3_Err != nil {
328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 46}
329+ }
330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
331+ if templ_7745c5c3_Err != nil {
332+ return templ_7745c5c3_Err
333+ }
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
335+ if templ_7745c5c3_Err != nil {
336+ return templ_7745c5c3_Err
337+ }
338+ var templ_7745c5c3_Var16 string
339+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
340+ if templ_7745c5c3_Err != nil {
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 76}
342+ }
343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
344+ if templ_7745c5c3_Err != nil {
345+ return templ_7745c5c3_Err
346+ }
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var17 string
352+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 86}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a></dd>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ } else {
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<dd title=\"")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var18 string
370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 39}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ var templ_7745c5c3_Var19 string
383+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
384+ if templ_7745c5c3_Err != nil {
385+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 49}
386+ }
387+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
388+ if templ_7745c5c3_Err != nil {
389+ return templ_7745c5c3_Err
390+ }
391+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd>")
392+ if templ_7745c5c3_Err != nil {
393+ return templ_7745c5c3_Err
394+ }
395+ }
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
397+ if templ_7745c5c3_Err != nil {
398+ return templ_7745c5c3_Err
399+ }
400+ }
289 }401 }
290- if r.PushedAt != "" {402+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div><dt>last activity</dt>")
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<span>pushed <time datetime=\"")403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ if r.ActiveAt != "" {
407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd><time datetime=\"")
292 if templ_7745c5c3_Err != nil {408 if templ_7745c5c3_Err != nil {
293 return templ_7745c5c3_Err409 return templ_7745c5c3_Err
294 }410 }
295- var templ_7745c5c3_Var13 string411+ var templ_7745c5c3_Var20 string
296- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)412+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
297 if templ_7745c5c3_Err != nil {413 if templ_7745c5c3_Err != nil {
298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 45}414+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 37}
299 }415 }
300- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)416+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
301 if templ_7745c5c3_Err != nil {417 if templ_7745c5c3_Err != nil {
302 return templ_7745c5c3_Err418 return templ_7745c5c3_Err
303 }419 }
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" title=\"")420+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"")
305 if templ_7745c5c3_Err != nil {421 if templ_7745c5c3_Err != nil {
306 return templ_7745c5c3_Err422 return templ_7745c5c3_Err
307 }423 }
308- var templ_7745c5c3_Var14 string424+ var templ_7745c5c3_Var21 string
309- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)425+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
310 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 66}427+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 58}
312 }428 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)429+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
314 if templ_7745c5c3_Err != nil {430 if templ_7745c5c3_Err != nil {
315 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
316 }432 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")433+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
318 if templ_7745c5c3_Err != nil {434 if templ_7745c5c3_Err != nil {
319 return templ_7745c5c3_Err435 return templ_7745c5c3_Err
320 }436 }
321- var templ_7745c5c3_Var15 string437+ var templ_7745c5c3_Var22 string
322- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)438+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
323 if templ_7745c5c3_Err != nil {439 if templ_7745c5c3_Err != nil {
324- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}440+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 71}
325 }441 }
326- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))442+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
327 if templ_7745c5c3_Err != nil {443 if templ_7745c5c3_Err != nil {
328 return templ_7745c5c3_Err444 return templ_7745c5c3_Err
329 }445 }
330- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</time></span>")446+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</time></dd>")
331 if templ_7745c5c3_Err != nil {447 if templ_7745c5c3_Err != nil {
332 return templ_7745c5c3_Err448 return templ_7745c5c3_Err
333 }449 }
334 } else {450 } else {
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span>never pushed</span>")451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<dd class=\"mute\">never</dd>")
336 if templ_7745c5c3_Err != nil {452 if templ_7745c5c3_Err != nil {
337 return templ_7745c5c3_Err453 return templ_7745c5c3_Err
338 }454 }
339 }455 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></dl></div>")
341 if templ_7745c5c3_Err != nil {457 if templ_7745c5c3_Err != nil {
342 return templ_7745c5c3_Err458 return templ_7745c5c3_Err
343 }459 }
344 if r.CI != nil {460 if r.CI != nil {
345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<a class=\"repo-ci\" href=\"")461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"repo-ci\" href=\"")
346 if templ_7745c5c3_Err != nil {462 if templ_7745c5c3_Err != nil {
347 return templ_7745c5c3_Err463 return templ_7745c5c3_Err
348 }464 }
349- var templ_7745c5c3_Var16 templ.SafeURL465+ var templ_7745c5c3_Var23 templ.SafeURL
350- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))466+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
351 if templ_7745c5c3_Err != nil {467 if templ_7745c5c3_Err != nil {
352- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 53}468+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 53}
353 }469 }
354- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))470+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
355 if templ_7745c5c3_Err != nil {471 if templ_7745c5c3_Err != nil {
356 return templ_7745c5c3_Err472 return templ_7745c5c3_Err
357 }473 }
358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" title=\"")474+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
359 if templ_7745c5c3_Err != nil {475 if templ_7745c5c3_Err != nil {
360 return templ_7745c5c3_Err476 return templ_7745c5c3_Err
361 }477 }
362- var templ_7745c5c3_Var17 string478+ var templ_7745c5c3_Var24 string
363- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)479+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
364 if templ_7745c5c3_Err != nil {480 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 67, Col: 74}481+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 74}
366 }482 }
367- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)483+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
368 if templ_7745c5c3_Err != nil {484 if templ_7745c5c3_Err != nil {
369 return templ_7745c5c3_Err485 return templ_7745c5c3_Err
370 }486 }
371- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")487+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
372 if templ_7745c5c3_Err != nil {488 if templ_7745c5c3_Err != nil {
373 return templ_7745c5c3_Err489 return templ_7745c5c3_Err
374 }490 }
375- var templ_7745c5c3_Var18 = []any{"ck", "ck-" + r.CI.Kind}491+ var templ_7745c5c3_Var25 = []any{"ck", "ck-" + r.CI.Kind}
376- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)492+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
377 if templ_7745c5c3_Err != nil {493 if templ_7745c5c3_Err != nil {
378 return templ_7745c5c3_Err494 return templ_7745c5c3_Err
379 }495 }
380- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<span class=\"")496+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"")
381 if templ_7745c5c3_Err != nil {497 if templ_7745c5c3_Err != nil {
382 return templ_7745c5c3_Err498 return templ_7745c5c3_Err
383 }499 }
384- var templ_7745c5c3_Var19 string500+ var templ_7745c5c3_Var26 string
385- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())501+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
386 if templ_7745c5c3_Err != nil {502 if templ_7745c5c3_Err != nil {
387 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}503 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
388 }504 }
389- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)505+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
390 if templ_7745c5c3_Err != nil {506 if templ_7745c5c3_Err != nil {
391 return templ_7745c5c3_Err507 return templ_7745c5c3_Err
392 }508 }
393- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
394 if templ_7745c5c3_Err != nil {510 if templ_7745c5c3_Err != nil {
395 return templ_7745c5c3_Err511 return templ_7745c5c3_Err
396 }512 }
397- var templ_7745c5c3_Var20 string513+ var templ_7745c5c3_Var27 string
398- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)514+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
399 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
400- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 69, Col: 44}516+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 83, Col: 44}
401 }517 }
402- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))518+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
403 if templ_7745c5c3_Err != nil {519 if templ_7745c5c3_Err != nil {
404 return templ_7745c5c3_Err520 return templ_7745c5c3_Err
405 }521 }
406- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a> ")522+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")
407 if templ_7745c5c3_Err != nil {523 if templ_7745c5c3_Err != nil {
408 return templ_7745c5c3_Err524 return templ_7745c5c3_Err
409 }525 }
410 }526 }
411 if len(r.Commits) > 0 {527 if len(r.Commits) > 0 {
412- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<ul class=\"repo-commits\" aria-label=\"")528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<ul class=\"repo-commits\" aria-label=\"")
413 if templ_7745c5c3_Err != nil {529 if templ_7745c5c3_Err != nil {
414 return templ_7745c5c3_Err530 return templ_7745c5c3_Err
415 }531 }
416- var templ_7745c5c3_Var21 string532+ var templ_7745c5c3_Var28 string
417- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)533+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
418 if templ_7745c5c3_Err != nil {534 if templ_7745c5c3_Err != nil {
419- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 70}535+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 87, Col: 70}
420 }536 }
421- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)537+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
422 if templ_7745c5c3_Err != nil {538 if templ_7745c5c3_Err != nil {
423 return templ_7745c5c3_Err539 return templ_7745c5c3_Err
424 }540 }
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")541+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
426 if templ_7745c5c3_Err != nil {542 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err543 return templ_7745c5c3_Err
428 }544 }
429 for _, c := range r.Commits {545 for _, c := range r.Commits {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<li><a class=\"rc-sha\" href=\"")546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<li><a class=\"rc-sha\" href=\"")
431 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
432 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
433 }549 }
434- var templ_7745c5c3_Var22 templ.SafeURL550+ var templ_7745c5c3_Var29 templ.SafeURL
435- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))551+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
436 if templ_7745c5c3_Err != nil {552 if templ_7745c5c3_Err != nil {
437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 53}553+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 53}
438 }554 }
439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))555+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
440 if templ_7745c5c3_Err != nil {556 if templ_7745c5c3_Err != nil {
441 return templ_7745c5c3_Err557 return templ_7745c5c3_Err
442 }558 }
443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\">")559+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
444 if templ_7745c5c3_Err != nil {560 if templ_7745c5c3_Err != nil {
445 return templ_7745c5c3_Err561 return templ_7745c5c3_Err
446 }562 }
447- var templ_7745c5c3_Var23 string563+ var templ_7745c5c3_Var30 string
448- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)564+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
449 if templ_7745c5c3_Err != nil {565 if templ_7745c5c3_Err != nil {
450- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 65}566+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 65}
451 }567 }
452- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))568+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
453 if templ_7745c5c3_Err != nil {569 if templ_7745c5c3_Err != nil {
454 return templ_7745c5c3_Err570 return templ_7745c5c3_Err
455 }571 }
456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a> <span class=\"rc-subject\" title=\"")572+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> <span class=\"rc-subject\" title=\"")
457 if templ_7745c5c3_Err != nil {573 if templ_7745c5c3_Err != nil {
458 return templ_7745c5c3_Err574 return templ_7745c5c3_Err
459 }575 }
460- var templ_7745c5c3_Var24 string576+ var templ_7745c5c3_Var31 string
461- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)577+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
462 if templ_7745c5c3_Err != nil {578 if templ_7745c5c3_Err != nil {
463- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 48}579+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 48}
464 }580 }
465- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)581+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
466 if templ_7745c5c3_Err != nil {582 if templ_7745c5c3_Err != nil {
467 return templ_7745c5c3_Err583 return templ_7745c5c3_Err
468 }584 }
469- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")585+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
470 if templ_7745c5c3_Err != nil {586 if templ_7745c5c3_Err != nil {
471 return templ_7745c5c3_Err587 return templ_7745c5c3_Err
472 }588 }
473- var templ_7745c5c3_Var25 string589+ var templ_7745c5c3_Var32 string
474- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)590+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
475 if templ_7745c5c3_Err != nil {591 if templ_7745c5c3_Err != nil {
476- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 77, Col: 62}592+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 62}
477 }593 }
478- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))594+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
479 if templ_7745c5c3_Err != nil {595 if templ_7745c5c3_Err != nil {
480 return templ_7745c5c3_Err596 return templ_7745c5c3_Err
481 }597 }
482- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")
483 if templ_7745c5c3_Err != nil {599 if templ_7745c5c3_Err != nil {
484 return templ_7745c5c3_Err600 return templ_7745c5c3_Err
485 }601 }
486 if c.WhenAt != "" {602 if c.WhenAt != "" {
487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<time class=\"dim\" datetime=\"")603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<time class=\"dim\" datetime=\"")
488 if templ_7745c5c3_Err != nil {604 if templ_7745c5c3_Err != nil {
489 return templ_7745c5c3_Err605 return templ_7745c5c3_Err
490 }606 }
491- var templ_7745c5c3_Var26 string607+ var templ_7745c5c3_Var33 string
492- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)608+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
493 if templ_7745c5c3_Err != nil {609 if templ_7745c5c3_Err != nil {
494- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 44}610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 44}
495 }611 }
496- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
497 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
498 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
499 }615 }
500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" title=\"")616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" title=\"")
501 if templ_7745c5c3_Err != nil {617 if templ_7745c5c3_Err != nil {
502 return templ_7745c5c3_Err618 return templ_7745c5c3_Err
503 }619 }
504- var templ_7745c5c3_Var27 string620+ var templ_7745c5c3_Var34 string
505- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)621+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
506 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
507- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 63}623+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 63}
508 }624 }
509- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)625+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
510 if templ_7745c5c3_Err != nil {626 if templ_7745c5c3_Err != nil {
511 return templ_7745c5c3_Err627 return templ_7745c5c3_Err
512 }628 }
513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">")629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
514 if templ_7745c5c3_Err != nil {630 if templ_7745c5c3_Err != nil {
515 return templ_7745c5c3_Err631 return templ_7745c5c3_Err
516 }632 }
517- var templ_7745c5c3_Var28 string633+ var templ_7745c5c3_Var35 string
518- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)634+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
519 if templ_7745c5c3_Err != nil {635 if templ_7745c5c3_Err != nil {
520- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 79, Col: 74}636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 74}
521 }637 }
522- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
523 if templ_7745c5c3_Err != nil {639 if templ_7745c5c3_Err != nil {
524 return templ_7745c5c3_Err640 return templ_7745c5c3_Err
525 }641 }
526- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</time>")642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time>")
527 if templ_7745c5c3_Err != nil {643 if templ_7745c5c3_Err != nil {
528 return templ_7745c5c3_Err644 return templ_7745c5c3_Err
529 }645 }
530 }646 }
531- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</li>")647+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>")
532 if templ_7745c5c3_Err != nil {648 if templ_7745c5c3_Err != nil {
533 return templ_7745c5c3_Err649 return templ_7745c5c3_Err
534 }650 }
535 }651 }
536- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</ul>")652+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>")
537 if templ_7745c5c3_Err != nil {653 if templ_7745c5c3_Err != nil {
538 return templ_7745c5c3_Err654 return templ_7745c5c3_Err
539 }655 }
540 } else if r.NoCommits {656 } else if r.NoCommits {
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<p class=\"repo-commits mute\">no commits yet</p>")657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"repo-commits mute\">no commits yet</p>")
542 if templ_7745c5c3_Err != nil {658 if templ_7745c5c3_Err != nil {
543 return templ_7745c5c3_Err659 return templ_7745c5c3_Err
544 }660 }
545 }661 }
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</li>")662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
547 if templ_7745c5c3_Err != nil {663 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err664 return templ_7745c5c3_Err
549 }665 }
@@ -568,66 +684,66 @@ func Outline(headings []Heading) templ.Component {
568 }()684 }()
569 }685 }
570 ctx = templ.InitializeContext(ctx)686 ctx = templ.InitializeContext(ctx)
571- templ_7745c5c3_Var29 := templ.GetChildren(ctx)687+ templ_7745c5c3_Var36 := templ.GetChildren(ctx)
572- if templ_7745c5c3_Var29 == nil {688+ if templ_7745c5c3_Var36 == nil {
573- templ_7745c5c3_Var29 = templ.NopComponent689+ templ_7745c5c3_Var36 = templ.NopComponent
574 }690 }
575 ctx = templ.ClearChildren(ctx)691 ctx = templ.ClearChildren(ctx)
576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<nav class=\"outline\" aria-label=\"Outline\">")692+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<nav class=\"outline\" aria-label=\"Outline\">")
577 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
578 return templ_7745c5c3_Err694 return templ_7745c5c3_Err
579 }695 }
580 for _, h := range headings {696 for _, h := range headings {
581- var templ_7745c5c3_Var30 = []any{"l" + itoa(h.Level)}697+ var templ_7745c5c3_Var37 = []any{"l" + itoa(h.Level)}
582- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...)698+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
583 if templ_7745c5c3_Err != nil {699 if templ_7745c5c3_Err != nil {
584 return templ_7745c5c3_Err700 return templ_7745c5c3_Err
585 }701 }
586- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<a class=\"")702+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"")
587 if templ_7745c5c3_Err != nil {703 if templ_7745c5c3_Err != nil {
588 return templ_7745c5c3_Err704 return templ_7745c5c3_Err
589 }705 }
590- var templ_7745c5c3_Var31 string706+ var templ_7745c5c3_Var38 string
591- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())707+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String())
592 if templ_7745c5c3_Err != nil {708 if templ_7745c5c3_Err != nil {
593 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}709 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
594 }710 }
595- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)711+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
596 if templ_7745c5c3_Err != nil {712 if templ_7745c5c3_Err != nil {
597 return templ_7745c5c3_Err713 return templ_7745c5c3_Err
598 }714 }
599- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" href=\"")715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" href=\"")
600 if templ_7745c5c3_Err != nil {716 if templ_7745c5c3_Err != nil {
601 return templ_7745c5c3_Err717 return templ_7745c5c3_Err
602 }718 }
603- var templ_7745c5c3_Var32 templ.SafeURL719+ var templ_7745c5c3_Var39 templ.SafeURL
604- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))720+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
605 if templ_7745c5c3_Err != nil {721 if templ_7745c5c3_Err != nil {
606- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 68}722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 68}
607 }723 }
608- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
609 if templ_7745c5c3_Err != nil {725 if templ_7745c5c3_Err != nil {
610 return templ_7745c5c3_Err726 return templ_7745c5c3_Err
611 }727 }
612- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\">")728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
613 if templ_7745c5c3_Err != nil {729 if templ_7745c5c3_Err != nil {
614 return templ_7745c5c3_Err730 return templ_7745c5c3_Err
615 }731 }
616- var templ_7745c5c3_Var33 string732+ var templ_7745c5c3_Var40 string
617- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)733+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
618 if templ_7745c5c3_Err != nil {734 if templ_7745c5c3_Err != nil {
619- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 94, Col: 79}735+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 79}
620 }736 }
621- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))737+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
622 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
623 return templ_7745c5c3_Err739 return templ_7745c5c3_Err
624 }740 }
625- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</a>")741+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")
626 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
627 return templ_7745c5c3_Err743 return templ_7745c5c3_Err
628 }744 }
629 }745 }
630- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</nav>")746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav>")
631 if templ_7745c5c3_Err != nil {747 if templ_7745c5c3_Err != nil {
632 return templ_7745c5c3_Err748 return templ_7745c5c3_Err
633 }749 }
@@ -652,25 +768,25 @@ func ReadmeCard(r Readme) templ.Component {
652 }()768 }()
653 }769 }
654 ctx = templ.InitializeContext(ctx)770 ctx = templ.InitializeContext(ctx)
655- templ_7745c5c3_Var34 := templ.GetChildren(ctx)771+ templ_7745c5c3_Var41 := templ.GetChildren(ctx)
656- if templ_7745c5c3_Var34 == nil {772+ if templ_7745c5c3_Var41 == nil {
657- templ_7745c5c3_Var34 = templ.NopComponent773+ templ_7745c5c3_Var41 = templ.NopComponent
658 }774 }
659 ctx = templ.ClearChildren(ctx)775 ctx = templ.ClearChildren(ctx)
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")776+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
661 if templ_7745c5c3_Err != nil {777 if templ_7745c5c3_Err != nil {
662 return templ_7745c5c3_Err778 return templ_7745c5c3_Err
663 }779 }
664- var templ_7745c5c3_Var35 string780+ var templ_7745c5c3_Var42 string
665- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)781+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
666 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
667- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 11}783+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 11}
668 }784 }
669- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))785+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
670 if templ_7745c5c3_Err != nil {786 if templ_7745c5c3_Err != nil {
671 return templ_7745c5c3_Err787 return templ_7745c5c3_Err
672 }788 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</h2>")789+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</h2>")
674 if templ_7745c5c3_Err != nil {790 if templ_7745c5c3_Err != nil {
675 return templ_7745c5c3_Err791 return templ_7745c5c3_Err
676 }792 }
@@ -678,7 +794,7 @@ func ReadmeCard(r Readme) templ.Component {
678 if templ_7745c5c3_Err != nil {794 if templ_7745c5c3_Err != nil {
679 return templ_7745c5c3_Err795 return templ_7745c5c3_Err
680 }796 }
681- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</section>")797+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</section>")
682 if templ_7745c5c3_Err != nil {798 if templ_7745c5c3_Err != nil {
683 return templ_7745c5c3_Err799 return templ_7745c5c3_Err
684 }800 }
@@ -704,43 +820,43 @@ func ReadmeView(r Readme) templ.Component {
704 }()820 }()
705 }821 }
706 ctx = templ.InitializeContext(ctx)822 ctx = templ.InitializeContext(ctx)
707- templ_7745c5c3_Var36 := templ.GetChildren(ctx)823+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
708- if templ_7745c5c3_Var36 == nil {824+ if templ_7745c5c3_Var43 == nil {
709- templ_7745c5c3_Var36 = templ.NopComponent825+ templ_7745c5c3_Var43 = templ.NopComponent
710 }826 }
711 ctx = templ.ClearChildren(ctx)827 ctx = templ.ClearChildren(ctx)
712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<article class=\"prose\" aria-label=\"")828+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<article class=\"prose\" aria-label=\"")
713 if templ_7745c5c3_Err != nil {829 if templ_7745c5c3_Err != nil {
714 return templ_7745c5c3_Err830 return templ_7745c5c3_Err
715 }831 }
716- var templ_7745c5c3_Var37 string832+ var templ_7745c5c3_Var44 string
717- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)833+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
718 if templ_7745c5c3_Err != nil {834 if templ_7745c5c3_Err != nil {
719- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 113, Col: 43}835+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 127, Col: 43}
720 }836 }
721- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)837+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
722 if templ_7745c5c3_Err != nil {838 if templ_7745c5c3_Err != nil {
723 return templ_7745c5c3_Err839 return templ_7745c5c3_Err
724 }840 }
725- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-ignore>")841+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-ignore>")
726 if templ_7745c5c3_Err != nil {842 if templ_7745c5c3_Err != nil {
727 return templ_7745c5c3_Err843 return templ_7745c5c3_Err
728 }844 }
729 if r.Note != "" {845 if r.Note != "" {
730- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"dim\">")846+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"dim\">")
731 if templ_7745c5c3_Err != nil {847 if templ_7745c5c3_Err != nil {
732 return templ_7745c5c3_Err848 return templ_7745c5c3_Err
733 }849 }
734- var templ_7745c5c3_Var38 string850+ var templ_7745c5c3_Var45 string
735- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)851+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
736 if templ_7745c5c3_Err != nil {852 if templ_7745c5c3_Err != nil {
737- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 115, Col: 26}853+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 129, Col: 26}
738 }854 }
739- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))855+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
740 if templ_7745c5c3_Err != nil {856 if templ_7745c5c3_Err != nil {
741 return templ_7745c5c3_Err857 return templ_7745c5c3_Err
742 }858 }
743- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</p>")859+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</p>")
744 if templ_7745c5c3_Err != nil {860 if templ_7745c5c3_Err != nil {
745 return templ_7745c5c3_Err861 return templ_7745c5c3_Err
746 }862 }
@@ -750,7 +866,7 @@ func ReadmeView(r Readme) templ.Component {
750 return templ_7745c5c3_Err866 return templ_7745c5c3_Err
751 }867 }
752 }868 }
753- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</article>")869+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</article>")
754 if templ_7745c5c3_Err != nil {870 if templ_7745c5c3_Err != nil {
755 return templ_7745c5c3_Err871 return templ_7745c5c3_Err
756 }872 }
@@ -776,133 +892,133 @@ func CommitRows(rows []CommitRow) templ.Component {
776 }()892 }()
777 }893 }
778 ctx = templ.InitializeContext(ctx)894 ctx = templ.InitializeContext(ctx)
779- templ_7745c5c3_Var39 := templ.GetChildren(ctx)895+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
780- if templ_7745c5c3_Var39 == nil {896+ if templ_7745c5c3_Var46 == nil {
781- templ_7745c5c3_Var39 = templ.NopComponent897+ templ_7745c5c3_Var46 = templ.NopComponent
782 }898 }
783 ctx = templ.ClearChildren(ctx)899 ctx = templ.ClearChildren(ctx)
784- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")900+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
785 if templ_7745c5c3_Err != nil {901 if templ_7745c5c3_Err != nil {
786 return templ_7745c5c3_Err902 return templ_7745c5c3_Err
787 }903 }
788 for _, c := range rows {904 for _, c := range rows {
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<tr")905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<tr")
790 if templ_7745c5c3_Err != nil {906 if templ_7745c5c3_Err != nil {
791 return templ_7745c5c3_Err907 return templ_7745c5c3_Err
792 }908 }
793 if c.ID != "" {909 if c.ID != "" {
794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " id=\"")910+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " id=\"")
795 if templ_7745c5c3_Err != nil {911 if templ_7745c5c3_Err != nil {
796 return templ_7745c5c3_Err912 return templ_7745c5c3_Err
797 }913 }
798- var templ_7745c5c3_Var40 string914+ var templ_7745c5c3_Var47 string
799- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)915+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
800 if templ_7745c5c3_Err != nil {916 if templ_7745c5c3_Err != nil {
801- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 130, Col: 15}917+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 144, Col: 15}
802 }918 }
803- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)919+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
804 if templ_7745c5c3_Err != nil {920 if templ_7745c5c3_Err != nil {
805 return templ_7745c5c3_Err921 return templ_7745c5c3_Err
806 }922 }
807- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")923+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"")
808 if templ_7745c5c3_Err != nil {924 if templ_7745c5c3_Err != nil {
809 return templ_7745c5c3_Err925 return templ_7745c5c3_Err
810 }926 }
811 }927 }
812 if c.Cursor {928 if c.Cursor {
813- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-cursor")929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " data-cursor")
814 if templ_7745c5c3_Err != nil {930 if templ_7745c5c3_Err != nil {
815 return templ_7745c5c3_Err931 return templ_7745c5c3_Err
816 }932 }
817 }933 }
818- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "><td class=\"c-sha mute\">")
819 if templ_7745c5c3_Err != nil {935 if templ_7745c5c3_Err != nil {
820 return templ_7745c5c3_Err936 return templ_7745c5c3_Err
821 }937 }
822- var templ_7745c5c3_Var41 string938+ var templ_7745c5c3_Var48 string
823- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)939+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
824 if templ_7745c5c3_Err != nil {940 if templ_7745c5c3_Err != nil {
825- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 37}941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 148, Col: 37}
826 }942 }
827- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))943+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
828 if templ_7745c5c3_Err != nil {944 if templ_7745c5c3_Err != nil {
829 return templ_7745c5c3_Err945 return templ_7745c5c3_Err
830 }946 }
831- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</td><td class=\"c-subject\"><a href=\"")947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</td><td class=\"c-subject\"><a href=\"")
832 if templ_7745c5c3_Err != nil {948 if templ_7745c5c3_Err != nil {
833 return templ_7745c5c3_Err949 return templ_7745c5c3_Err
834 }950 }
835- var templ_7745c5c3_Var42 templ.SafeURL951+ var templ_7745c5c3_Var49 templ.SafeURL
836- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))952+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
837 if templ_7745c5c3_Err != nil {953 if templ_7745c5c3_Err != nil {
838- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 38}954+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 38}
839 }955 }
840- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))956+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
841 if templ_7745c5c3_Err != nil {957 if templ_7745c5c3_Err != nil {
842 return templ_7745c5c3_Err958 return templ_7745c5c3_Err
843 }959 }
844- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\">")960+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
845 if templ_7745c5c3_Err != nil {961 if templ_7745c5c3_Err != nil {
846 return templ_7745c5c3_Err962 return templ_7745c5c3_Err
847 }963 }
848- var templ_7745c5c3_Var43 string964+ var templ_7745c5c3_Var50 string
849- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)965+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
850 if templ_7745c5c3_Err != nil {966 if templ_7745c5c3_Err != nil {
851- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 52}967+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 52}
852 }968 }
853- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))969+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
854 if templ_7745c5c3_Err != nil {970 if templ_7745c5c3_Err != nil {
855 return templ_7745c5c3_Err971 return templ_7745c5c3_Err
856 }972 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</a> ")973+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a> ")
858 if templ_7745c5c3_Err != nil {974 if templ_7745c5c3_Err != nil {
859 return templ_7745c5c3_Err975 return templ_7745c5c3_Err
860 }976 }
861 if c.Merge {977 if c.Merge {
862- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span class=\"pill\">merge</span> ")978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"pill\">merge</span> ")
863 if templ_7745c5c3_Err != nil {979 if templ_7745c5c3_Err != nil {
864 return templ_7745c5c3_Err980 return templ_7745c5c3_Err
865 }981 }
866 }982 }
867 for _, b := range c.Branches {983 for _, b := range c.Branches {
868- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"pill\">")984+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"pill\">")
869 if templ_7745c5c3_Err != nil {985 if templ_7745c5c3_Err != nil {
870 return templ_7745c5c3_Err986 return templ_7745c5c3_Err
871 }987 }
872- var templ_7745c5c3_Var44 string988+ var templ_7745c5c3_Var51 string
873- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b)989+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(b)
874 if templ_7745c5c3_Err != nil {990 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 142, Col: 29}991+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 155, Col: 29}
876 }992 }
877- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))993+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
878 if templ_7745c5c3_Err != nil {994 if templ_7745c5c3_Err != nil {
879 return templ_7745c5c3_Err995 return templ_7745c5c3_Err
880 }996 }
881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span> ")997+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")
882 if templ_7745c5c3_Err != nil {998 if templ_7745c5c3_Err != nil {
883 return templ_7745c5c3_Err999 return templ_7745c5c3_Err
884 }1000 }
885 }1001 }
886 for _, t := range c.Tags {1002 for _, t := range c.Tags {
887- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")1003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
888 if templ_7745c5c3_Err != nil {1004 if templ_7745c5c3_Err != nil {
889 return templ_7745c5c3_Err1005 return templ_7745c5c3_Err
890 }1006 }
891- var templ_7745c5c3_Var45 string1007+ var templ_7745c5c3_Var52 string
892- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(t)1008+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(t)
893 if templ_7745c5c3_Err != nil {1009 if templ_7745c5c3_Err != nil {
894- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 145, Col: 61}1010+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 158, Col: 61}
895 }1011 }
896- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))1012+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
897 if templ_7745c5c3_Err != nil {1013 if templ_7745c5c3_Err != nil {
898 return templ_7745c5c3_Err1014 return templ_7745c5c3_Err
899 }1015 }
900- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span>")1016+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span>")
901 if templ_7745c5c3_Err != nil {1017 if templ_7745c5c3_Err != nil {
902 return templ_7745c5c3_Err1018 return templ_7745c5c3_Err
903 }1019 }
904 }1020 }
905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</td><td class=\"c-ck\">")1021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td class=\"c-ck\">")
906 if templ_7745c5c3_Err != nil {1022 if templ_7745c5c3_Err != nil {
907 return templ_7745c5c3_Err1023 return templ_7745c5c3_Err
908 }1024 }
@@ -910,64 +1026,64 @@ func CommitRows(rows []CommitRow) templ.Component {
910 if templ_7745c5c3_Err != nil {1026 if templ_7745c5c3_Err != nil {
911 return templ_7745c5c3_Err1027 return templ_7745c5c3_Err
912 }1028 }
913- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</td><td class=\"c-who dim\">")1029+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td class=\"c-who dim\">")
914 if templ_7745c5c3_Err != nil {1030 if templ_7745c5c3_Err != nil {
915 return templ_7745c5c3_Err1031 return templ_7745c5c3_Err
916 }1032 }
917- var templ_7745c5c3_Var46 string1033+ var templ_7745c5c3_Var53 string
918- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)1034+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
919 if templ_7745c5c3_Err != nil {1035 if templ_7745c5c3_Err != nil {
920- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 149, Col: 37}1036+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 162, Col: 37}
921 }1037 }
922- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))1038+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
923 if templ_7745c5c3_Err != nil {1039 if templ_7745c5c3_Err != nil {
924 return templ_7745c5c3_Err1040 return templ_7745c5c3_Err
925 }1041 }
926- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")1042+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
927 if templ_7745c5c3_Err != nil {1043 if templ_7745c5c3_Err != nil {
928 return templ_7745c5c3_Err1044 return templ_7745c5c3_Err
929 }1045 }
930- var templ_7745c5c3_Var47 string1046+ var templ_7745c5c3_Var54 string
931- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1047+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
932 if templ_7745c5c3_Err != nil {1048 if templ_7745c5c3_Err != nil {
933- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 60}1049+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 60}
934 }1050 }
935- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)1051+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
936 if templ_7745c5c3_Err != nil {1052 if templ_7745c5c3_Err != nil {
937 return templ_7745c5c3_Err1053 return templ_7745c5c3_Err
938 }1054 }
939- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" title=\"")1055+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" title=\"")
940 if templ_7745c5c3_Err != nil {1056 if templ_7745c5c3_Err != nil {
941 return templ_7745c5c3_Err1057 return templ_7745c5c3_Err
942 }1058 }
943- var templ_7745c5c3_Var48 string1059+ var templ_7745c5c3_Var55 string
944- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1060+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
945 if templ_7745c5c3_Err != nil {1061 if templ_7745c5c3_Err != nil {
946- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 79}1062+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 79}
947 }1063 }
948- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)1064+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
949 if templ_7745c5c3_Err != nil {1065 if templ_7745c5c3_Err != nil {
950 return templ_7745c5c3_Err1066 return templ_7745c5c3_Err
951 }1067 }
952- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\">")1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\">")
953 if templ_7745c5c3_Err != nil {1069 if templ_7745c5c3_Err != nil {
954 return templ_7745c5c3_Err1070 return templ_7745c5c3_Err
955 }1071 }
956- var templ_7745c5c3_Var49 string1072+ var templ_7745c5c3_Var56 string
957- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)1073+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
958 if templ_7745c5c3_Err != nil {1074 if templ_7745c5c3_Err != nil {
959- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 90}1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 90}
960 }1076 }
961- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1077+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
962 if templ_7745c5c3_Err != nil {1078 if templ_7745c5c3_Err != nil {
963 return templ_7745c5c3_Err1079 return templ_7745c5c3_Err
964 }1080 }
965- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</time></td></tr>")1081+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</time></td></tr>")
966 if templ_7745c5c3_Err != nil {1082 if templ_7745c5c3_Err != nil {
967 return templ_7745c5c3_Err1083 return templ_7745c5c3_Err
968 }1084 }
969 }1085 }
970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</tbody></table>")1086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</tbody></table>")
971 if templ_7745c5c3_Err != nil {1087 if templ_7745c5c3_Err != nil {
972 return templ_7745c5c3_Err1088 return templ_7745c5c3_Err
973 }1089 }
M internal/adapters/web/format.go +16 -0
@@ -45,6 +45,22 @@ func rfc3339(t time.Time) string {
4545 return t.UTC().Format(time.RFC3339)
4646 }
4747
48+// grouped writes a count with thousands separated by a thin space: 12 345.
49+func grouped(n int) string {
50+ s := itoa(n)
51+ if n < 0 || len(s) <= 3 {
52+ return s
53+ }
54+ var b []byte
55+ for i := range len(s) {
56+ if i > 0 && (len(s)-i)%3 == 0 {
57+ b = append(b, "\u202f"...)
58+ }
59+ b = append(b, s[i])
60+ }
61+ return string(b)
62+}
63+
4864 func plural(n int, one, many string) string {
4965 if n == 1 {
5066 return "1 " + one
@@ -45,6 +45,22 @@ func rfc3339(t time.Time) string {
45 return t.UTC().Format(time.RFC3339)45 return t.UTC().Format(time.RFC3339)
46 }46 }
47 47
48+// grouped writes a count with thousands separated by a thin space: 12 345.
49+func grouped(n int) string {
50+ s := itoa(n)
51+ if n < 0 || len(s) <= 3 {
52+ return s
53+ }
54+ var b []byte
55+ for i := range len(s) {
56+ if i > 0 && (len(s)-i)%3 == 0 {
57+ b = append(b, "\u202f"...)
58+ }
59+ b = append(b, s[i])
60+ }
61+ return string(b)
62+}
63+
48 func plural(n int, one, many string) string {64 func plural(n int, one, many string) string {
49 if n == 1 {65 if n == 1 {
50 return "1 " + one66 return "1 " + one
M internal/adapters/web/home.go +30 -2
@@ -168,6 +168,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
168168 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169169 if head != "" {
170170 row.CI = s.homeCI(ctx, r, head)
171+ row.Stats = s.homeStats(ctx, r, head, now)
171172 }
172173 return row
173174 }
@@ -195,6 +196,33 @@ func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Tim
195196 return rows, head, len(rows) == 0
196197 }
197198
199+// homeStats are the figures of a row; nil when the read fails (logged), since
200+// the row does not depend on them.
201+func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
202+ id, err := domain.ParseObjectID(head)
203+ if err != nil {
204+ return nil
205+ }
206+ st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
207+ if err != nil {
208+ if !errors.Is(err, domain.ErrNotFound) {
209+ log := zerowrap.FromCtx(ctx)
210+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
211+ }
212+ return nil
213+ }
214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}
215+ if t := st.LatestTag; t != nil {
216+ hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217+ if !t.CommitID.IsZero() {
218+ // By its commit: a branch of the same name would win over the
219+ // tag in a tree path.
220+ hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
221+ }
222+ }
223+ return hs
224+}
225+
198226 // homeCI is the state of the CI on the head of the default branch, as the
199227 // caller of the request sees it: the combined checks of that commit, read by
200228 // its id so the cost does not grow with the CI history. nil when the server
@@ -231,11 +259,11 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
231259 name := r.Name.String()
232260 row := HomeRow{
233261 ID: "repo-" + name, Name: name, Href: repoHref(name),
234- Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,
262+ Desc: r.Description, Cursor: cursor,
235263 Private: signed && r.Visibility == domain.VisibilityPrivate,
236264 }
237265 if !r.PushedAt.IsZero() {
238- row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
266+ row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
239267 }
240268 return row
241269 }
@@ -168,6 +168,7 @@ func (s *Server) homeRowOf(ctx context.Context, r ports.RepoView, now time.Time,
168 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)168 row.Commits, head, row.NoCommits = s.homeCommits(ctx, r, now)
169 if head != "" {169 if head != "" {
170 row.CI = s.homeCI(ctx, r, head)170 row.CI = s.homeCI(ctx, r, head)
171+ row.Stats = s.homeStats(ctx, r, head, now)
171 }172 }
172 return row173 return row
173 }174 }
@@ -195,6 +196,33 @@ func (s *Server) homeCommits(ctx context.Context, r ports.RepoView, now time.Tim
195 return rows, head, len(rows) == 0196 return rows, head, len(rows) == 0
196 }197 }
197 198
199+// homeStats are the figures of a row; nil when the read fails (logged), since
200+// the row does not depend on them.
201+func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, now time.Time) *HomeStats {
202+ id, err := domain.ParseObjectID(head)
203+ if err != nil {
204+ return nil
205+ }
206+ st, err := s.cfg.Browse.RepoStats(ctx, principal(ctx), r.Name, id, now)
207+ if err != nil {
208+ if !errors.Is(err, domain.ErrNotFound) {
209+ log := zerowrap.FromCtx(ctx)
210+ log.Warn().Err(err).Str("repo", r.Name.String()).Msg("read the figures of the home page")
211+ }
212+ return nil
213+ }
214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}
215+ if t := st.LatestTag; t != nil {
216+ hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217+ if !t.CommitID.IsZero() {
218+ // By its commit: a branch of the same name would win over the
219+ // tag in a tree path.
220+ hs.TagHref = repoHref(r.Name.String(), "tree", t.CommitID.String())
221+ }
222+ }
223+ return hs
224+}
225+
198 // homeCI is the state of the CI on the head of the default branch, as the226 // homeCI is the state of the CI on the head of the default branch, as the
199 // caller of the request sees it: the combined checks of that commit, read by227 // caller of the request sees it: the combined checks of that commit, read by
200 // its id so the cost does not grow with the CI history. nil when the server228 // its id so the cost does not grow with the CI history. nil when the server
@@ -231,11 +259,11 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
231 name := r.Name.String()259 name := r.Name.String()
232 row := HomeRow{260 row := HomeRow{
233 ID: "repo-" + name, Name: name, Href: repoHref(name),261 ID: "repo-" + name, Name: name, Href: repoHref(name),
234- Desc: r.Description, Branch: r.DefaultBranch, Cursor: cursor,262+ Desc: r.Description, Cursor: cursor,
235 Private: signed && r.Visibility == domain.VisibilityPrivate,263 Private: signed && r.Visibility == domain.VisibilityPrivate,
236 }264 }
237 if !r.PushedAt.IsZero() {265 if !r.PushedAt.IsZero() {
238- row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)266+ row.Active, row.ActiveAt = ago(now, r.PushedAt), rfc3339(r.PushedAt)
239 }267 }
240 return row268 return row
241 }269 }
M internal/adapters/web/home_test.go +57 -7
@@ -45,6 +45,55 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
4545 return out
4646 }
4747
48+// homeBrowse is a BrowseService whose figures are always there: 1234
49+// commits, 7 of them recent, no tag. Tests that care set their own.
50+func homeBrowse(t *testing.T) *mocks.BrowseService {
51+ t.Helper()
52+ b := mocks.NewBrowseService(t)
53+ b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
54+ Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
55+ return b
56+}
57+
58+// Each row shows the figures of its repository: commits, recent commits,
59+// latest tag and last activity; no branch name.
60+func TestHomeRowFigures(t *testing.T) {
61+ t.Parallel()
62+ repo := repoName(t, "proj")
63+ head := objectID(t, fmt.Sprintf("%040x", 2))
64+ now := time.Now()
65+ browse := mocks.NewBrowseService(t)
66+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68+ Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ }, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
73+ require.NotNil(t, row.Stats)
74+ out := renderRow(t, row)
75+ assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
76+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")
77+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
78+ assert.Contains(t, out, "<dt>last activity</dt>")
79+ assert.NotContains(t, out, "i-branch", "the default branch is not shown")
80+ assert.NotContains(t, out, "pushed")
81+
82+ // A failed read leaves the figures out, not the row.
83+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
84+ row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
85+ assert.Nil(t, row.Stats)
86+ assert.Len(t, row.Commits, 2)
87+ assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>")
88+}
89+
90+func TestGrouped(t *testing.T) {
91+ t.Parallel()
92+ for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} {
93+ assert.Equal(t, want, grouped(n), n)
94+ }
95+}
96+
4897 func renderRow(t testing.TB, r HomeRow) string {
4998 t.Helper()
5099 var b bytes.Buffer
@@ -63,7 +112,7 @@ func mustRevision(t testing.TB, s string) domain.Revision {
63112 // name of the branch.
64113 func TestHomeRowShowsFiveCommits(t *testing.T) {
65114 t.Parallel()
66- browse := mocks.NewBrowseService(t)
115+ browse := homeBrowse(t)
67116 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68117 Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69118 }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
@@ -90,7 +139,8 @@ func TestHomeRowEmptyRepository(t *testing.T) {
90139 assert.True(t, row.NoCommits)
91140 out := renderRow(t, row)
92141 assert.Contains(t, out, "no commits yet")
93- assert.Contains(t, out, "never pushed")
142+ assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`)
143+ assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit")
94144 assert.NotContains(t, out, "repo-commits\" aria-label")
95145 }
96146
@@ -119,7 +169,7 @@ func TestHomeRowCI(t *testing.T) {
119169 for _, tt := range tests {
120170 t.Run(tt.name, func(t *testing.T) {
121171 t.Parallel()
122- browse := mocks.NewBrowseService(t)
172+ browse := homeBrowse(t)
123173 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124174 runs := mocks.NewRunService(t)
125175 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
@@ -147,7 +197,7 @@ func TestHomeRowPrivateBadge(t *testing.T) {
147197 t.Parallel()
148198 priv := pushedRepo(t, "secret")
149199 priv.Visibility = domain.VisibilityPrivate
150- browse := mocks.NewBrowseService(t)
200+ browse := homeBrowse(t)
151201 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152202 srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153203
@@ -162,7 +212,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162212 t.Parallel()
163213 repo := repoName(t, "proj")
164214 head := fmt.Sprintf("%040x", 2)
165- browse := mocks.NewBrowseService(t)
215+ browse := homeBrowse(t)
166216 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167217 runs := mocks.NewRunService(t)
168218 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
@@ -196,7 +246,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
196246 // served; the others are not affected.
197247 func TestHomeSurvivesABrokenRepository(t *testing.T) {
198248 t.Parallel()
199- browse := mocks.NewBrowseService(t)
249+ browse := homeBrowse(t)
200250 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201251 Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202252 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
@@ -222,7 +272,7 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
222272 views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223273 }
224274 var running, peak atomic.Int32
225- browse := mocks.NewBrowseService(t)
275+ browse := homeBrowse(t)
226276 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227277 func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228278 cur := running.Add(1)
@@ -45,6 +45,55 @@ func homeCommits(t testing.TB, n int) []domain.Commit {
45 return out45 return out
46 }46 }
47 47
48+// homeBrowse is a BrowseService whose figures are always there: 1234
49+// commits, 7 of them recent, no tag. Tests that care set their own.
50+func homeBrowse(t *testing.T) *mocks.BrowseService {
51+ t.Helper()
52+ b := mocks.NewBrowseService(t)
53+ b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
54+ Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
55+ return b
56+}
57+
58+// Each row shows the figures of its repository: commits, recent commits,
59+// latest tag and last activity; no branch name.
60+func TestHomeRowFigures(t *testing.T) {
61+ t.Parallel()
62+ repo := repoName(t, "proj")
63+ head := objectID(t, fmt.Sprintf("%040x", 2))
64+ now := time.Now()
65+ browse := mocks.NewBrowseService(t)
66+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68+ Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ }, nil).Once()
70+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71+
72+ row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
73+ require.NotNil(t, row.Stats)
74+ out := renderRow(t, row)
75+ assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
76+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")
77+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
78+ assert.Contains(t, out, "<dt>last activity</dt>")
79+ assert.NotContains(t, out, "i-branch", "the default branch is not shown")
80+ assert.NotContains(t, out, "pushed")
81+
82+ // A failed read leaves the figures out, not the row.
83+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
84+ row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
85+ assert.Nil(t, row.Stats)
86+ assert.Len(t, row.Commits, 2)
87+ assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>")
88+}
89+
90+func TestGrouped(t *testing.T) {
91+ t.Parallel()
92+ for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} {
93+ assert.Equal(t, want, grouped(n), n)
94+ }
95+}
96+
48 func renderRow(t testing.TB, r HomeRow) string {97 func renderRow(t testing.TB, r HomeRow) string {
49 t.Helper()98 t.Helper()
50 var b bytes.Buffer99 var b bytes.Buffer
@@ -63,7 +112,7 @@ func mustRevision(t testing.TB, s string) domain.Revision {
63 // name of the branch.112 // name of the branch.
64 func TestHomeRowShowsFiveCommits(t *testing.T) {113 func TestHomeRowShowsFiveCommits(t *testing.T) {
65 t.Parallel()114 t.Parallel()
66- browse := mocks.NewBrowseService(t)115+ browse := homeBrowse(t)
67 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{116 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68 Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,117 Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69 }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()118 }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
@@ -90,7 +139,8 @@ func TestHomeRowEmptyRepository(t *testing.T) {
90 assert.True(t, row.NoCommits)139 assert.True(t, row.NoCommits)
91 out := renderRow(t, row)140 out := renderRow(t, row)
92 assert.Contains(t, out, "no commits yet")141 assert.Contains(t, out, "no commits yet")
93- assert.Contains(t, out, "never pushed")142+ assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`)
143+ assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit")
94 assert.NotContains(t, out, "repo-commits\" aria-label")144 assert.NotContains(t, out, "repo-commits\" aria-label")
95 }145 }
96 146
@@ -119,7 +169,7 @@ func TestHomeRowCI(t *testing.T) {
119 for _, tt := range tests {169 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {170 t.Run(tt.name, func(t *testing.T) {
121 t.Parallel()171 t.Parallel()
122- browse := mocks.NewBrowseService(t)172+ browse := homeBrowse(t)
123 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)173 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124 runs := mocks.NewRunService(t)174 runs := mocks.NewRunService(t)
125 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).175 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
@@ -147,7 +197,7 @@ func TestHomeRowPrivateBadge(t *testing.T) {
147 t.Parallel()197 t.Parallel()
148 priv := pushedRepo(t, "secret")198 priv := pushedRepo(t, "secret")
149 priv.Visibility = domain.VisibilityPrivate199 priv.Visibility = domain.VisibilityPrivate
150- browse := mocks.NewBrowseService(t)200+ browse := homeBrowse(t)
151 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()201 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152 srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})202 srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153 203
@@ -162,7 +212,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162 t.Parallel()212 t.Parallel()
163 repo := repoName(t, "proj")213 repo := repoName(t, "proj")
164 head := fmt.Sprintf("%040x", 2)214 head := fmt.Sprintf("%040x", 2)
165- browse := mocks.NewBrowseService(t)215+ browse := homeBrowse(t)
166 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)216 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167 runs := mocks.NewRunService(t)217 runs := mocks.NewRunService(t)
168 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{218 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
@@ -196,7 +246,7 @@ func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
196 // served; the others are not affected.246 // served; the others are not affected.
197 func TestHomeSurvivesABrokenRepository(t *testing.T) {247 func TestHomeSurvivesABrokenRepository(t *testing.T) {
198 t.Parallel()248 t.Parallel()
199- browse := mocks.NewBrowseService(t)249+ browse := homeBrowse(t)
200 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).250 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201 Return(ports.HistoryPage{}, errors.New("git exploded")).Once()251 Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).252 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
@@ -222,7 +272,7 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
222 views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))272 views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223 }273 }
224 var running, peak atomic.Int32274 var running, peak atomic.Int32
225- browse := mocks.NewBrowseService(t)275+ browse := homeBrowse(t)
226 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(276 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227 func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {277 func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228 cur := running.Add(1)278 cur := running.Add(1)
M internal/adapters/web/mr.go +6 -6
@@ -199,12 +199,12 @@ type MRPage struct {
199199 UpdatedAt string
200200 // Desc is the description rendered as Markdown (as a README: no raw
201201 // HTML, filtered URLs), "" when it is empty.
202- Desc template.HTML
203- Head string
204- HeadShort string
205- MergedShort string
206- MergedHref string
207- MergeMode string
202+ Desc template.HTML
203+ Head string
204+ HeadShort string
205+ MergedShort string
206+ MergedHref string
207+ MergeMode string
208208
209209 Since *SinceNav
210210 SinceNote string
@@ -199,12 +199,12 @@ type MRPage struct {
199 UpdatedAt string199 UpdatedAt string
200 // Desc is the description rendered as Markdown (as a README: no raw200 // Desc is the description rendered as Markdown (as a README: no raw
201 // HTML, filtered URLs), "" when it is empty.201 // HTML, filtered URLs), "" when it is empty.
202- Desc template.HTML202+ Desc template.HTML
203- Head string203+ Head string
204- HeadShort string204+ HeadShort string
205- MergedShort string205+ MergedShort string
206- MergedHref string206+ MergedHref string
207- MergeMode string207+ MergeMode string
208 208
209 Since *SinceNav209 Since *SinceNav
210 SinceNote string210 SinceNote string
M internal/adapters/web/mr.templ +0 -1
@@ -60,7 +60,6 @@ templ mrRows(rows []MRRow, label string) {
6060 <tbody data-rows>
6161 for _, m := range rows {
6262 <tr data-cursor?={ m.Cursor } data-state={ m.State }>
63- <td class="cur" aria-hidden="true"></td>
6463 <td class="c-n mute">{ m.Number }</td>
6564 <td class="c-state">{ m.State }</td>
6665 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
@@ -60,7 +60,6 @@ templ mrRows(rows []MRRow, label string) {
60 <tbody data-rows>60 <tbody data-rows>
61 for _, m := range rows {61 for _, m := range rows {
62 <tr data-cursor?={ m.Cursor } data-state={ m.State }>62 <tr data-cursor?={ m.Cursor } data-state={ m.State }>
63- <td class="cur" aria-hidden="true"></td>
64 <td class="c-n mute">{ m.Number }</td>63 <td class="c-n mute">{ m.Number }</td>
65 <td class="c-state">{ m.State }</td>64 <td class="c-state">{ m.State }</td>
66 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>65 <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
M internal/adapters/web/mr_templ.go +124 -124 generated file: collapsed
M internal/adapters/web/pages.templ +1 -4
@@ -122,7 +122,7 @@ templ Refs(l Layout, p RefsPage) {
122122 </div>
123123 <table class="list refs" aria-label="Branches and tags">
124124 <thead>
125- <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
125+ <tr><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
126126 </thead>
127127 @RefsBody(p)
128128 </table>
@@ -134,7 +134,6 @@ templ Refs(l Layout, p RefsPage) {
134134
135135 templ refRow(kind string, r RefRow) {
136136 <tr data-cursor?={ r.Cursor }>
137- <td class="cur" aria-hidden="true"></td>
138137 <td class="c-kind mute">{ kind }</td>
139138 <td class="c-name">
140139 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -234,7 +233,6 @@ templ Passkeys(l Layout, p PasskeysPage) {
234233 <table class="list passkeys" aria-label="Passkeys">
235234 <thead>
236235 <tr>
237- <th class="cur"></th>
238236 <th class="c-name">Name</th>
239237 <th class="c-when">Created</th>
240238 <th class="c-when">Last used</th>
@@ -245,7 +243,6 @@ templ Passkeys(l Layout, p PasskeysPage) {
245243 <tbody>
246244 for _, r := range p.Rows {
247245 <tr>
248- <td class="cur" aria-hidden="true"></td>
249246 <td class="c-name">{ r.Name }</td>
250247 <td class="c-when nowrap">
251248 <time
@@ -122,7 +122,7 @@ templ Refs(l Layout, p RefsPage) {
122 </div>122 </div>
123 <table class="list refs" aria-label="Branches and tags">123 <table class="list refs" aria-label="Branches and tags">
124 <thead>124 <thead>
125- <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>125+ <tr><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
126 </thead>126 </thead>
127 @RefsBody(p)127 @RefsBody(p)
128 </table>128 </table>
@@ -134,7 +134,6 @@ templ Refs(l Layout, p RefsPage) {
134 134
135 templ refRow(kind string, r RefRow) {135 templ refRow(kind string, r RefRow) {
136 <tr data-cursor?={ r.Cursor }>136 <tr data-cursor?={ r.Cursor }>
137- <td class="cur" aria-hidden="true"></td>
138 <td class="c-kind mute">{ kind }</td>137 <td class="c-kind mute">{ kind }</td>
139 <td class="c-name">138 <td class="c-name">
140 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>139 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
@@ -234,7 +233,6 @@ templ Passkeys(l Layout, p PasskeysPage) {
234 <table class="list passkeys" aria-label="Passkeys">233 <table class="list passkeys" aria-label="Passkeys">
235 <thead>234 <thead>
236 <tr>235 <tr>
237- <th class="cur"></th>
238 <th class="c-name">Name</th>236 <th class="c-name">Name</th>
239 <th class="c-when">Created</th>237 <th class="c-when">Created</th>
240 <th class="c-when">Last used</th>238 <th class="c-when">Last used</th>
@@ -245,7 +243,6 @@ templ Passkeys(l Layout, p PasskeysPage) {
245 <tbody>243 <tbody>
246 for _, r := range p.Rows {244 for _, r := range p.Rows {
247 <tr>245 <tr>
248- <td class="cur" aria-hidden="true"></td>
249 <td class="c-name">{ r.Name }</td>246 <td class="c-name">{ r.Name }</td>
250 <td class="c-when nowrap">247 <td class="c-when nowrap">
251 <time248 <time
M internal/adapters/web/pages_templ.go +39 -39 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +12 -8
@@ -14,10 +14,9 @@
1414 & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); }
1515 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
1616 & tbody tr:first-child td { border-top: 0; }
17- & th.cur, & td.cur { width: 3px; padding: 0; }
1817 & tbody tr:hover { background: var(--bg-raised); }
19- & tbody tr[data-cursor] { background: var(--bg-raised); }
20- & tbody tr[data-cursor] .cur { background: var(--ink-hi); }
18+ /* the cursor: the row raised, framed by one even line */
19+ & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; }
2120 & tbody tr[hidden] { display: none; }
2221 & td > a { text-decoration: none; }
2322 & td > a:first-child { color: var(--ink-hi); }
@@ -54,7 +53,7 @@
5453 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5554 }
5655 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
57- & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }
56+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); }
5857 & .repo[hidden] { display: none; }
5958 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
6059 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
@@ -66,9 +65,14 @@
6665 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6766 }
6867 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69- & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }
70- & .repo-meta time { color: var(--ink-dim); }
71- & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
68+ /* the figures: a small label over a mono value, side by side */
69+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; }
70+ & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
71+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
72+ & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
73+ & .repo-stats dd.mute { color: var(--ink-mute); }
74+ & .repo-stats dd a { color: inherit; text-decoration: none; }
75+ & .repo-stats dd a:hover { text-decoration: underline; }
7276 & .repo-ci {
7377 grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
7478 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
@@ -168,7 +172,7 @@
168172 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
169173 }
170174 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
171- & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); }
175+ & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; }
172176 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
173177 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
174178 & .t-twig {
@@ -14,10 +14,9 @@
14 & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); }14 & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); }
15 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }15 & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
16 & tbody tr:first-child td { border-top: 0; }16 & tbody tr:first-child td { border-top: 0; }
17- & th.cur, & td.cur { width: 3px; padding: 0; }
18 & tbody tr:hover { background: var(--bg-raised); }17 & tbody tr:hover { background: var(--bg-raised); }
19- & tbody tr[data-cursor] { background: var(--bg-raised); }18+ /* the cursor: the row raised, framed by one even line */
20- & tbody tr[data-cursor] .cur { background: var(--ink-hi); }19+ & tbody tr[data-cursor] { background: var(--bg-raised); outline: 1px solid var(--ink-mute); outline-offset: -1px; }
21 & tbody tr[hidden] { display: none; }20 & tbody tr[hidden] { display: none; }
22 & td > a { text-decoration: none; }21 & td > a { text-decoration: none; }
23 & td > a:first-child { color: var(--ink-hi); }22 & td > a:first-child { color: var(--ink-hi); }
@@ -54,7 +53,7 @@
54 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);53 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
55 }54 }
56 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
57- & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); box-shadow: inset 3px 0 0 var(--ink-hi); }56+ & .repo[data-cursor] { background: var(--bg-raised); border-color: var(--ink-mute); }
58 & .repo[hidden] { display: none; }57 & .repo[hidden] { display: none; }
59 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }58 & .repo-main { grid-area: main; display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
60 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }59 & .repo-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s1) var(--s2); font-family: var(--mono); font-size: 17px; line-height: 26px; overflow-wrap: anywhere; }
@@ -66,9 +65,14 @@
66 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;65 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
67 }66 }
68 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
69- & .repo-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1) var(--s4); font-size: var(--fs-s); color: var(--ink-mute); }68+ /* the figures: a small label over a mono value, side by side */
70- & .repo-meta time { color: var(--ink-dim); }69+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: var(--s1) 0 0; }
71- & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }70+ & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
71+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
72+ & .repo-stats dd { margin: 0; font-family: var(--mono); font-size: 15px; line-height: 20px; color: var(--ink-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
73+ & .repo-stats dd.mute { color: var(--ink-mute); }
74+ & .repo-stats dd a { color: inherit; text-decoration: none; }
75+ & .repo-stats dd a:hover { text-decoration: underline; }
72 & .repo-ci {76 & .repo-ci {
73 grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);77 grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
74 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;78 font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
@@ -168,7 +172,7 @@
168 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);172 min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
169 }173 }
170 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }174 & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
171- & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); }175+ & > li[data-cursor] { outline: 1px solid var(--ink-mute); outline-offset: -1px; }
172 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }176 & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
173 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }177 & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
174 & .t-twig {178 & .t-twig {
M internal/adapters/web/static/css/50-code.css +3 -4
@@ -119,11 +119,11 @@
119119 a pixel below the row, enough to give the list a scroll bar */
120120 display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center;
121121 height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2));
122- border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);
122+ border-radius: 4px; border: 1px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);
123123 }
124124 & a:hover { background: var(--bg-raised); }
125125 & a:hover .p { text-decoration: underline; }
126- & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); }
126+ & a[aria-current="true"] { background: var(--row); border-color: var(--ink-mute); }
127127 & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }
128128 & .st { color: var(--ink-mute); font-weight: bold; }
129129 & .st[data-st="A"] { color: var(--teal); }
@@ -158,8 +158,7 @@
158158 .rail .list.commits {
159159 border: 0; border-radius: 0; background: none; table-layout: auto;
160160 & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; }
161- & td.cur { width: 3px; padding: 0; }
162- & td:nth-child(2) { padding-left: var(--s3); }
161+ & td:first-child { padding-left: var(--s3); }
163162 & td:last-child { padding-right: var(--s4); }
164163 & .c-who { display: none; }
165164 & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); }
@@ -119,11 +119,11 @@
119 a pixel below the row, enough to give the list a scroll bar */119 a pixel below the row, enough to give the list a scroll bar */
120 display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center;120 display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: center;
121 height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2));121 height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2));
122- border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);122+ border-radius: 4px; border: 1px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink);
123 }123 }
124 & a:hover { background: var(--bg-raised); }124 & a:hover { background: var(--bg-raised); }
125 & a:hover .p { text-decoration: underline; }125 & a:hover .p { text-decoration: underline; }
126- & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); }126+ & a[aria-current="true"] { background: var(--row); border-color: var(--ink-mute); }
127 & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }127 & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); }
128 & .st { color: var(--ink-mute); font-weight: bold; }128 & .st { color: var(--ink-mute); font-weight: bold; }
129 & .st[data-st="A"] { color: var(--teal); }129 & .st[data-st="A"] { color: var(--teal); }
@@ -158,8 +158,7 @@
158 .rail .list.commits {158 .rail .list.commits {
159 border: 0; border-radius: 0; background: none; table-layout: auto;159 border: 0; border-radius: 0; background: none; table-layout: auto;
160 & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; }160 & td { height: 34px; padding: 0 var(--s2); width: 1%; white-space: nowrap; }
161- & td.cur { width: 3px; padding: 0; }161+ & td:first-child { padding-left: var(--s3); }
162- & td:nth-child(2) { padding-left: var(--s3); }
163 & td:last-child { padding-right: var(--s4); }162 & td:last-child { padding-right: var(--s4); }
164 & .c-who { display: none; }163 & .c-who { display: none; }
165 & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); }164 & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); }
M internal/adapters/web/view.go +19 -7
@@ -76,14 +76,17 @@ type Link struct{ Label, Href string }
7676
7777 // HomeRow is one repository in the home list.
7878 type HomeRow struct {
79- ID string
80- Name string
81- Href string
82- Desc string
83- Pushed string
84- PushedAt string // RFC 3339, "" when never pushed
85- Branch string
79+ ID string
80+ Name string
81+ Href string
82+ Desc string
83+ // Active is when the repository last changed (its last push or merge),
84+ // ActiveAt its RFC 3339 form, "" when it never got a commit.
85+ Active string
86+ ActiveAt string
8687 Cursor bool
88+ // Stats are the figures of the row; nil when they could not be read.
89+ Stats *HomeStats
8790 // Private marks a private repository; only set for a signed-in visitor.
8891 Private bool
8992 // Fresh marks a row that a push just updated (live updates only).
@@ -97,6 +100,15 @@ type HomeRow struct {
97100 CI *HomeCI
98101 }
99102
103+// HomeStats are the figures of a home row: commits of the default branch,
104+// those of the last 30 days, and the latest tag.
105+type HomeStats struct {
106+ Commits, Recent string
107+ // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108+ // tells when it was made.
109+ Tag, TagHref, TagWhen string
110+}
111+
100112 // HomeCI is the state of the latest run of the default branch of a repository.
101113 type HomeCI struct {
102114 Href string
@@ -76,14 +76,17 @@ type Link struct{ Label, Href string }
76 76
77 // HomeRow is one repository in the home list.77 // HomeRow is one repository in the home list.
78 type HomeRow struct {78 type HomeRow struct {
79- ID string79+ ID string
80- Name string80+ Name string
81- Href string81+ Href string
82- Desc string82+ Desc string
83- Pushed string83+ // Active is when the repository last changed (its last push or merge),
84- PushedAt string // RFC 3339, "" when never pushed84+ // ActiveAt its RFC 3339 form, "" when it never got a commit.
85- Branch string85+ Active string
86+ ActiveAt string
86 Cursor bool87 Cursor bool
88+ // Stats are the figures of the row; nil when they could not be read.
89+ Stats *HomeStats
87 // Private marks a private repository; only set for a signed-in visitor.90 // Private marks a private repository; only set for a signed-in visitor.
88 Private bool91 Private bool
89 // Fresh marks a row that a push just updated (live updates only).92 // Fresh marks a row that a push just updated (live updates only).
@@ -97,6 +100,15 @@ type HomeRow struct {
97 CI *HomeCI100 CI *HomeCI
98 }101 }
99 102
103+// HomeStats are the figures of a home row: commits of the default branch,
104+// those of the last 30 days, and the latest tag.
105+type HomeStats struct {
106+ Commits, Recent string
107+ // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108+ // tells when it was made.
109+ Tag, TagHref, TagWhen string
110+}
111+
100 // HomeCI is the state of the latest run of the default branch of a repository.112 // HomeCI is the state of the latest run of the default branch of a repository.
101 type HomeCI struct {113 type HomeCI struct {
102 Href string114 Href string
M internal/app/testdata/web/commits-page2.html +0 -4
@@ -108,8 +108,6 @@
108108 <table class="list commits" aria-label="Commits">
109109 <tbody data-rows>
110110 <tr id="c-SHA7" data-cursor>
111-<td class="cur" aria-hidden="true">
112-</td>
113111 <td class="c-sha mute">SHA7</td>
114112 <td class="c-subject">
115113 <a href="/long/commit/SHA40">commit number 1</a>
@@ -122,8 +120,6 @@
122120 </td>
123121 </tr>
124122 <tr id="c-SHA7">
125-<td class="cur" aria-hidden="true">
126-</td>
127123 <td class="c-sha mute">SHA7</td>
128124 <td class="c-subject">
129125 <a href="/long/commit/SHA40">commit number 0</a>
@@ -108,8 +108,6 @@
108 <table class="list commits" aria-label="Commits">108 <table class="list commits" aria-label="Commits">
109 <tbody data-rows>109 <tbody data-rows>
110 <tr id="c-SHA7" data-cursor>110 <tr id="c-SHA7" data-cursor>
111-<td class="cur" aria-hidden="true">
112-</td>
113 <td class="c-sha mute">SHA7</td>111 <td class="c-sha mute">SHA7</td>
114 <td class="c-subject">112 <td class="c-subject">
115 <a href="/long/commit/SHA40">commit number 1</a>113 <a href="/long/commit/SHA40">commit number 1</a>
@@ -122,8 +120,6 @@
122 </td>120 </td>
123 </tr>121 </tr>
124 <tr id="c-SHA7">122 <tr id="c-SHA7">
125-<td class="cur" aria-hidden="true">
126-</td>
127 <td class="c-sha mute">SHA7</td>123 <td class="c-sha mute">SHA7</td>
128 <td class="c-subject">124 <td class="c-subject">
129 <a href="/long/commit/SHA40">commit number 0</a>125 <a href="/long/commit/SHA40">commit number 0</a>
M internal/app/testdata/web/commits-path.html +0 -4
@@ -112,8 +112,6 @@
112112 <table class="list commits" aria-label="Commits">
113113 <tbody data-rows>
114114 <tr id="c-SHA7" data-cursor>
115-<td class="cur" aria-hidden="true">
116-</td>
117115 <td class="c-sha mute">SHA7</td>
118116 <td class="c-subject">
119117 <a href="/long/commit/SHA40">commit number 30</a>
@@ -135,8 +133,6 @@
135133 <table class="list commits" aria-label="Commits">
136134 <tbody data-rows>
137135 <tr id="c-SHA7">
138-<td class="cur" aria-hidden="true">
139-</td>
140136 <td class="c-sha mute">SHA7</td>
141137 <td class="c-subject">
142138 <a href="/long/commit/SHA40">commit number 3</a>
@@ -112,8 +112,6 @@
112 <table class="list commits" aria-label="Commits">112 <table class="list commits" aria-label="Commits">
113 <tbody data-rows>113 <tbody data-rows>
114 <tr id="c-SHA7" data-cursor>114 <tr id="c-SHA7" data-cursor>
115-<td class="cur" aria-hidden="true">
116-</td>
117 <td class="c-sha mute">SHA7</td>115 <td class="c-sha mute">SHA7</td>
118 <td class="c-subject">116 <td class="c-subject">
119 <a href="/long/commit/SHA40">commit number 30</a>117 <a href="/long/commit/SHA40">commit number 30</a>
@@ -135,8 +133,6 @@
135 <table class="list commits" aria-label="Commits">133 <table class="list commits" aria-label="Commits">
136 <tbody data-rows>134 <tbody data-rows>
137 <tr id="c-SHA7">135 <tr id="c-SHA7">
138-<td class="cur" aria-hidden="true">
139-</td>
140 <td class="c-sha mute">SHA7</td>136 <td class="c-sha mute">SHA7</td>
141 <td class="c-subject">137 <td class="c-subject">
142 <a href="/long/commit/SHA40">commit number 3</a>138 <a href="/long/commit/SHA40">commit number 3</a>
M internal/app/testdata/web/commits-root.html +0 -100
@@ -110,8 +110,6 @@
110110 <table class="list commits" aria-label="Commits">
111111 <tbody data-rows>
112112 <tr id="c-SHA7" data-cursor>
113-<td class="cur" aria-hidden="true">
114-</td>
115113 <td class="c-sha mute">SHA7</td>
116114 <td class="c-subject">
117115 <a href="/long/commit/SHA40">commit number 51</a>
@@ -125,8 +123,6 @@
125123 </td>
126124 </tr>
127125 <tr id="c-SHA7">
128-<td class="cur" aria-hidden="true">
129-</td>
130126 <td class="c-sha mute">SHA7</td>
131127 <td class="c-subject">
132128 <a href="/long/commit/SHA40">commit number 50</a>
@@ -148,8 +144,6 @@
148144 <table class="list commits" aria-label="Commits">
149145 <tbody data-rows>
150146 <tr id="c-SHA7">
151-<td class="cur" aria-hidden="true">
152-</td>
153147 <td class="c-sha mute">SHA7</td>
154148 <td class="c-subject">
155149 <a href="/long/commit/SHA40">commit number 49</a>
@@ -162,8 +156,6 @@
162156 </td>
163157 </tr>
164158 <tr id="c-SHA7">
165-<td class="cur" aria-hidden="true">
166-</td>
167159 <td class="c-sha mute">SHA7</td>
168160 <td class="c-subject">
169161 <a href="/long/commit/SHA40">commit number 48</a>
@@ -185,8 +177,6 @@
185177 <table class="list commits" aria-label="Commits">
186178 <tbody data-rows>
187179 <tr id="c-SHA7">
188-<td class="cur" aria-hidden="true">
189-</td>
190180 <td class="c-sha mute">SHA7</td>
191181 <td class="c-subject">
192182 <a href="/long/commit/SHA40">commit number 47</a>
@@ -199,8 +189,6 @@
199189 </td>
200190 </tr>
201191 <tr id="c-SHA7">
202-<td class="cur" aria-hidden="true">
203-</td>
204192 <td class="c-sha mute">SHA7</td>
205193 <td class="c-subject">
206194 <a href="/long/commit/SHA40">commit number 46</a>
@@ -222,8 +210,6 @@
222210 <table class="list commits" aria-label="Commits">
223211 <tbody data-rows>
224212 <tr id="c-SHA7">
225-<td class="cur" aria-hidden="true">
226-</td>
227213 <td class="c-sha mute">SHA7</td>
228214 <td class="c-subject">
229215 <a href="/long/commit/SHA40">commit number 45</a>
@@ -236,8 +222,6 @@
236222 </td>
237223 </tr>
238224 <tr id="c-SHA7">
239-<td class="cur" aria-hidden="true">
240-</td>
241225 <td class="c-sha mute">SHA7</td>
242226 <td class="c-subject">
243227 <a href="/long/commit/SHA40">commit number 44</a>
@@ -259,8 +243,6 @@
259243 <table class="list commits" aria-label="Commits">
260244 <tbody data-rows>
261245 <tr id="c-SHA7">
262-<td class="cur" aria-hidden="true">
263-</td>
264246 <td class="c-sha mute">SHA7</td>
265247 <td class="c-subject">
266248 <a href="/long/commit/SHA40">commit number 43</a>
@@ -273,8 +255,6 @@
273255 </td>
274256 </tr>
275257 <tr id="c-SHA7">
276-<td class="cur" aria-hidden="true">
277-</td>
278258 <td class="c-sha mute">SHA7</td>
279259 <td class="c-subject">
280260 <a href="/long/commit/SHA40">commit number 42</a>
@@ -296,8 +276,6 @@
296276 <table class="list commits" aria-label="Commits">
297277 <tbody data-rows>
298278 <tr id="c-SHA7">
299-<td class="cur" aria-hidden="true">
300-</td>
301279 <td class="c-sha mute">SHA7</td>
302280 <td class="c-subject">
303281 <a href="/long/commit/SHA40">commit number 41</a>
@@ -310,8 +288,6 @@
310288 </td>
311289 </tr>
312290 <tr id="c-SHA7">
313-<td class="cur" aria-hidden="true">
314-</td>
315291 <td class="c-sha mute">SHA7</td>
316292 <td class="c-subject">
317293 <a href="/long/commit/SHA40">commit number 40</a>
@@ -333,8 +309,6 @@
333309 <table class="list commits" aria-label="Commits">
334310 <tbody data-rows>
335311 <tr id="c-SHA7">
336-<td class="cur" aria-hidden="true">
337-</td>
338312 <td class="c-sha mute">SHA7</td>
339313 <td class="c-subject">
340314 <a href="/long/commit/SHA40">commit number 39</a>
@@ -347,8 +321,6 @@
347321 </td>
348322 </tr>
349323 <tr id="c-SHA7">
350-<td class="cur" aria-hidden="true">
351-</td>
352324 <td class="c-sha mute">SHA7</td>
353325 <td class="c-subject">
354326 <a href="/long/commit/SHA40">commit number 38</a>
@@ -370,8 +342,6 @@
370342 <table class="list commits" aria-label="Commits">
371343 <tbody data-rows>
372344 <tr id="c-SHA7">
373-<td class="cur" aria-hidden="true">
374-</td>
375345 <td class="c-sha mute">SHA7</td>
376346 <td class="c-subject">
377347 <a href="/long/commit/SHA40">commit number 37</a>
@@ -384,8 +354,6 @@
384354 </td>
385355 </tr>
386356 <tr id="c-SHA7">
387-<td class="cur" aria-hidden="true">
388-</td>
389357 <td class="c-sha mute">SHA7</td>
390358 <td class="c-subject">
391359 <a href="/long/commit/SHA40">commit number 36</a>
@@ -407,8 +375,6 @@
407375 <table class="list commits" aria-label="Commits">
408376 <tbody data-rows>
409377 <tr id="c-SHA7">
410-<td class="cur" aria-hidden="true">
411-</td>
412378 <td class="c-sha mute">SHA7</td>
413379 <td class="c-subject">
414380 <a href="/long/commit/SHA40">commit number 35</a>
@@ -421,8 +387,6 @@
421387 </td>
422388 </tr>
423389 <tr id="c-SHA7">
424-<td class="cur" aria-hidden="true">
425-</td>
426390 <td class="c-sha mute">SHA7</td>
427391 <td class="c-subject">
428392 <a href="/long/commit/SHA40">commit number 34</a>
@@ -444,8 +408,6 @@
444408 <table class="list commits" aria-label="Commits">
445409 <tbody data-rows>
446410 <tr id="c-SHA7">
447-<td class="cur" aria-hidden="true">
448-</td>
449411 <td class="c-sha mute">SHA7</td>
450412 <td class="c-subject">
451413 <a href="/long/commit/SHA40">commit number 33</a>
@@ -458,8 +420,6 @@
458420 </td>
459421 </tr>
460422 <tr id="c-SHA7">
461-<td class="cur" aria-hidden="true">
462-</td>
463423 <td class="c-sha mute">SHA7</td>
464424 <td class="c-subject">
465425 <a href="/long/commit/SHA40">commit number 32</a>
@@ -481,8 +441,6 @@
481441 <table class="list commits" aria-label="Commits">
482442 <tbody data-rows>
483443 <tr id="c-SHA7">
484-<td class="cur" aria-hidden="true">
485-</td>
486444 <td class="c-sha mute">SHA7</td>
487445 <td class="c-subject">
488446 <a href="/long/commit/SHA40">commit number 31</a>
@@ -495,8 +453,6 @@
495453 </td>
496454 </tr>
497455 <tr id="c-SHA7">
498-<td class="cur" aria-hidden="true">
499-</td>
500456 <td class="c-sha mute">SHA7</td>
501457 <td class="c-subject">
502458 <a href="/long/commit/SHA40">commit number 30</a>
@@ -518,8 +474,6 @@
518474 <table class="list commits" aria-label="Commits">
519475 <tbody data-rows>
520476 <tr id="c-SHA7">
521-<td class="cur" aria-hidden="true">
522-</td>
523477 <td class="c-sha mute">SHA7</td>
524478 <td class="c-subject">
525479 <a href="/long/commit/SHA40">commit number 29</a>
@@ -532,8 +486,6 @@
532486 </td>
533487 </tr>
534488 <tr id="c-SHA7">
535-<td class="cur" aria-hidden="true">
536-</td>
537489 <td class="c-sha mute">SHA7</td>
538490 <td class="c-subject">
539491 <a href="/long/commit/SHA40">commit number 28</a>
@@ -555,8 +507,6 @@
555507 <table class="list commits" aria-label="Commits">
556508 <tbody data-rows>
557509 <tr id="c-SHA7">
558-<td class="cur" aria-hidden="true">
559-</td>
560510 <td class="c-sha mute">SHA7</td>
561511 <td class="c-subject">
562512 <a href="/long/commit/SHA40">commit number 27</a>
@@ -569,8 +519,6 @@
569519 </td>
570520 </tr>
571521 <tr id="c-SHA7">
572-<td class="cur" aria-hidden="true">
573-</td>
574522 <td class="c-sha mute">SHA7</td>
575523 <td class="c-subject">
576524 <a href="/long/commit/SHA40">commit number 26</a>
@@ -592,8 +540,6 @@
592540 <table class="list commits" aria-label="Commits">
593541 <tbody data-rows>
594542 <tr id="c-SHA7">
595-<td class="cur" aria-hidden="true">
596-</td>
597543 <td class="c-sha mute">SHA7</td>
598544 <td class="c-subject">
599545 <a href="/long/commit/SHA40">commit number 25</a>
@@ -606,8 +552,6 @@
606552 </td>
607553 </tr>
608554 <tr id="c-SHA7">
609-<td class="cur" aria-hidden="true">
610-</td>
611555 <td class="c-sha mute">SHA7</td>
612556 <td class="c-subject">
613557 <a href="/long/commit/SHA40">commit number 24</a>
@@ -629,8 +573,6 @@
629573 <table class="list commits" aria-label="Commits">
630574 <tbody data-rows>
631575 <tr id="c-SHA7">
632-<td class="cur" aria-hidden="true">
633-</td>
634576 <td class="c-sha mute">SHA7</td>
635577 <td class="c-subject">
636578 <a href="/long/commit/SHA40">commit number 23</a>
@@ -643,8 +585,6 @@
643585 </td>
644586 </tr>
645587 <tr id="c-SHA7">
646-<td class="cur" aria-hidden="true">
647-</td>
648588 <td class="c-sha mute">SHA7</td>
649589 <td class="c-subject">
650590 <a href="/long/commit/SHA40">commit number 22</a>
@@ -666,8 +606,6 @@
666606 <table class="list commits" aria-label="Commits">
667607 <tbody data-rows>
668608 <tr id="c-SHA7">
669-<td class="cur" aria-hidden="true">
670-</td>
671609 <td class="c-sha mute">SHA7</td>
672610 <td class="c-subject">
673611 <a href="/long/commit/SHA40">commit number 21</a>
@@ -680,8 +618,6 @@
680618 </td>
681619 </tr>
682620 <tr id="c-SHA7">
683-<td class="cur" aria-hidden="true">
684-</td>
685621 <td class="c-sha mute">SHA7</td>
686622 <td class="c-subject">
687623 <a href="/long/commit/SHA40">commit number 20</a>
@@ -703,8 +639,6 @@
703639 <table class="list commits" aria-label="Commits">
704640 <tbody data-rows>
705641 <tr id="c-SHA7">
706-<td class="cur" aria-hidden="true">
707-</td>
708642 <td class="c-sha mute">SHA7</td>
709643 <td class="c-subject">
710644 <a href="/long/commit/SHA40">commit number 19</a>
@@ -717,8 +651,6 @@
717651 </td>
718652 </tr>
719653 <tr id="c-SHA7">
720-<td class="cur" aria-hidden="true">
721-</td>
722654 <td class="c-sha mute">SHA7</td>
723655 <td class="c-subject">
724656 <a href="/long/commit/SHA40">commit number 18</a>
@@ -740,8 +672,6 @@
740672 <table class="list commits" aria-label="Commits">
741673 <tbody data-rows>
742674 <tr id="c-SHA7">
743-<td class="cur" aria-hidden="true">
744-</td>
745675 <td class="c-sha mute">SHA7</td>
746676 <td class="c-subject">
747677 <a href="/long/commit/SHA40">commit number 17</a>
@@ -754,8 +684,6 @@
754684 </td>
755685 </tr>
756686 <tr id="c-SHA7">
757-<td class="cur" aria-hidden="true">
758-</td>
759687 <td class="c-sha mute">SHA7</td>
760688 <td class="c-subject">
761689 <a href="/long/commit/SHA40">commit number 16</a>
@@ -777,8 +705,6 @@
777705 <table class="list commits" aria-label="Commits">
778706 <tbody data-rows>
779707 <tr id="c-SHA7">
780-<td class="cur" aria-hidden="true">
781-</td>
782708 <td class="c-sha mute">SHA7</td>
783709 <td class="c-subject">
784710 <a href="/long/commit/SHA40">commit number 15</a>
@@ -791,8 +717,6 @@
791717 </td>
792718 </tr>
793719 <tr id="c-SHA7">
794-<td class="cur" aria-hidden="true">
795-</td>
796720 <td class="c-sha mute">SHA7</td>
797721 <td class="c-subject">
798722 <a href="/long/commit/SHA40">commit number 14</a>
@@ -814,8 +738,6 @@
814738 <table class="list commits" aria-label="Commits">
815739 <tbody data-rows>
816740 <tr id="c-SHA7">
817-<td class="cur" aria-hidden="true">
818-</td>
819741 <td class="c-sha mute">SHA7</td>
820742 <td class="c-subject">
821743 <a href="/long/commit/SHA40">commit number 13</a>
@@ -828,8 +750,6 @@
828750 </td>
829751 </tr>
830752 <tr id="c-SHA7">
831-<td class="cur" aria-hidden="true">
832-</td>
833753 <td class="c-sha mute">SHA7</td>
834754 <td class="c-subject">
835755 <a href="/long/commit/SHA40">commit number 12</a>
@@ -851,8 +771,6 @@
851771 <table class="list commits" aria-label="Commits">
852772 <tbody data-rows>
853773 <tr id="c-SHA7">
854-<td class="cur" aria-hidden="true">
855-</td>
856774 <td class="c-sha mute">SHA7</td>
857775 <td class="c-subject">
858776 <a href="/long/commit/SHA40">commit number 11</a>
@@ -865,8 +783,6 @@
865783 </td>
866784 </tr>
867785 <tr id="c-SHA7">
868-<td class="cur" aria-hidden="true">
869-</td>
870786 <td class="c-sha mute">SHA7</td>
871787 <td class="c-subject">
872788 <a href="/long/commit/SHA40">commit number 10</a>
@@ -888,8 +804,6 @@
888804 <table class="list commits" aria-label="Commits">
889805 <tbody data-rows>
890806 <tr id="c-SHA7">
891-<td class="cur" aria-hidden="true">
892-</td>
893807 <td class="c-sha mute">SHA7</td>
894808 <td class="c-subject">
895809 <a href="/long/commit/SHA40">commit number 9</a>
@@ -902,8 +816,6 @@
902816 </td>
903817 </tr>
904818 <tr id="c-SHA7">
905-<td class="cur" aria-hidden="true">
906-</td>
907819 <td class="c-sha mute">SHA7</td>
908820 <td class="c-subject">
909821 <a href="/long/commit/SHA40">commit number 8</a>
@@ -925,8 +837,6 @@
925837 <table class="list commits" aria-label="Commits">
926838 <tbody data-rows>
927839 <tr id="c-SHA7">
928-<td class="cur" aria-hidden="true">
929-</td>
930840 <td class="c-sha mute">SHA7</td>
931841 <td class="c-subject">
932842 <a href="/long/commit/SHA40">commit number 7</a>
@@ -939,8 +849,6 @@
939849 </td>
940850 </tr>
941851 <tr id="c-SHA7">
942-<td class="cur" aria-hidden="true">
943-</td>
944852 <td class="c-sha mute">SHA7</td>
945853 <td class="c-subject">
946854 <a href="/long/commit/SHA40">commit number 6</a>
@@ -962,8 +870,6 @@
962870 <table class="list commits" aria-label="Commits">
963871 <tbody data-rows>
964872 <tr id="c-SHA7">
965-<td class="cur" aria-hidden="true">
966-</td>
967873 <td class="c-sha mute">SHA7</td>
968874 <td class="c-subject">
969875 <a href="/long/commit/SHA40">commit number 5</a>
@@ -976,8 +882,6 @@
976882 </td>
977883 </tr>
978884 <tr id="c-SHA7">
979-<td class="cur" aria-hidden="true">
980-</td>
981885 <td class="c-sha mute">SHA7</td>
982886 <td class="c-subject">
983887 <a href="/long/commit/SHA40">commit number 4</a>
@@ -999,8 +903,6 @@
999903 <table class="list commits" aria-label="Commits">
1000904 <tbody data-rows>
1001905 <tr id="c-SHA7">
1002-<td class="cur" aria-hidden="true">
1003-</td>
1004906 <td class="c-sha mute">SHA7</td>
1005907 <td class="c-subject">
1006908 <a href="/long/commit/SHA40">commit number 3</a>
@@ -1013,8 +915,6 @@
1013915 </td>
1014916 </tr>
1015917 <tr id="c-SHA7">
1016-<td class="cur" aria-hidden="true">
1017-</td>
1018918 <td class="c-sha mute">SHA7</td>
1019919 <td class="c-subject">
1020920 <a href="/long/commit/SHA40">commit number 2</a>
@@ -110,8 +110,6 @@
110 <table class="list commits" aria-label="Commits">110 <table class="list commits" aria-label="Commits">
111 <tbody data-rows>111 <tbody data-rows>
112 <tr id="c-SHA7" data-cursor>112 <tr id="c-SHA7" data-cursor>
113-<td class="cur" aria-hidden="true">
114-</td>
115 <td class="c-sha mute">SHA7</td>113 <td class="c-sha mute">SHA7</td>
116 <td class="c-subject">114 <td class="c-subject">
117 <a href="/long/commit/SHA40">commit number 51</a>115 <a href="/long/commit/SHA40">commit number 51</a>
@@ -125,8 +123,6 @@
125 </td>123 </td>
126 </tr>124 </tr>
127 <tr id="c-SHA7">125 <tr id="c-SHA7">
128-<td class="cur" aria-hidden="true">
129-</td>
130 <td class="c-sha mute">SHA7</td>126 <td class="c-sha mute">SHA7</td>
131 <td class="c-subject">127 <td class="c-subject">
132 <a href="/long/commit/SHA40">commit number 50</a>128 <a href="/long/commit/SHA40">commit number 50</a>
@@ -148,8 +144,6 @@
148 <table class="list commits" aria-label="Commits">144 <table class="list commits" aria-label="Commits">
149 <tbody data-rows>145 <tbody data-rows>
150 <tr id="c-SHA7">146 <tr id="c-SHA7">
151-<td class="cur" aria-hidden="true">
152-</td>
153 <td class="c-sha mute">SHA7</td>147 <td class="c-sha mute">SHA7</td>
154 <td class="c-subject">148 <td class="c-subject">
155 <a href="/long/commit/SHA40">commit number 49</a>149 <a href="/long/commit/SHA40">commit number 49</a>
@@ -162,8 +156,6 @@
162 </td>156 </td>
163 </tr>157 </tr>
164 <tr id="c-SHA7">158 <tr id="c-SHA7">
165-<td class="cur" aria-hidden="true">
166-</td>
167 <td class="c-sha mute">SHA7</td>159 <td class="c-sha mute">SHA7</td>
168 <td class="c-subject">160 <td class="c-subject">
169 <a href="/long/commit/SHA40">commit number 48</a>161 <a href="/long/commit/SHA40">commit number 48</a>
@@ -185,8 +177,6 @@
185 <table class="list commits" aria-label="Commits">177 <table class="list commits" aria-label="Commits">
186 <tbody data-rows>178 <tbody data-rows>
187 <tr id="c-SHA7">179 <tr id="c-SHA7">
188-<td class="cur" aria-hidden="true">
189-</td>
190 <td class="c-sha mute">SHA7</td>180 <td class="c-sha mute">SHA7</td>
191 <td class="c-subject">181 <td class="c-subject">
192 <a href="/long/commit/SHA40">commit number 47</a>182 <a href="/long/commit/SHA40">commit number 47</a>
@@ -199,8 +189,6 @@
199 </td>189 </td>
200 </tr>190 </tr>
201 <tr id="c-SHA7">191 <tr id="c-SHA7">
202-<td class="cur" aria-hidden="true">
203-</td>
204 <td class="c-sha mute">SHA7</td>192 <td class="c-sha mute">SHA7</td>
205 <td class="c-subject">193 <td class="c-subject">
206 <a href="/long/commit/SHA40">commit number 46</a>194 <a href="/long/commit/SHA40">commit number 46</a>
@@ -222,8 +210,6 @@
222 <table class="list commits" aria-label="Commits">210 <table class="list commits" aria-label="Commits">
223 <tbody data-rows>211 <tbody data-rows>
224 <tr id="c-SHA7">212 <tr id="c-SHA7">
225-<td class="cur" aria-hidden="true">
226-</td>
227 <td class="c-sha mute">SHA7</td>213 <td class="c-sha mute">SHA7</td>
228 <td class="c-subject">214 <td class="c-subject">
229 <a href="/long/commit/SHA40">commit number 45</a>215 <a href="/long/commit/SHA40">commit number 45</a>
@@ -236,8 +222,6 @@
236 </td>222 </td>
237 </tr>223 </tr>
238 <tr id="c-SHA7">224 <tr id="c-SHA7">
239-<td class="cur" aria-hidden="true">
240-</td>
241 <td class="c-sha mute">SHA7</td>225 <td class="c-sha mute">SHA7</td>
242 <td class="c-subject">226 <td class="c-subject">
243 <a href="/long/commit/SHA40">commit number 44</a>227 <a href="/long/commit/SHA40">commit number 44</a>
@@ -259,8 +243,6 @@
259 <table class="list commits" aria-label="Commits">243 <table class="list commits" aria-label="Commits">
260 <tbody data-rows>244 <tbody data-rows>
261 <tr id="c-SHA7">245 <tr id="c-SHA7">
262-<td class="cur" aria-hidden="true">
263-</td>
264 <td class="c-sha mute">SHA7</td>246 <td class="c-sha mute">SHA7</td>
265 <td class="c-subject">247 <td class="c-subject">
266 <a href="/long/commit/SHA40">commit number 43</a>248 <a href="/long/commit/SHA40">commit number 43</a>
@@ -273,8 +255,6 @@
273 </td>255 </td>
274 </tr>256 </tr>
275 <tr id="c-SHA7">257 <tr id="c-SHA7">
276-<td class="cur" aria-hidden="true">
277-</td>
278 <td class="c-sha mute">SHA7</td>258 <td class="c-sha mute">SHA7</td>
279 <td class="c-subject">259 <td class="c-subject">
280 <a href="/long/commit/SHA40">commit number 42</a>260 <a href="/long/commit/SHA40">commit number 42</a>
@@ -296,8 +276,6 @@
296 <table class="list commits" aria-label="Commits">276 <table class="list commits" aria-label="Commits">
297 <tbody data-rows>277 <tbody data-rows>
298 <tr id="c-SHA7">278 <tr id="c-SHA7">
299-<td class="cur" aria-hidden="true">
300-</td>
301 <td class="c-sha mute">SHA7</td>279 <td class="c-sha mute">SHA7</td>
302 <td class="c-subject">280 <td class="c-subject">
303 <a href="/long/commit/SHA40">commit number 41</a>281 <a href="/long/commit/SHA40">commit number 41</a>
@@ -310,8 +288,6 @@
310 </td>288 </td>
311 </tr>289 </tr>
312 <tr id="c-SHA7">290 <tr id="c-SHA7">
313-<td class="cur" aria-hidden="true">
314-</td>
315 <td class="c-sha mute">SHA7</td>291 <td class="c-sha mute">SHA7</td>
316 <td class="c-subject">292 <td class="c-subject">
317 <a href="/long/commit/SHA40">commit number 40</a>293 <a href="/long/commit/SHA40">commit number 40</a>
@@ -333,8 +309,6 @@
333 <table class="list commits" aria-label="Commits">309 <table class="list commits" aria-label="Commits">
334 <tbody data-rows>310 <tbody data-rows>
335 <tr id="c-SHA7">311 <tr id="c-SHA7">
336-<td class="cur" aria-hidden="true">
337-</td>
338 <td class="c-sha mute">SHA7</td>312 <td class="c-sha mute">SHA7</td>
339 <td class="c-subject">313 <td class="c-subject">
340 <a href="/long/commit/SHA40">commit number 39</a>314 <a href="/long/commit/SHA40">commit number 39</a>
@@ -347,8 +321,6 @@
347 </td>321 </td>
348 </tr>322 </tr>
349 <tr id="c-SHA7">323 <tr id="c-SHA7">
350-<td class="cur" aria-hidden="true">
351-</td>
352 <td class="c-sha mute">SHA7</td>324 <td class="c-sha mute">SHA7</td>
353 <td class="c-subject">325 <td class="c-subject">
354 <a href="/long/commit/SHA40">commit number 38</a>326 <a href="/long/commit/SHA40">commit number 38</a>
@@ -370,8 +342,6 @@
370 <table class="list commits" aria-label="Commits">342 <table class="list commits" aria-label="Commits">
371 <tbody data-rows>343 <tbody data-rows>
372 <tr id="c-SHA7">344 <tr id="c-SHA7">
373-<td class="cur" aria-hidden="true">
374-</td>
375 <td class="c-sha mute">SHA7</td>345 <td class="c-sha mute">SHA7</td>
376 <td class="c-subject">346 <td class="c-subject">
377 <a href="/long/commit/SHA40">commit number 37</a>347 <a href="/long/commit/SHA40">commit number 37</a>
@@ -384,8 +354,6 @@
384 </td>354 </td>
385 </tr>355 </tr>
386 <tr id="c-SHA7">356 <tr id="c-SHA7">
387-<td class="cur" aria-hidden="true">
388-</td>
389 <td class="c-sha mute">SHA7</td>357 <td class="c-sha mute">SHA7</td>
390 <td class="c-subject">358 <td class="c-subject">
391 <a href="/long/commit/SHA40">commit number 36</a>359 <a href="/long/commit/SHA40">commit number 36</a>
@@ -407,8 +375,6 @@
407 <table class="list commits" aria-label="Commits">375 <table class="list commits" aria-label="Commits">
408 <tbody data-rows>376 <tbody data-rows>
409 <tr id="c-SHA7">377 <tr id="c-SHA7">
410-<td class="cur" aria-hidden="true">
411-</td>
412 <td class="c-sha mute">SHA7</td>378 <td class="c-sha mute">SHA7</td>
413 <td class="c-subject">379 <td class="c-subject">
414 <a href="/long/commit/SHA40">commit number 35</a>380 <a href="/long/commit/SHA40">commit number 35</a>
@@ -421,8 +387,6 @@
421 </td>387 </td>
422 </tr>388 </tr>
423 <tr id="c-SHA7">389 <tr id="c-SHA7">
424-<td class="cur" aria-hidden="true">
425-</td>
426 <td class="c-sha mute">SHA7</td>390 <td class="c-sha mute">SHA7</td>
427 <td class="c-subject">391 <td class="c-subject">
428 <a href="/long/commit/SHA40">commit number 34</a>392 <a href="/long/commit/SHA40">commit number 34</a>
@@ -444,8 +408,6 @@
444 <table class="list commits" aria-label="Commits">408 <table class="list commits" aria-label="Commits">
445 <tbody data-rows>409 <tbody data-rows>
446 <tr id="c-SHA7">410 <tr id="c-SHA7">
447-<td class="cur" aria-hidden="true">
448-</td>
449 <td class="c-sha mute">SHA7</td>411 <td class="c-sha mute">SHA7</td>
450 <td class="c-subject">412 <td class="c-subject">
451 <a href="/long/commit/SHA40">commit number 33</a>413 <a href="/long/commit/SHA40">commit number 33</a>
@@ -458,8 +420,6 @@
458 </td>420 </td>
459 </tr>421 </tr>
460 <tr id="c-SHA7">422 <tr id="c-SHA7">
461-<td class="cur" aria-hidden="true">
462-</td>
463 <td class="c-sha mute">SHA7</td>423 <td class="c-sha mute">SHA7</td>
464 <td class="c-subject">424 <td class="c-subject">
465 <a href="/long/commit/SHA40">commit number 32</a>425 <a href="/long/commit/SHA40">commit number 32</a>
@@ -481,8 +441,6 @@
481 <table class="list commits" aria-label="Commits">441 <table class="list commits" aria-label="Commits">
482 <tbody data-rows>442 <tbody data-rows>
483 <tr id="c-SHA7">443 <tr id="c-SHA7">
484-<td class="cur" aria-hidden="true">
485-</td>
486 <td class="c-sha mute">SHA7</td>444 <td class="c-sha mute">SHA7</td>
487 <td class="c-subject">445 <td class="c-subject">
488 <a href="/long/commit/SHA40">commit number 31</a>446 <a href="/long/commit/SHA40">commit number 31</a>
@@ -495,8 +453,6 @@
495 </td>453 </td>
496 </tr>454 </tr>
497 <tr id="c-SHA7">455 <tr id="c-SHA7">
498-<td class="cur" aria-hidden="true">
499-</td>
500 <td class="c-sha mute">SHA7</td>456 <td class="c-sha mute">SHA7</td>
501 <td class="c-subject">457 <td class="c-subject">
502 <a href="/long/commit/SHA40">commit number 30</a>458 <a href="/long/commit/SHA40">commit number 30</a>
@@ -518,8 +474,6 @@
518 <table class="list commits" aria-label="Commits">474 <table class="list commits" aria-label="Commits">
519 <tbody data-rows>475 <tbody data-rows>
520 <tr id="c-SHA7">476 <tr id="c-SHA7">
521-<td class="cur" aria-hidden="true">
522-</td>
523 <td class="c-sha mute">SHA7</td>477 <td class="c-sha mute">SHA7</td>
524 <td class="c-subject">478 <td class="c-subject">
525 <a href="/long/commit/SHA40">commit number 29</a>479 <a href="/long/commit/SHA40">commit number 29</a>
@@ -532,8 +486,6 @@
532 </td>486 </td>
533 </tr>487 </tr>
534 <tr id="c-SHA7">488 <tr id="c-SHA7">
535-<td class="cur" aria-hidden="true">
536-</td>
537 <td class="c-sha mute">SHA7</td>489 <td class="c-sha mute">SHA7</td>
538 <td class="c-subject">490 <td class="c-subject">
539 <a href="/long/commit/SHA40">commit number 28</a>491 <a href="/long/commit/SHA40">commit number 28</a>
@@ -555,8 +507,6 @@
555 <table class="list commits" aria-label="Commits">507 <table class="list commits" aria-label="Commits">
556 <tbody data-rows>508 <tbody data-rows>
557 <tr id="c-SHA7">509 <tr id="c-SHA7">
558-<td class="cur" aria-hidden="true">
559-</td>
560 <td class="c-sha mute">SHA7</td>510 <td class="c-sha mute">SHA7</td>
561 <td class="c-subject">511 <td class="c-subject">
562 <a href="/long/commit/SHA40">commit number 27</a>512 <a href="/long/commit/SHA40">commit number 27</a>
@@ -569,8 +519,6 @@
569 </td>519 </td>
570 </tr>520 </tr>
571 <tr id="c-SHA7">521 <tr id="c-SHA7">
572-<td class="cur" aria-hidden="true">
573-</td>
574 <td class="c-sha mute">SHA7</td>522 <td class="c-sha mute">SHA7</td>
575 <td class="c-subject">523 <td class="c-subject">
576 <a href="/long/commit/SHA40">commit number 26</a>524 <a href="/long/commit/SHA40">commit number 26</a>
@@ -592,8 +540,6 @@
592 <table class="list commits" aria-label="Commits">540 <table class="list commits" aria-label="Commits">
593 <tbody data-rows>541 <tbody data-rows>
594 <tr id="c-SHA7">542 <tr id="c-SHA7">
595-<td class="cur" aria-hidden="true">
596-</td>
597 <td class="c-sha mute">SHA7</td>543 <td class="c-sha mute">SHA7</td>
598 <td class="c-subject">544 <td class="c-subject">
599 <a href="/long/commit/SHA40">commit number 25</a>545 <a href="/long/commit/SHA40">commit number 25</a>
@@ -606,8 +552,6 @@
606 </td>552 </td>
607 </tr>553 </tr>
608 <tr id="c-SHA7">554 <tr id="c-SHA7">
609-<td class="cur" aria-hidden="true">
610-</td>
611 <td class="c-sha mute">SHA7</td>555 <td class="c-sha mute">SHA7</td>
612 <td class="c-subject">556 <td class="c-subject">
613 <a href="/long/commit/SHA40">commit number 24</a>557 <a href="/long/commit/SHA40">commit number 24</a>
@@ -629,8 +573,6 @@
629 <table class="list commits" aria-label="Commits">573 <table class="list commits" aria-label="Commits">
630 <tbody data-rows>574 <tbody data-rows>
631 <tr id="c-SHA7">575 <tr id="c-SHA7">
632-<td class="cur" aria-hidden="true">
633-</td>
634 <td class="c-sha mute">SHA7</td>576 <td class="c-sha mute">SHA7</td>
635 <td class="c-subject">577 <td class="c-subject">
636 <a href="/long/commit/SHA40">commit number 23</a>578 <a href="/long/commit/SHA40">commit number 23</a>
@@ -643,8 +585,6 @@
643 </td>585 </td>
644 </tr>586 </tr>
645 <tr id="c-SHA7">587 <tr id="c-SHA7">
646-<td class="cur" aria-hidden="true">
647-</td>
648 <td class="c-sha mute">SHA7</td>588 <td class="c-sha mute">SHA7</td>
649 <td class="c-subject">589 <td class="c-subject">
650 <a href="/long/commit/SHA40">commit number 22</a>590 <a href="/long/commit/SHA40">commit number 22</a>
@@ -666,8 +606,6 @@
666 <table class="list commits" aria-label="Commits">606 <table class="list commits" aria-label="Commits">
667 <tbody data-rows>607 <tbody data-rows>
668 <tr id="c-SHA7">608 <tr id="c-SHA7">
669-<td class="cur" aria-hidden="true">
670-</td>
671 <td class="c-sha mute">SHA7</td>609 <td class="c-sha mute">SHA7</td>
672 <td class="c-subject">610 <td class="c-subject">
673 <a href="/long/commit/SHA40">commit number 21</a>611 <a href="/long/commit/SHA40">commit number 21</a>
@@ -680,8 +618,6 @@
680 </td>618 </td>
681 </tr>619 </tr>
682 <tr id="c-SHA7">620 <tr id="c-SHA7">
683-<td class="cur" aria-hidden="true">
684-</td>
685 <td class="c-sha mute">SHA7</td>621 <td class="c-sha mute">SHA7</td>
686 <td class="c-subject">622 <td class="c-subject">
687 <a href="/long/commit/SHA40">commit number 20</a>623 <a href="/long/commit/SHA40">commit number 20</a>
@@ -703,8 +639,6 @@
703 <table class="list commits" aria-label="Commits">639 <table class="list commits" aria-label="Commits">
704 <tbody data-rows>640 <tbody data-rows>
705 <tr id="c-SHA7">641 <tr id="c-SHA7">
706-<td class="cur" aria-hidden="true">
707-</td>
708 <td class="c-sha mute">SHA7</td>642 <td class="c-sha mute">SHA7</td>
709 <td class="c-subject">643 <td class="c-subject">
710 <a href="/long/commit/SHA40">commit number 19</a>644 <a href="/long/commit/SHA40">commit number 19</a>
@@ -717,8 +651,6 @@
717 </td>651 </td>
718 </tr>652 </tr>
719 <tr id="c-SHA7">653 <tr id="c-SHA7">
720-<td class="cur" aria-hidden="true">
721-</td>
722 <td class="c-sha mute">SHA7</td>654 <td class="c-sha mute">SHA7</td>
723 <td class="c-subject">655 <td class="c-subject">
724 <a href="/long/commit/SHA40">commit number 18</a>656 <a href="/long/commit/SHA40">commit number 18</a>
@@ -740,8 +672,6 @@
740 <table class="list commits" aria-label="Commits">672 <table class="list commits" aria-label="Commits">
741 <tbody data-rows>673 <tbody data-rows>
742 <tr id="c-SHA7">674 <tr id="c-SHA7">
743-<td class="cur" aria-hidden="true">
744-</td>
745 <td class="c-sha mute">SHA7</td>675 <td class="c-sha mute">SHA7</td>
746 <td class="c-subject">676 <td class="c-subject">
747 <a href="/long/commit/SHA40">commit number 17</a>677 <a href="/long/commit/SHA40">commit number 17</a>
@@ -754,8 +684,6 @@
754 </td>684 </td>
755 </tr>685 </tr>
756 <tr id="c-SHA7">686 <tr id="c-SHA7">
757-<td class="cur" aria-hidden="true">
758-</td>
759 <td class="c-sha mute">SHA7</td>687 <td class="c-sha mute">SHA7</td>
760 <td class="c-subject">688 <td class="c-subject">
761 <a href="/long/commit/SHA40">commit number 16</a>689 <a href="/long/commit/SHA40">commit number 16</a>
@@ -777,8 +705,6 @@
777 <table class="list commits" aria-label="Commits">705 <table class="list commits" aria-label="Commits">
778 <tbody data-rows>706 <tbody data-rows>
779 <tr id="c-SHA7">707 <tr id="c-SHA7">
780-<td class="cur" aria-hidden="true">
781-</td>
782 <td class="c-sha mute">SHA7</td>708 <td class="c-sha mute">SHA7</td>
783 <td class="c-subject">709 <td class="c-subject">
784 <a href="/long/commit/SHA40">commit number 15</a>710 <a href="/long/commit/SHA40">commit number 15</a>
@@ -791,8 +717,6 @@
791 </td>717 </td>
792 </tr>718 </tr>
793 <tr id="c-SHA7">719 <tr id="c-SHA7">
794-<td class="cur" aria-hidden="true">
795-</td>
796 <td class="c-sha mute">SHA7</td>720 <td class="c-sha mute">SHA7</td>
797 <td class="c-subject">721 <td class="c-subject">
798 <a href="/long/commit/SHA40">commit number 14</a>722 <a href="/long/commit/SHA40">commit number 14</a>
@@ -814,8 +738,6 @@
814 <table class="list commits" aria-label="Commits">738 <table class="list commits" aria-label="Commits">
815 <tbody data-rows>739 <tbody data-rows>
816 <tr id="c-SHA7">740 <tr id="c-SHA7">
817-<td class="cur" aria-hidden="true">
818-</td>
819 <td class="c-sha mute">SHA7</td>741 <td class="c-sha mute">SHA7</td>
820 <td class="c-subject">742 <td class="c-subject">
821 <a href="/long/commit/SHA40">commit number 13</a>743 <a href="/long/commit/SHA40">commit number 13</a>
@@ -828,8 +750,6 @@
828 </td>750 </td>
829 </tr>751 </tr>
830 <tr id="c-SHA7">752 <tr id="c-SHA7">
831-<td class="cur" aria-hidden="true">
832-</td>
833 <td class="c-sha mute">SHA7</td>753 <td class="c-sha mute">SHA7</td>
834 <td class="c-subject">754 <td class="c-subject">
835 <a href="/long/commit/SHA40">commit number 12</a>755 <a href="/long/commit/SHA40">commit number 12</a>
@@ -851,8 +771,6 @@
851 <table class="list commits" aria-label="Commits">771 <table class="list commits" aria-label="Commits">
852 <tbody data-rows>772 <tbody data-rows>
853 <tr id="c-SHA7">773 <tr id="c-SHA7">
854-<td class="cur" aria-hidden="true">
855-</td>
856 <td class="c-sha mute">SHA7</td>774 <td class="c-sha mute">SHA7</td>
857 <td class="c-subject">775 <td class="c-subject">
858 <a href="/long/commit/SHA40">commit number 11</a>776 <a href="/long/commit/SHA40">commit number 11</a>
@@ -865,8 +783,6 @@
865 </td>783 </td>
866 </tr>784 </tr>
867 <tr id="c-SHA7">785 <tr id="c-SHA7">
868-<td class="cur" aria-hidden="true">
869-</td>
870 <td class="c-sha mute">SHA7</td>786 <td class="c-sha mute">SHA7</td>
871 <td class="c-subject">787 <td class="c-subject">
872 <a href="/long/commit/SHA40">commit number 10</a>788 <a href="/long/commit/SHA40">commit number 10</a>
@@ -888,8 +804,6 @@
888 <table class="list commits" aria-label="Commits">804 <table class="list commits" aria-label="Commits">
889 <tbody data-rows>805 <tbody data-rows>
890 <tr id="c-SHA7">806 <tr id="c-SHA7">
891-<td class="cur" aria-hidden="true">
892-</td>
893 <td class="c-sha mute">SHA7</td>807 <td class="c-sha mute">SHA7</td>
894 <td class="c-subject">808 <td class="c-subject">
895 <a href="/long/commit/SHA40">commit number 9</a>809 <a href="/long/commit/SHA40">commit number 9</a>
@@ -902,8 +816,6 @@
902 </td>816 </td>
903 </tr>817 </tr>
904 <tr id="c-SHA7">818 <tr id="c-SHA7">
905-<td class="cur" aria-hidden="true">
906-</td>
907 <td class="c-sha mute">SHA7</td>819 <td class="c-sha mute">SHA7</td>
908 <td class="c-subject">820 <td class="c-subject">
909 <a href="/long/commit/SHA40">commit number 8</a>821 <a href="/long/commit/SHA40">commit number 8</a>
@@ -925,8 +837,6 @@
925 <table class="list commits" aria-label="Commits">837 <table class="list commits" aria-label="Commits">
926 <tbody data-rows>838 <tbody data-rows>
927 <tr id="c-SHA7">839 <tr id="c-SHA7">
928-<td class="cur" aria-hidden="true">
929-</td>
930 <td class="c-sha mute">SHA7</td>840 <td class="c-sha mute">SHA7</td>
931 <td class="c-subject">841 <td class="c-subject">
932 <a href="/long/commit/SHA40">commit number 7</a>842 <a href="/long/commit/SHA40">commit number 7</a>
@@ -939,8 +849,6 @@
939 </td>849 </td>
940 </tr>850 </tr>
941 <tr id="c-SHA7">851 <tr id="c-SHA7">
942-<td class="cur" aria-hidden="true">
943-</td>
944 <td class="c-sha mute">SHA7</td>852 <td class="c-sha mute">SHA7</td>
945 <td class="c-subject">853 <td class="c-subject">
946 <a href="/long/commit/SHA40">commit number 6</a>854 <a href="/long/commit/SHA40">commit number 6</a>
@@ -962,8 +870,6 @@
962 <table class="list commits" aria-label="Commits">870 <table class="list commits" aria-label="Commits">
963 <tbody data-rows>871 <tbody data-rows>
964 <tr id="c-SHA7">872 <tr id="c-SHA7">
965-<td class="cur" aria-hidden="true">
966-</td>
967 <td class="c-sha mute">SHA7</td>873 <td class="c-sha mute">SHA7</td>
968 <td class="c-subject">874 <td class="c-subject">
969 <a href="/long/commit/SHA40">commit number 5</a>875 <a href="/long/commit/SHA40">commit number 5</a>
@@ -976,8 +882,6 @@
976 </td>882 </td>
977 </tr>883 </tr>
978 <tr id="c-SHA7">884 <tr id="c-SHA7">
979-<td class="cur" aria-hidden="true">
980-</td>
981 <td class="c-sha mute">SHA7</td>885 <td class="c-sha mute">SHA7</td>
982 <td class="c-subject">886 <td class="c-subject">
983 <a href="/long/commit/SHA40">commit number 4</a>887 <a href="/long/commit/SHA40">commit number 4</a>
@@ -999,8 +903,6 @@
999 <table class="list commits" aria-label="Commits">903 <table class="list commits" aria-label="Commits">
1000 <tbody data-rows>904 <tbody data-rows>
1001 <tr id="c-SHA7">905 <tr id="c-SHA7">
1002-<td class="cur" aria-hidden="true">
1003-</td>
1004 <td class="c-sha mute">SHA7</td>906 <td class="c-sha mute">SHA7</td>
1005 <td class="c-subject">907 <td class="c-subject">
1006 <a href="/long/commit/SHA40">commit number 3</a>908 <a href="/long/commit/SHA40">commit number 3</a>
@@ -1013,8 +915,6 @@
1013 </td>915 </td>
1014 </tr>916 </tr>
1015 <tr id="c-SHA7">917 <tr id="c-SHA7">
1016-<td class="cur" aria-hidden="true">
1017-</td>
1018 <td class="c-sha mute">SHA7</td>918 <td class="c-sha mute">SHA7</td>
1019 <td class="c-subject">919 <td class="c-subject">
1020 <a href="/long/commit/SHA40">commit number 2</a>920 <a href="/long/commit/SHA40">commit number 2</a>
M internal/app/testdata/web/commits-small.html +0 -16
@@ -110,8 +110,6 @@
110110 <table class="list commits" aria-label="Commits">
111111 <tbody data-rows>
112112 <tr id="c-SHA7" data-cursor>
113-<td class="cur" aria-hidden="true">
114-</td>
115113 <td class="c-sha mute">SHA7</td>
116114 <td class="c-subject">
117115 <a href="/diffs/commit/SHA40">add a very large file</a>
@@ -134,8 +132,6 @@
134132 <table class="list commits" aria-label="Commits">
135133 <tbody data-rows>
136134 <tr id="c-SHA7">
137-<td class="cur" aria-hidden="true">
138-</td>
139135 <td class="c-sha mute">SHA7</td>
140136 <td class="c-subject">
141137 <a href="/diffs/commit/SHA40">add evil.go</a>
@@ -157,8 +153,6 @@
157153 <table class="list commits" aria-label="Commits">
158154 <tbody data-rows>
159155 <tr id="c-SHA7">
160-<td class="cur" aria-hidden="true">
161-</td>
162156 <td class="c-sha mute">SHA7</td>
163157 <td class="c-subject">
164158 <a href="/diffs/commit/SHA40">merge the side branch</a>
@@ -181,8 +175,6 @@
181175 <table class="list commits" aria-label="Commits">
182176 <tbody data-rows>
183177 <tr id="c-SHA7">
184-<td class="cur" aria-hidden="true">
185-</td>
186178 <td class="c-sha mute">SHA7</td>
187179 <td class="c-subject">
188180 <a href="/diffs/commit/SHA40">work on the side</a>
@@ -205,8 +197,6 @@
205197 <table class="list commits" aria-label="Commits">
206198 <tbody data-rows>
207199 <tr id="c-SHA7">
208-<td class="cur" aria-hidden="true">
209-</td>
210200 <td class="c-sha mute">SHA7</td>
211201 <td class="c-subject">
212202 <a href="/diffs/commit/SHA40">change a binary file</a>
@@ -228,8 +218,6 @@
228218 <table class="list commits" aria-label="Commits">
229219 <tbody data-rows>
230220 <tr id="c-SHA7">
231-<td class="cur" aria-hidden="true">
232-</td>
233221 <td class="c-sha mute">SHA7</td>
234222 <td class="c-subject">
235223 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
@@ -251,8 +239,6 @@
251239 <table class="list commits" aria-label="Commits">
252240 <tbody data-rows>
253241 <tr id="c-SHA7">
254-<td class="cur" aria-hidden="true">
255-</td>
256242 <td class="c-sha mute">SHA7</td>
257243 <td class="c-subject">
258244 <a href="/diffs/commit/SHA40">modify a file and make a script executable</a>
@@ -276,8 +262,6 @@
276262 <table class="list commits" aria-label="Commits">
277263 <tbody data-rows>
278264 <tr id="c-SHA7">
279-<td class="cur" aria-hidden="true">
280-</td>
281265 <td class="c-sha mute">SHA7</td>
282266 <td class="c-subject">
283267 <a href="/diffs/commit/SHA40">root: the first files</a>
@@ -110,8 +110,6 @@
110 <table class="list commits" aria-label="Commits">110 <table class="list commits" aria-label="Commits">
111 <tbody data-rows>111 <tbody data-rows>
112 <tr id="c-SHA7" data-cursor>112 <tr id="c-SHA7" data-cursor>
113-<td class="cur" aria-hidden="true">
114-</td>
115 <td class="c-sha mute">SHA7</td>113 <td class="c-sha mute">SHA7</td>
116 <td class="c-subject">114 <td class="c-subject">
117 <a href="/diffs/commit/SHA40">add a very large file</a>115 <a href="/diffs/commit/SHA40">add a very large file</a>
@@ -134,8 +132,6 @@
134 <table class="list commits" aria-label="Commits">132 <table class="list commits" aria-label="Commits">
135 <tbody data-rows>133 <tbody data-rows>
136 <tr id="c-SHA7">134 <tr id="c-SHA7">
137-<td class="cur" aria-hidden="true">
138-</td>
139 <td class="c-sha mute">SHA7</td>135 <td class="c-sha mute">SHA7</td>
140 <td class="c-subject">136 <td class="c-subject">
141 <a href="/diffs/commit/SHA40">add evil.go</a>137 <a href="/diffs/commit/SHA40">add evil.go</a>
@@ -157,8 +153,6 @@
157 <table class="list commits" aria-label="Commits">153 <table class="list commits" aria-label="Commits">
158 <tbody data-rows>154 <tbody data-rows>
159 <tr id="c-SHA7">155 <tr id="c-SHA7">
160-<td class="cur" aria-hidden="true">
161-</td>
162 <td class="c-sha mute">SHA7</td>156 <td class="c-sha mute">SHA7</td>
163 <td class="c-subject">157 <td class="c-subject">
164 <a href="/diffs/commit/SHA40">merge the side branch</a>158 <a href="/diffs/commit/SHA40">merge the side branch</a>
@@ -181,8 +175,6 @@
181 <table class="list commits" aria-label="Commits">175 <table class="list commits" aria-label="Commits">
182 <tbody data-rows>176 <tbody data-rows>
183 <tr id="c-SHA7">177 <tr id="c-SHA7">
184-<td class="cur" aria-hidden="true">
185-</td>
186 <td class="c-sha mute">SHA7</td>178 <td class="c-sha mute">SHA7</td>
187 <td class="c-subject">179 <td class="c-subject">
188 <a href="/diffs/commit/SHA40">work on the side</a>180 <a href="/diffs/commit/SHA40">work on the side</a>
@@ -205,8 +197,6 @@
205 <table class="list commits" aria-label="Commits">197 <table class="list commits" aria-label="Commits">
206 <tbody data-rows>198 <tbody data-rows>
207 <tr id="c-SHA7">199 <tr id="c-SHA7">
208-<td class="cur" aria-hidden="true">
209-</td>
210 <td class="c-sha mute">SHA7</td>200 <td class="c-sha mute">SHA7</td>
211 <td class="c-subject">201 <td class="c-subject">
212 <a href="/diffs/commit/SHA40">change a binary file</a>202 <a href="/diffs/commit/SHA40">change a binary file</a>
@@ -228,8 +218,6 @@
228 <table class="list commits" aria-label="Commits">218 <table class="list commits" aria-label="Commits">
229 <tbody data-rows>219 <tbody data-rows>
230 <tr id="c-SHA7">220 <tr id="c-SHA7">
231-<td class="cur" aria-hidden="true">
232-</td>
233 <td class="c-sha mute">SHA7</td>221 <td class="c-sha mute">SHA7</td>
234 <td class="c-subject">222 <td class="c-subject">
235 <a href="/diffs/commit/SHA40">rename a file and delete another</a>223 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
@@ -251,8 +239,6 @@
251 <table class="list commits" aria-label="Commits">239 <table class="list commits" aria-label="Commits">
252 <tbody data-rows>240 <tbody data-rows>
253 <tr id="c-SHA7">241 <tr id="c-SHA7">
254-<td class="cur" aria-hidden="true">
255-</td>
256 <td class="c-sha mute">SHA7</td>242 <td class="c-sha mute">SHA7</td>
257 <td class="c-subject">243 <td class="c-subject">
258 <a href="/diffs/commit/SHA40">modify a file and make a script executable</a>244 <a href="/diffs/commit/SHA40">modify a file and make a script executable</a>
@@ -276,8 +262,6 @@
276 <table class="list commits" aria-label="Commits">262 <table class="list commits" aria-label="Commits">
277 <tbody data-rows>263 <tbody data-rows>
278 <tr id="c-SHA7">264 <tr id="c-SHA7">
279-<td class="cur" aria-hidden="true">
280-</td>
281 <td class="c-sha mute">SHA7</td>265 <td class="c-sha mute">SHA7</td>
282 <td class="c-subject">266 <td class="c-subject">
283 <a href="/diffs/commit/SHA40">root: the first files</a>267 <a href="/diffs/commit/SHA40">root: the first files</a>
M internal/app/testdata/web/home.html +28 -13
@@ -72,13 +72,28 @@
7272 <a href="/demo">demo</a>
7373 </h2>
7474 <p class="repo-desc">A demo repository</p>
75-<p class="repo-meta">
76-<span class="repo-branch">
77-<span class="ico i-branch" aria-hidden="true">
78-</span>main</span>
79-<span>pushed <time datetime="STAMP" title="STAMP">AGO</time>
80-</span>
81-</p>
75+<dl class="repo-stats" aria-label="Figures of demo">
76+<div>
77+<dt>commits</dt>
78+<dd>2</dd>
79+</div>
80+<div>
81+<dt>last 30 days</dt>
82+<dd>0</dd>
83+</div>
84+<div>
85+<dt>latest tag</dt>
86+<dd>
87+<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>
88+</dd>
89+</div>
90+<div>
91+<dt>last activity</dt>
92+<dd>
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</dd>
95+</div>
96+</dl>
8297 </div>
8398 <ul class="repo-commits" aria-label="Recent commits of demo">
8499 <li>
@@ -99,12 +114,12 @@
99114 <a href="/quiet">quiet</a>
100115 </h2>
101116 <p class="repo-desc mute">no description</p>
102-<p class="repo-meta">
103-<span class="repo-branch">
104-<span class="ico i-branch" aria-hidden="true">
105-</span>main</span>
106-<span>never pushed</span>
107-</p>
117+<dl class="repo-stats" aria-label="Figures of quiet">
118+<div>
119+<dt>last activity</dt>
120+<dd class="mute">never</dd>
121+</div>
122+</dl>
108123 </div>
109124 <p class="repo-commits mute">no commits yet</p>
110125 </li>
@@ -72,13 +72,28 @@
72 <a href="/demo">demo</a>72 <a href="/demo">demo</a>
73 </h2>73 </h2>
74 <p class="repo-desc">A demo repository</p>74 <p class="repo-desc">A demo repository</p>
75-<p class="repo-meta">75+<dl class="repo-stats" aria-label="Figures of demo">
76-<span class="repo-branch">76+<div>
77-<span class="ico i-branch" aria-hidden="true">77+<dt>commits</dt>
78-</span>main</span>78+<dd>2</dd>
79-<span>pushed <time datetime="STAMP" title="STAMP">AGO</time>79+</div>
80-</span>80+<div>
81-</p>81+<dt>last 30 days</dt>
82+<dd>0</dd>
83+</div>
84+<div>
85+<dt>latest tag</dt>
86+<dd>
87+<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>
88+</dd>
89+</div>
90+<div>
91+<dt>last activity</dt>
92+<dd>
93+<time datetime="STAMP" title="STAMP">AGO</time>
94+</dd>
95+</div>
96+</dl>
82 </div>97 </div>
83 <ul class="repo-commits" aria-label="Recent commits of demo">98 <ul class="repo-commits" aria-label="Recent commits of demo">
84 <li>99 <li>
@@ -99,12 +114,12 @@
99 <a href="/quiet">quiet</a>114 <a href="/quiet">quiet</a>
100 </h2>115 </h2>
101 <p class="repo-desc mute">no description</p>116 <p class="repo-desc mute">no description</p>
102-<p class="repo-meta">117+<dl class="repo-stats" aria-label="Figures of quiet">
103-<span class="repo-branch">118+<div>
104-<span class="ico i-branch" aria-hidden="true">119+<dt>last activity</dt>
105-</span>main</span>120+<dd class="mute">never</dd>
106-<span>never pushed</span>121+</div>
107-</p>122+</dl>
108 </div>123 </div>
109 <p class="repo-commits mute">no commits yet</p>124 <p class="repo-commits mute">no commits yet</p>
110 </li>125 </li>
M internal/app/testdata/web/refs.html +0 -10
@@ -100,8 +100,6 @@
100100 <table class="list refs" aria-label="Branches and tags">
101101 <thead>
102102 <tr>
103-<th class="cur">
104-</th>
105103 <th class="c-kind">kind</th>
106104 <th class="c-name">name</th>
107105 <th class="c-sha">commit</th>
@@ -111,8 +109,6 @@
111109 </thead>
112110 <tbody id="ref-rows" data-rows>
113111 <tr data-cursor>
114-<td class="cur" aria-hidden="true">
115-</td>
116112 <td class="c-kind mute">branch</td>
117113 <td class="c-name">
118114 <a href="/demo/tree/main">main</a>
@@ -127,8 +123,6 @@
127123 </td>
128124 </tr>
129125 <tr>
130-<td class="cur" aria-hidden="true">
131-</td>
132126 <td class="c-kind mute">branch</td>
133127 <td class="c-name">
134128 <a href="/demo/tree/feature/x">feature/x</a>
@@ -142,8 +136,6 @@
142136 </td>
143137 </tr>
144138 <tr>
145-<td class="cur" aria-hidden="true">
146-</td>
147139 <td class="c-kind mute">tag</td>
148140 <td class="c-name">
149141 <a href="/demo/tree/v0.2.0">v0.2.0</a>
@@ -157,8 +149,6 @@
157149 </td>
158150 </tr>
159151 <tr>
160-<td class="cur" aria-hidden="true">
161-</td>
162152 <td class="c-kind mute">tag</td>
163153 <td class="c-name">
164154 <a href="/demo/tree/v0.1.0">v0.1.0</a>
@@ -100,8 +100,6 @@
100 <table class="list refs" aria-label="Branches and tags">100 <table class="list refs" aria-label="Branches and tags">
101 <thead>101 <thead>
102 <tr>102 <tr>
103-<th class="cur">
104-</th>
105 <th class="c-kind">kind</th>103 <th class="c-kind">kind</th>
106 <th class="c-name">name</th>104 <th class="c-name">name</th>
107 <th class="c-sha">commit</th>105 <th class="c-sha">commit</th>
@@ -111,8 +109,6 @@
111 </thead>109 </thead>
112 <tbody id="ref-rows" data-rows>110 <tbody id="ref-rows" data-rows>
113 <tr data-cursor>111 <tr data-cursor>
114-<td class="cur" aria-hidden="true">
115-</td>
116 <td class="c-kind mute">branch</td>112 <td class="c-kind mute">branch</td>
117 <td class="c-name">113 <td class="c-name">
118 <a href="/demo/tree/main">main</a>114 <a href="/demo/tree/main">main</a>
@@ -127,8 +123,6 @@
127 </td>123 </td>
128 </tr>124 </tr>
129 <tr>125 <tr>
130-<td class="cur" aria-hidden="true">
131-</td>
132 <td class="c-kind mute">branch</td>126 <td class="c-kind mute">branch</td>
133 <td class="c-name">127 <td class="c-name">
134 <a href="/demo/tree/feature/x">feature/x</a>128 <a href="/demo/tree/feature/x">feature/x</a>
@@ -142,8 +136,6 @@
142 </td>136 </td>
143 </tr>137 </tr>
144 <tr>138 <tr>
145-<td class="cur" aria-hidden="true">
146-</td>
147 <td class="c-kind mute">tag</td>139 <td class="c-kind mute">tag</td>
148 <td class="c-name">140 <td class="c-name">
149 <a href="/demo/tree/v0.2.0">v0.2.0</a>141 <a href="/demo/tree/v0.2.0">v0.2.0</a>
@@ -157,8 +149,6 @@
157 </td>149 </td>
158 </tr>150 </tr>
159 <tr>151 <tr>
160-<td class="cur" aria-hidden="true">
161-</td>
162 <td class="c-kind mute">tag</td>152 <td class="c-kind mute">tag</td>
163 <td class="c-name">153 <td class="c-name">
164 <a href="/demo/tree/v0.1.0">v0.1.0</a>154 <a href="/demo/tree/v0.1.0">v0.1.0</a>
M internal/app/testdata/web/summary.html +0 -4
@@ -159,8 +159,6 @@
159159 <table class="list commits" aria-label="Commits">
160160 <tbody data-rows>
161161 <tr data-cursor>
162-<td class="cur" aria-hidden="true">
163-</td>
164162 <td class="c-sha mute">SHA7</td>
165163 <td class="c-subject">
166164 <a href="/demo/commit/SHA40">add main.go</a>
@@ -176,8 +174,6 @@
176174 </td>
177175 </tr>
178176 <tr>
179-<td class="cur" aria-hidden="true">
180-</td>
181177 <td class="c-sha mute">SHA7</td>
182178 <td class="c-subject">
183179 <a href="/demo/commit/SHA40">first commit</a>
@@ -159,8 +159,6 @@
159 <table class="list commits" aria-label="Commits">159 <table class="list commits" aria-label="Commits">
160 <tbody data-rows>160 <tbody data-rows>
161 <tr data-cursor>161 <tr data-cursor>
162-<td class="cur" aria-hidden="true">
163-</td>
164 <td class="c-sha mute">SHA7</td>162 <td class="c-sha mute">SHA7</td>
165 <td class="c-subject">163 <td class="c-subject">
166 <a href="/demo/commit/SHA40">add main.go</a>164 <a href="/demo/commit/SHA40">add main.go</a>
@@ -176,8 +174,6 @@
176 </td>174 </td>
177 </tr>175 </tr>
178 <tr>176 <tr>
179-<td class="cur" aria-hidden="true">
180-</td>
181 <td class="c-sha mute">SHA7</td>177 <td class="c-sha mute">SHA7</td>
182 <td class="c-subject">178 <td class="c-subject">
183 <a href="/demo/commit/SHA40">first commit</a>179 <a href="/demo/commit/SHA40">first commit</a>
M internal/domain/git.go +10 -0
@@ -253,6 +253,16 @@ type Ref struct {
253253 CommitID ObjectID
254254 }
255255
256+// TagInfo is a tag and when it was made: the tagger date of an annotated
257+// tag, the committer date of the commit of a lightweight one.
258+type TagInfo struct {
259+ Name string // short name, without refs/tags/
260+ At time.Time
261+ // CommitID is the commit the tag leads to; zero for a tag of a tree or
262+ // a blob.
263+ CommitID ObjectID
264+}
265+
256266 // ResolvedRevision is the result of resolving a Revision.
257267 type ResolvedRevision struct {
258268 // CommitID is the commit the revision designates, tags followed.
@@ -253,6 +253,16 @@ type Ref struct {
253 CommitID ObjectID253 CommitID ObjectID
254 }254 }
255 255
256+// TagInfo is a tag and when it was made: the tagger date of an annotated
257+// tag, the committer date of the commit of a lightweight one.
258+type TagInfo struct {
259+ Name string // short name, without refs/tags/
260+ At time.Time
261+ // CommitID is the commit the tag leads to; zero for a tag of a tree or
262+ // a blob.
263+ CommitID ObjectID
264+}
265+
256 // ResolvedRevision is the result of resolving a Revision.266 // ResolvedRevision is the result of resolving a Revision.
257 type ResolvedRevision struct {267 type ResolvedRevision struct {
258 // CommitID is the commit the revision designates, tags followed.268 // CommitID is the commit the revision designates, tags followed.
M internal/ports/dto.go +12 -0
@@ -114,6 +114,18 @@ type HistoryPage struct {
114114 NextPageToken string
115115 }
116116
117+// RepoStats are the figures of a repository the home page shows.
118+type RepoStats struct {
119+ // Commits is the number of commits of the default branch; Recent of them
120+ // were committed in the last RecentDays days.
121+ Commits, Recent int
122+ // LatestTag is the most recently created tag; nil without tags.
123+ LatestTag *domain.TagInfo
124+}
125+
126+// RecentDays is the window of RepoStats.Recent.
127+const RecentDays = 30
128+
117129 // WatchParams select the events of a watch.
118130 type WatchParams struct {
119131 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
@@ -114,6 +114,18 @@ type HistoryPage struct {
114 NextPageToken string114 NextPageToken string
115 }115 }
116 116
117+// RepoStats are the figures of a repository the home page shows.
118+type RepoStats struct {
119+ // Commits is the number of commits of the default branch; Recent of them
120+ // were committed in the last RecentDays days.
121+ Commits, Recent int
122+ // LatestTag is the most recently created tag; nil without tags.
123+ LatestTag *domain.TagInfo
124+}
125+
126+// RecentDays is the window of RepoStats.Recent.
127+const RecentDays = 30
128+
117 // WatchParams select the events of a watch.129 // WatchParams select the events of a watch.
118 type WatchParams struct {130 type WatchParams struct {
119 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.131 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
M internal/ports/mocks/BrowseService.go +85 -0
@@ -7,6 +7,7 @@ package mocks
77 import (
88 "context"
99 "io"
10+ "time"
1011
1112 "bnema.dev/ermite/internal/domain"
1213 "bnema.dev/ermite/internal/ports"
@@ -543,6 +544,90 @@ func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context
543544 return _c
544545 }
545546
547+// RepoStats provides a mock function for the type BrowseService
548+func (_mock *BrowseService) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
549+ ret := _mock.Called(ctx, p, repo, head, now)
550+
551+ if len(ret) == 0 {
552+ panic("no return value specified for RepoStats")
553+ }
554+
555+ var r0 ports.RepoStats
556+ var r1 error
557+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) (ports.RepoStats, error)); ok {
558+ return returnFunc(ctx, p, repo, head, now)
559+ }
560+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) ports.RepoStats); ok {
561+ r0 = returnFunc(ctx, p, repo, head, now)
562+ } else {
563+ r0 = ret.Get(0).(ports.RepoStats)
564+ }
565+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) error); ok {
566+ r1 = returnFunc(ctx, p, repo, head, now)
567+ } else {
568+ r1 = ret.Error(1)
569+ }
570+ return r0, r1
571+}
572+
573+// BrowseService_RepoStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoStats'
574+type BrowseService_RepoStats_Call struct {
575+ *mock.Call
576+}
577+
578+// RepoStats is a helper method to define mock.On call
579+// - ctx context.Context
580+// - p domain.Principal
581+// - repo domain.RepoName
582+// - head domain.ObjectID
583+// - now time.Time
584+func (_e *BrowseService_Expecter) RepoStats(ctx any, p any, repo any, head any, now any) *BrowseService_RepoStats_Call {
585+ return &BrowseService_RepoStats_Call{Call: _e.mock.On("RepoStats", ctx, p, repo, head, now)}
586+}
587+
588+func (_c *BrowseService_RepoStats_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time)) *BrowseService_RepoStats_Call {
589+ _c.Call.Run(func(args mock.Arguments) {
590+ var arg0 context.Context
591+ if args[0] != nil {
592+ arg0 = args[0].(context.Context)
593+ }
594+ var arg1 domain.Principal
595+ if args[1] != nil {
596+ arg1 = args[1].(domain.Principal)
597+ }
598+ var arg2 domain.RepoName
599+ if args[2] != nil {
600+ arg2 = args[2].(domain.RepoName)
601+ }
602+ var arg3 domain.ObjectID
603+ if args[3] != nil {
604+ arg3 = args[3].(domain.ObjectID)
605+ }
606+ var arg4 time.Time
607+ if args[4] != nil {
608+ arg4 = args[4].(time.Time)
609+ }
610+ run(
611+ arg0,
612+ arg1,
613+ arg2,
614+ arg3,
615+ arg4,
616+ )
617+ })
618+ return _c
619+}
620+
621+func (_c *BrowseService_RepoStats_Call) Return(repoStats ports.RepoStats, err error) *BrowseService_RepoStats_Call {
622+ _c.Call.Return(repoStats, err)
623+ return _c
624+}
625+
626+func (_c *BrowseService_RepoStats_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error)) *BrowseService_RepoStats_Call {
627+ _c.Call.Return(run)
628+ return _c
629+}
630+
546631 // ResolveRevision provides a mock function for the type BrowseService
547632 func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
548633 ret := _mock.Called(ctx, p, repo, rev)
@@ -7,6 +7,7 @@ package mocks
7 import (7 import (
8 "context"8 "context"
9 "io"9 "io"
10+ "time"
10 11
11 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"13 "bnema.dev/ermite/internal/ports"
@@ -543,6 +544,90 @@ func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context
543 return _c544 return _c
544 }545 }
545 546
547+// RepoStats provides a mock function for the type BrowseService
548+func (_mock *BrowseService) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
549+ ret := _mock.Called(ctx, p, repo, head, now)
550+
551+ if len(ret) == 0 {
552+ panic("no return value specified for RepoStats")
553+ }
554+
555+ var r0 ports.RepoStats
556+ var r1 error
557+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) (ports.RepoStats, error)); ok {
558+ return returnFunc(ctx, p, repo, head, now)
559+ }
560+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) ports.RepoStats); ok {
561+ r0 = returnFunc(ctx, p, repo, head, now)
562+ } else {
563+ r0 = ret.Get(0).(ports.RepoStats)
564+ }
565+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, time.Time) error); ok {
566+ r1 = returnFunc(ctx, p, repo, head, now)
567+ } else {
568+ r1 = ret.Error(1)
569+ }
570+ return r0, r1
571+}
572+
573+// BrowseService_RepoStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoStats'
574+type BrowseService_RepoStats_Call struct {
575+ *mock.Call
576+}
577+
578+// RepoStats is a helper method to define mock.On call
579+// - ctx context.Context
580+// - p domain.Principal
581+// - repo domain.RepoName
582+// - head domain.ObjectID
583+// - now time.Time
584+func (_e *BrowseService_Expecter) RepoStats(ctx any, p any, repo any, head any, now any) *BrowseService_RepoStats_Call {
585+ return &BrowseService_RepoStats_Call{Call: _e.mock.On("RepoStats", ctx, p, repo, head, now)}
586+}
587+
588+func (_c *BrowseService_RepoStats_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time)) *BrowseService_RepoStats_Call {
589+ _c.Call.Run(func(args mock.Arguments) {
590+ var arg0 context.Context
591+ if args[0] != nil {
592+ arg0 = args[0].(context.Context)
593+ }
594+ var arg1 domain.Principal
595+ if args[1] != nil {
596+ arg1 = args[1].(domain.Principal)
597+ }
598+ var arg2 domain.RepoName
599+ if args[2] != nil {
600+ arg2 = args[2].(domain.RepoName)
601+ }
602+ var arg3 domain.ObjectID
603+ if args[3] != nil {
604+ arg3 = args[3].(domain.ObjectID)
605+ }
606+ var arg4 time.Time
607+ if args[4] != nil {
608+ arg4 = args[4].(time.Time)
609+ }
610+ run(
611+ arg0,
612+ arg1,
613+ arg2,
614+ arg3,
615+ arg4,
616+ )
617+ })
618+ return _c
619+}
620+
621+func (_c *BrowseService_RepoStats_Call) Return(repoStats ports.RepoStats, err error) *BrowseService_RepoStats_Call {
622+ _c.Call.Return(repoStats, err)
623+ return _c
624+}
625+
626+func (_c *BrowseService_RepoStats_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error)) *BrowseService_RepoStats_Call {
627+ _c.Call.Return(run)
628+ return _c
629+}
630+
546 // ResolveRevision provides a mock function for the type BrowseService631 // ResolveRevision provides a mock function for the type BrowseService
547 func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {632 func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
548 ret := _mock.Called(ctx, p, repo, rev)633 ret := _mock.Called(ctx, p, repo, rev)
M internal/ports/mocks/RepoReader.go +151 -0
@@ -7,6 +7,7 @@ package mocks
77 import (
88 "context"
99 "io"
10+ "time"
1011
1112 "bnema.dev/ermite/internal/domain"
1213 "bnema.dev/ermite/internal/ports"
@@ -49,6 +50,84 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
4950 return &RepoReader_Expecter{mock: &_m.Mock}
5051 }
5152
53+// CountCommits provides a mock function for the type RepoReader
54+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
55+ ret := _mock.Called(ctx, repo, head, since)
56+
57+ if len(ret) == 0 {
58+ panic("no return value specified for CountCommits")
59+ }
60+
61+ var r0 int
62+ var r1 error
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok {
64+ return returnFunc(ctx, repo, head, since)
65+ }
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {
67+ r0 = returnFunc(ctx, repo, head, since)
68+ } else {
69+ r0 = ret.Get(0).(int)
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
72+ r1 = returnFunc(ctx, repo, head, since)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
80+type RepoReader_CountCommits_Call struct {
81+ *mock.Call
82+}
83+
84+// CountCommits is a helper method to define mock.On call
85+// - ctx context.Context
86+// - repo domain.RepoName
87+// - head domain.ObjectID
88+// - since time.Time
89+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call {
90+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)}
91+}
92+
93+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.RepoName
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.RepoName)
102+ }
103+ var arg2 domain.ObjectID
104+ if args[2] != nil {
105+ arg2 = args[2].(domain.ObjectID)
106+ }
107+ var arg3 time.Time
108+ if args[3] != nil {
109+ arg3 = args[3].(time.Time)
110+ }
111+ run(
112+ arg0,
113+ arg1,
114+ arg2,
115+ arg3,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122+ _c.Call.Return(n, err)
123+ return _c
124+}
125+
126+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
52131 // GetCommit provides a mock function for the type RepoReader
53132 func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
54133 ret := _mock.Called(ctx, repo, rev, limits)
@@ -211,6 +290,78 @@ func (_c *RepoReader_GetTree_Call) RunAndReturn(run func(ctx context.Context, re
211290 return _c
212291 }
213292
293+// LatestTag provides a mock function for the type RepoReader
294+func (_mock *RepoReader) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
295+ ret := _mock.Called(ctx, repo)
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for LatestTag")
299+ }
300+
301+ var r0 domain.TagInfo
302+ var r1 bool
303+ var r2 error
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.TagInfo, bool, error)); ok {
305+ return returnFunc(ctx, repo)
306+ }
307+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.TagInfo); ok {
308+ r0 = returnFunc(ctx, repo)
309+ } else {
310+ r0 = ret.Get(0).(domain.TagInfo)
311+ }
312+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
313+ r1 = returnFunc(ctx, repo)
314+ } else {
315+ r1 = ret.Get(1).(bool)
316+ }
317+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
318+ r2 = returnFunc(ctx, repo)
319+ } else {
320+ r2 = ret.Error(2)
321+ }
322+ return r0, r1, r2
323+}
324+
325+// RepoReader_LatestTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestTag'
326+type RepoReader_LatestTag_Call struct {
327+ *mock.Call
328+}
329+
330+// LatestTag is a helper method to define mock.On call
331+// - ctx context.Context
332+// - repo domain.RepoName
333+func (_e *RepoReader_Expecter) LatestTag(ctx any, repo any) *RepoReader_LatestTag_Call {
334+ return &RepoReader_LatestTag_Call{Call: _e.mock.On("LatestTag", ctx, repo)}
335+}
336+
337+func (_c *RepoReader_LatestTag_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *RepoReader_LatestTag_Call {
338+ _c.Call.Run(func(args mock.Arguments) {
339+ var arg0 context.Context
340+ if args[0] != nil {
341+ arg0 = args[0].(context.Context)
342+ }
343+ var arg1 domain.RepoName
344+ if args[1] != nil {
345+ arg1 = args[1].(domain.RepoName)
346+ }
347+ run(
348+ arg0,
349+ arg1,
350+ )
351+ })
352+ return _c
353+}
354+
355+func (_c *RepoReader_LatestTag_Call) Return(tag domain.TagInfo, ok bool, err error) *RepoReader_LatestTag_Call {
356+ _c.Call.Return(tag, ok, err)
357+ return _c
358+}
359+
360+func (_c *RepoReader_LatestTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error)) *RepoReader_LatestTag_Call {
361+ _c.Call.Return(run)
362+ return _c
363+}
364+
214365 // ListCommits provides a mock function for the type RepoReader
215366 func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
216367 ret := _mock.Called(ctx, repo, q)
@@ -7,6 +7,7 @@ package mocks
7 import (7 import (
8 "context"8 "context"
9 "io"9 "io"
10+ "time"
10 11
11 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"13 "bnema.dev/ermite/internal/ports"
@@ -49,6 +50,84 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
49 return &RepoReader_Expecter{mock: &_m.Mock}50 return &RepoReader_Expecter{mock: &_m.Mock}
50 }51 }
51 52
53+// CountCommits provides a mock function for the type RepoReader
54+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {
55+ ret := _mock.Called(ctx, repo, head, since)
56+
57+ if len(ret) == 0 {
58+ panic("no return value specified for CountCommits")
59+ }
60+
61+ var r0 int
62+ var r1 error
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok {
64+ return returnFunc(ctx, repo, head, since)
65+ }
66+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {
67+ r0 = returnFunc(ctx, repo, head, since)
68+ } else {
69+ r0 = ret.Get(0).(int)
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
72+ r1 = returnFunc(ctx, repo, head, since)
73+ } else {
74+ r1 = ret.Error(1)
75+ }
76+ return r0, r1
77+}
78+
79+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
80+type RepoReader_CountCommits_Call struct {
81+ *mock.Call
82+}
83+
84+// CountCommits is a helper method to define mock.On call
85+// - ctx context.Context
86+// - repo domain.RepoName
87+// - head domain.ObjectID
88+// - since time.Time
89+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call {
90+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)}
91+}
92+
93+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 domain.RepoName
100+ if args[1] != nil {
101+ arg1 = args[1].(domain.RepoName)
102+ }
103+ var arg2 domain.ObjectID
104+ if args[2] != nil {
105+ arg2 = args[2].(domain.ObjectID)
106+ }
107+ var arg3 time.Time
108+ if args[3] != nil {
109+ arg3 = args[3].(time.Time)
110+ }
111+ run(
112+ arg0,
113+ arg1,
114+ arg2,
115+ arg3,
116+ )
117+ })
118+ return _c
119+}
120+
121+func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122+ _c.Call.Return(n, err)
123+ return _c
124+}
125+
126+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)) *RepoReader_CountCommits_Call {
127+ _c.Call.Return(run)
128+ return _c
129+}
130+
52 // GetCommit provides a mock function for the type RepoReader131 // GetCommit provides a mock function for the type RepoReader
53 func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {132 func (_mock *RepoReader) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) {
54 ret := _mock.Called(ctx, repo, rev, limits)133 ret := _mock.Called(ctx, repo, rev, limits)
@@ -211,6 +290,78 @@ func (_c *RepoReader_GetTree_Call) RunAndReturn(run func(ctx context.Context, re
211 return _c290 return _c
212 }291 }
213 292
293+// LatestTag provides a mock function for the type RepoReader
294+func (_mock *RepoReader) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
295+ ret := _mock.Called(ctx, repo)
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for LatestTag")
299+ }
300+
301+ var r0 domain.TagInfo
302+ var r1 bool
303+ var r2 error
304+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.TagInfo, bool, error)); ok {
305+ return returnFunc(ctx, repo)
306+ }
307+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.TagInfo); ok {
308+ r0 = returnFunc(ctx, repo)
309+ } else {
310+ r0 = ret.Get(0).(domain.TagInfo)
311+ }
312+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
313+ r1 = returnFunc(ctx, repo)
314+ } else {
315+ r1 = ret.Get(1).(bool)
316+ }
317+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
318+ r2 = returnFunc(ctx, repo)
319+ } else {
320+ r2 = ret.Error(2)
321+ }
322+ return r0, r1, r2
323+}
324+
325+// RepoReader_LatestTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestTag'
326+type RepoReader_LatestTag_Call struct {
327+ *mock.Call
328+}
329+
330+// LatestTag is a helper method to define mock.On call
331+// - ctx context.Context
332+// - repo domain.RepoName
333+func (_e *RepoReader_Expecter) LatestTag(ctx any, repo any) *RepoReader_LatestTag_Call {
334+ return &RepoReader_LatestTag_Call{Call: _e.mock.On("LatestTag", ctx, repo)}
335+}
336+
337+func (_c *RepoReader_LatestTag_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *RepoReader_LatestTag_Call {
338+ _c.Call.Run(func(args mock.Arguments) {
339+ var arg0 context.Context
340+ if args[0] != nil {
341+ arg0 = args[0].(context.Context)
342+ }
343+ var arg1 domain.RepoName
344+ if args[1] != nil {
345+ arg1 = args[1].(domain.RepoName)
346+ }
347+ run(
348+ arg0,
349+ arg1,
350+ )
351+ })
352+ return _c
353+}
354+
355+func (_c *RepoReader_LatestTag_Call) Return(tag domain.TagInfo, ok bool, err error) *RepoReader_LatestTag_Call {
356+ _c.Call.Return(tag, ok, err)
357+ return _c
358+}
359+
360+func (_c *RepoReader_LatestTag_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error)) *RepoReader_LatestTag_Call {
361+ _c.Call.Return(run)
362+ return _c
363+}
364+
214 // ListCommits provides a mock function for the type RepoReader365 // ListCommits provides a mock function for the type RepoReader
215 func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {366 func (_mock *RepoReader) ListCommits(ctx context.Context, repo domain.RepoName, q ports.CommitQuery) (ports.CommitPage, error) {
216 ret := _mock.Called(ctx, repo, q)367 ret := _mock.Called(ctx, repo, q)
M internal/ports/ports.go +6 -0
@@ -178,6 +178,12 @@ type RepoReader interface {
178178 // GetCommit returns a commit with its statistics and its diff against the
179179 // first parent, bounded by limits.
180180 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
181+ // CountCommits counts the commits reachable from head; with a non-zero
182+ // since, only those committed at or after it.
183+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)
184+ // LatestTag is the most recently created tag (the commit date for a
185+ // lightweight tag); ok is false when the repository has no tag.
186+ LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
181187 }
182188
183189 // HTTPBackend tells how to run `git http-backend` for one request.
@@ -178,6 +178,12 @@ type RepoReader interface {
178 // GetCommit returns a commit with its statistics and its diff against the178 // GetCommit returns a commit with its statistics and its diff against the
179 // first parent, bounded by limits.179 // first parent, bounded by limits.
180 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)180 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
181+ // CountCommits counts the commits reachable from head; with a non-zero
182+ // since, only those committed at or after it.
183+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)
184+ // LatestTag is the most recently created tag (the commit date for a
185+ // lightweight tag); ok is false when the repository has no tag.
186+ LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
181 }187 }
182 188
183 // HTTPBackend tells how to run `git http-backend` for one request.189 // HTTPBackend tells how to run `git http-backend` for one request.
M internal/ports/services.go +3 -0
@@ -96,6 +96,9 @@ type BrowseService interface {
9696 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
9797 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
9898 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
99+ // RepoStats counts the commits of the branch whose head is given, and
100+ // names the latest tag.
101+ RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
99102 }
100103
101104 // EventService streams the events of the forge. *usecase.Events implements it.
@@ -96,6 +96,9 @@ type BrowseService interface {
96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)96 OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)97 ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListCommitsParams) (HistoryPage, error)
98 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)98 GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)
99+ // RepoStats counts the commits of the branch whose head is given, and
100+ // names the latest tag.
101+ RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (RepoStats, error)
99 }102 }
100 103
101 // EventService streams the events of the forge. *usecase.Events implements it.104 // EventService streams the events of the forge. *usecase.Events implements it.
M internal/readertest/conformance.go +62 -0
@@ -49,6 +49,7 @@ func Conformance(t *testing.T, mk Maker) {
4949 {"GetTreeIsBounded", testGetTreeIsBounded},
5050 {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
5151 {"ListPaths", testListPaths},
52+ {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag},
5253 } {
5354 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
5455 }
@@ -104,6 +105,67 @@ func testListRefs(t *testing.T, mk Maker) {
104105 }, got)
105106 }
106107
108+func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
109+ ctx := context.Background()
110+ g, data := mk(t)
111+ f := NewFixture(t, data, "stats")
112+
113+ _, ok, err := g.LatestTag(ctx, f.Name)
114+ require.NoError(t, err)
115+ assert.False(t, ok, "no tag yet")
116+
117+ var ids []string
118+ for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04
119+ ids = append(ids, f.Commit("c"+itoa(i)))
120+ }
121+ head := MustID(t, ids[3])
122+ n, err := g.CountCommits(ctx, f.Name, head, time.Time{})
123+ require.NoError(t, err)
124+ assert.Equal(t, 4, n)
125+ n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))
126+ require.NoError(t, err)
127+ assert.Equal(t, 2, n, "the commits of 12:03 and 12:04")
128+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{})
129+ require.NoError(t, err)
130+ assert.Equal(t, 2, n, "from another commit")
131+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})
132+ assert.ErrorIs(t, err, domain.ErrNotFound)
133+
134+ // Dates out of order: a commit dated in the past on top of recent ones
135+ // does not hide them.
136+ recent := f.When
137+ f.When = recent.Add(-48 * time.Hour)
138+ old := f.Commit("backdated")
139+ f.When = recent
140+ n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))
141+ require.NoError(t, err)
142+ assert.Equal(t, 2, n, "the recent commits behind an old one count")
143+
144+ // The latest tag is the one made last: an annotated tag by its own date,
145+ // a lightweight one by the date of its commit.
146+ f.Git("tag", "light-old", ids[0])
147+ f.When = f.When.Add(time.Hour)
148+ f.Git("tag", "-a", "-m", "new", "v2", ids[0])
149+ f.Git("tag", "light-new", ids[3])
150+ tag, ok, err := g.LatestTag(ctx, f.Name)
151+ require.NoError(t, err)
152+ require.True(t, ok)
153+ assert.Equal(t, "v2", tag.Name)
154+ assert.True(t, tag.At.Equal(f.When), "%v", tag.At)
155+ assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit")
156+
157+ f.When = f.When.Add(time.Hour)
158+ f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}")
159+ tag, ok, err = g.LatestTag(ctx, f.Name)
160+ require.NoError(t, err)
161+ require.True(t, ok)
162+ assert.Equal(t, "tree-tag", tag.Name)
163+ assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit")
164+
165+ _, _, err = g.LatestTag(ctx, RepoName(t, "nope"))
166+ assert.ErrorIs(t, err, domain.ErrNotFound)
167+}
168+
107169 func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
108170 ctx := context.Background()
109171 g, data := mk(t)
@@ -49,6 +49,7 @@ func Conformance(t *testing.T, mk Maker) {
49 {"GetTreeIsBounded", testGetTreeIsBounded},49 {"GetTreeIsBounded", testGetTreeIsBounded},
50 {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},50 {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
51 {"ListPaths", testListPaths},51 {"ListPaths", testListPaths},
52+ {"CountCommitsAndLatestTag", testCountCommitsAndLatestTag},
52 } {53 } {
53 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })54 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
54 }55 }
@@ -104,6 +105,67 @@ func testListRefs(t *testing.T, mk Maker) {
104 }, got)105 }, got)
105 }106 }
106 107
108+func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
109+ ctx := context.Background()
110+ g, data := mk(t)
111+ f := NewFixture(t, data, "stats")
112+
113+ _, ok, err := g.LatestTag(ctx, f.Name)
114+ require.NoError(t, err)
115+ assert.False(t, ok, "no tag yet")
116+
117+ var ids []string
118+ for i := range 4 { // committed at 12:01, 12:02, 12:03, 12:04
119+ ids = append(ids, f.Commit("c"+itoa(i)))
120+ }
121+ head := MustID(t, ids[3])
122+ n, err := g.CountCommits(ctx, f.Name, head, time.Time{})
123+ require.NoError(t, err)
124+ assert.Equal(t, 4, n)
125+ n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))
126+ require.NoError(t, err)
127+ assert.Equal(t, 2, n, "the commits of 12:03 and 12:04")
128+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]), time.Time{})
129+ require.NoError(t, err)
130+ assert.Equal(t, 2, n, "from another commit")
131+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})
132+ assert.ErrorIs(t, err, domain.ErrNotFound)
133+
134+ // Dates out of order: a commit dated in the past on top of recent ones
135+ // does not hide them.
136+ recent := f.When
137+ f.When = recent.Add(-48 * time.Hour)
138+ old := f.Commit("backdated")
139+ f.When = recent
140+ n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))
141+ require.NoError(t, err)
142+ assert.Equal(t, 2, n, "the recent commits behind an old one count")
143+
144+ // The latest tag is the one made last: an annotated tag by its own date,
145+ // a lightweight one by the date of its commit.
146+ f.Git("tag", "light-old", ids[0])
147+ f.When = f.When.Add(time.Hour)
148+ f.Git("tag", "-a", "-m", "new", "v2", ids[0])
149+ f.Git("tag", "light-new", ids[3])
150+ tag, ok, err := g.LatestTag(ctx, f.Name)
151+ require.NoError(t, err)
152+ require.True(t, ok)
153+ assert.Equal(t, "v2", tag.Name)
154+ assert.True(t, tag.At.Equal(f.When), "%v", tag.At)
155+ assert.Equal(t, ids[0], tag.CommitID.String(), "an annotated tag is peeled to its commit")
156+
157+ f.When = f.When.Add(time.Hour)
158+ f.Git("tag", "-a", "-m", "tree", "tree-tag", ids[0]+"^{tree}")
159+ tag, ok, err = g.LatestTag(ctx, f.Name)
160+ require.NoError(t, err)
161+ require.True(t, ok)
162+ assert.Equal(t, "tree-tag", tag.Name)
163+ assert.True(t, tag.CommitID.IsZero(), "a tag of a tree has no commit")
164+
165+ _, _, err = g.LatestTag(ctx, RepoName(t, "nope"))
166+ assert.ErrorIs(t, err, domain.ErrNotFound)
167+}
168+
107 func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {169 func testListRefsEmptyAndMissing(t *testing.T, mk Maker) {
108 ctx := context.Background()170 ctx := context.Background()
109 g, data := mk(t)171 g, data := mk(t)
M internal/usecase/browse.go +2 -0
@@ -29,6 +29,8 @@ type Browse struct {
2929 // they never change, and computing them costs a history walk. Nil
3030 // disables it.
3131 history *historyCache
32+ // stats keeps the figures of the home page.
33+ stats statsCache
3234 }
3335
3436 var _ ports.BrowseService = (*Browse)(nil)
@@ -29,6 +29,8 @@ type Browse struct {
29 // they never change, and computing them costs a history walk. Nil29 // they never change, and computing them costs a history walk. Nil
30 // disables it.30 // disables it.
31 history *historyCache31 history *historyCache
32+ // stats keeps the figures of the home page.
33+ stats statsCache
32 }34 }
33 35
34 var _ ports.BrowseService = (*Browse)(nil)36 var _ ports.BrowseService = (*Browse)(nil)
A internal/usecase/browse_stats.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+const (
15+ // maxStatsEntries bounds the cache of figures; when full it is emptied,
16+ // and the entries of the current heads come back on the next read.
17+ maxStatsEntries = 1024
18+ // statsTTL is how long figures are served: the 30-day window moves
19+ // with time even when the repository does not.
20+ statsTTL = 10 * time.Minute
21+)
22+
23+// statsKey names the figures of one state of a repository: its life (the
24+// creation time), its last push or merge (which every ref change, tags
25+// included, moves) and the head counted from.
26+type statsKey struct {
27+ repo domain.RepoName
28+ created int64
29+ pushed int64
30+ head domain.ObjectID
31+}
32+
33+type statsEntry struct {
34+ st ports.RepoStats
35+ at time.Time
36+ err error
37+ // done is closed when st and err are set; until then the entry is the
38+ // read in flight that the other callers wait for.
39+ done chan struct{}
40+}
41+
42+// statsCache keeps the figures of the home page, so that a request reads Git
43+// once per repository state and per statsTTL, and concurrent requests share
44+// one read.
45+type statsCache struct {
46+ mu sync.Mutex
47+ m map[statsKey]*statsEntry
48+}
49+
50+// get returns the figures of key, computing them with read when they are
51+// missing, failed or older than statsTTL.
52+func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) {
53+ c.mu.Lock()
54+ e, ok := c.m[key]
55+ if ok {
56+ select {
57+ case <-e.done:
58+ if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) {
59+ ok = false
60+ }
61+ default: // in flight
62+ }
63+ }
64+ if !ok {
65+ if c.m == nil || len(c.m) >= maxStatsEntries {
66+ c.m = make(map[statsKey]*statsEntry)
67+ }
68+ e = &statsEntry{at: now, done: make(chan struct{})}
69+ c.m[key] = e
70+ c.mu.Unlock()
71+ e.st, e.err = read()
72+ close(e.done)
73+ return cloneStats(e.st), e.err
74+ }
75+ c.mu.Unlock()
76+ select {
77+ case <-e.done:
78+ return cloneStats(e.st), e.err
79+ case <-ctx.Done():
80+ return ports.RepoStats{}, ctx.Err()
81+ }
82+}
83+
84+func cloneStats(st ports.RepoStats) ports.RepoStats {
85+ if st.LatestTag != nil {
86+ t := *st.LatestTag
87+ st.LatestTag = &t
88+ }
89+ return st
90+}
91+
92+// RepoStats counts the commits reachable from head, all of them and those of
93+// the last ports.RecentDays days before now, and names the latest tag. The
94+// answer is kept for statsTTL, or until the repository is pushed to.
95+func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96+ r, err := b.authorize(ctx, p, repo)
97+ if err != nil {
98+ return ports.RepoStats{}, err
99+ }
100+ key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head}
101+ return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) {
102+ // The read outlives a caller that gives up: the others wait for it.
103+ rctx := context.WithoutCancel(ctx)
104+ var st ports.RepoStats
105+ var err error
106+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {
107+ return ports.RepoStats{}, err
108+ }
109+ since := now.AddDate(0, 0, -ports.RecentDays)
110+ if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {
111+ return ports.RepoStats{}, err
112+ }
113+ tag, ok, err := b.reader.LatestTag(rctx, repo)
114+ if err != nil {
115+ return ports.RepoStats{}, err
116+ }
117+ if ok {
118+ st.LatestTag = &tag
119+ }
120+ return st, nil
121+ })
122+}
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+const (
15+ // maxStatsEntries bounds the cache of figures; when full it is emptied,
16+ // and the entries of the current heads come back on the next read.
17+ maxStatsEntries = 1024
18+ // statsTTL is how long figures are served: the 30-day window moves
19+ // with time even when the repository does not.
20+ statsTTL = 10 * time.Minute
21+)
22+
23+// statsKey names the figures of one state of a repository: its life (the
24+// creation time), its last push or merge (which every ref change, tags
25+// included, moves) and the head counted from.
26+type statsKey struct {
27+ repo domain.RepoName
28+ created int64
29+ pushed int64
30+ head domain.ObjectID
31+}
32+
33+type statsEntry struct {
34+ st ports.RepoStats
35+ at time.Time
36+ err error
37+ // done is closed when st and err are set; until then the entry is the
38+ // read in flight that the other callers wait for.
39+ done chan struct{}
40+}
41+
42+// statsCache keeps the figures of the home page, so that a request reads Git
43+// once per repository state and per statsTTL, and concurrent requests share
44+// one read.
45+type statsCache struct {
46+ mu sync.Mutex
47+ m map[statsKey]*statsEntry
48+}
49+
50+// get returns the figures of key, computing them with read when they are
51+// missing, failed or older than statsTTL.
52+func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read func() (ports.RepoStats, error)) (ports.RepoStats, error) {
53+ c.mu.Lock()
54+ e, ok := c.m[key]
55+ if ok {
56+ select {
57+ case <-e.done:
58+ if e.err != nil || now.Sub(e.at) >= statsTTL || now.Before(e.at) {
59+ ok = false
60+ }
61+ default: // in flight
62+ }
63+ }
64+ if !ok {
65+ if c.m == nil || len(c.m) >= maxStatsEntries {
66+ c.m = make(map[statsKey]*statsEntry)
67+ }
68+ e = &statsEntry{at: now, done: make(chan struct{})}
69+ c.m[key] = e
70+ c.mu.Unlock()
71+ e.st, e.err = read()
72+ close(e.done)
73+ return cloneStats(e.st), e.err
74+ }
75+ c.mu.Unlock()
76+ select {
77+ case <-e.done:
78+ return cloneStats(e.st), e.err
79+ case <-ctx.Done():
80+ return ports.RepoStats{}, ctx.Err()
81+ }
82+}
83+
84+func cloneStats(st ports.RepoStats) ports.RepoStats {
85+ if st.LatestTag != nil {
86+ t := *st.LatestTag
87+ st.LatestTag = &t
88+ }
89+ return st
90+}
91+
92+// RepoStats counts the commits reachable from head, all of them and those of
93+// the last ports.RecentDays days before now, and names the latest tag. The
94+// answer is kept for statsTTL, or until the repository is pushed to.
95+func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96+ r, err := b.authorize(ctx, p, repo)
97+ if err != nil {
98+ return ports.RepoStats{}, err
99+ }
100+ key := statsKey{repo: r.Name, created: repoEpoch(r), pushed: r.PushedAt.UnixNano(), head: head}
101+ return b.stats.get(ctx, key, now, func() (ports.RepoStats, error) {
102+ // The read outlives a caller that gives up: the others wait for it.
103+ rctx := context.WithoutCancel(ctx)
104+ var st ports.RepoStats
105+ var err error
106+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {
107+ return ports.RepoStats{}, err
108+ }
109+ since := now.AddDate(0, 0, -ports.RecentDays)
110+ if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {
111+ return ports.RepoStats{}, err
112+ }
113+ tag, ok, err := b.reader.LatestTag(rctx, repo)
114+ if err != nil {
115+ return ports.RepoStats{}, err
116+ }
117+ if ok {
118+ st.LatestTag = &tag
119+ }
120+ return st, nil
121+ })
122+}
M internal/usecase/browse_test.go +93 -0
@@ -5,11 +5,16 @@ package usecase_test
55 import (
66 "context"
77 "encoding/base64"
8+ "errors"
89 "fmt"
910 "io"
11+ "runtime"
1012 "strconv"
1113 "strings"
14+ "sync"
15+ "sync/atomic"
1216 "testing"
17+ "time"
1318
1419 "github.com/stretchr/testify/assert"
1520 "github.com/stretchr/testify/mock"
@@ -104,7 +109,95 @@ func browseOps() []browseOp {
104109 }, func(r *mocks.RepoReader) {
105110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
106111 }},
112+ {"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
113+ _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114+ return err
115+ }, func(r *mocks.RepoReader) {
116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118+ }},
119+ }
120+}
121+
122+// RepoStats reads Git once per state of the repository (head and last push)
123+// and per 10 minutes; concurrent callers share the read, and a failure is
124+// not kept.
125+func TestRepoStatsCache(t *testing.T) {
126+ t.Parallel()
127+ ctx := context.Background()
128+ f := newBrowseFixture(t)
129+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)}
130+ var mu sync.Mutex
131+ f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
132+ mu.Lock()
133+ defer mu.Unlock()
134+ return repo, nil
135+ })
136+ now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137+ var reads atomic.Int32
138+ gate := make(chan struct{})
139+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(
140+ func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {
141+ reads.Add(1)
142+ <-gate
143+ return 900, nil
144+ })
145+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil)
146+ tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148+
149+ stats := func(head string, at time.Time) ports.RepoStats {
150+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at)
151+ require.NoError(t, err)
152+ return st
153+ }
154+
155+ // Eight concurrent requests: one read.
156+ var wg sync.WaitGroup
157+ for range 8 {
158+ wg.Go(func() {
159+ st := stats("a", now)
160+ assert.Equal(t, 900, st.Commits)
161+ assert.Equal(t, 12, st.Recent)
162+ assert.Equal(t, tag, *st.LatestTag)
163+ })
164+ }
165+ for reads.Load() == 0 {
166+ runtime.Gosched()
107167 }
168+ close(gate)
169+ wg.Wait()
170+ assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read")
171+
172+ stats("a", now.Add(9*time.Minute))
173+ assert.EqualValues(t, 1, reads.Load(), "served from the cache")
174+ stats("a", now.Add(10*time.Minute))
175+ assert.EqualValues(t, 2, reads.Load(), "expired")
176+ stats("b", now.Add(10*time.Minute))
177+ assert.EqualValues(t, 3, reads.Load(), "another head")
178+ mu.Lock()
179+ repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included
180+ mu.Unlock()
181+ stats("b", now.Add(10*time.Minute))
182+ assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183+}
184+
185+func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186+ t.Parallel()
187+ ctx := context.Background()
188+ f := newBrowseFixture(t)
189+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191+ now := time.Now()
192+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()
193+ _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194+ require.Error(t, err)
195+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()
196+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198+ require.NoError(t, err)
199+ assert.Equal(t, 3, st.Commits)
200+ assert.Nil(t, st.LatestTag)
108201 }
109202
110203 // TestBrowseAccess is the access rule over every read use case and every
@@ -5,11 +5,16 @@ package usecase_test
5 import (5 import (
6 "context"6 "context"
7 "encoding/base64"7 "encoding/base64"
8+ "errors"
8 "fmt"9 "fmt"
9 "io"10 "io"
11+ "runtime"
10 "strconv"12 "strconv"
11 "strings"13 "strings"
14+ "sync"
15+ "sync/atomic"
12 "testing"16 "testing"
17+ "time"
13 18
14 "github.com/stretchr/testify/assert"19 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"20 "github.com/stretchr/testify/mock"
@@ -104,7 +109,95 @@ func browseOps() []browseOp {
104 }, func(r *mocks.RepoReader) {109 }, func(r *mocks.RepoReader) {
105 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)110 r.EXPECT().GetCommit(mock.Anything, mock.Anything, mock.Anything, domain.DefaultDiffLimits()).Return(domain.CommitDetail{}, nil)
106 }},111 }},
112+ {"RepoStats", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error {
113+ _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114+ return err
115+ }, func(r *mocks.RepoReader) {
116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118+ }},
119+ }
120+}
121+
122+// RepoStats reads Git once per state of the repository (head and last push)
123+// and per 10 minutes; concurrent callers share the read, and a failure is
124+// not kept.
125+func TestRepoStatsCache(t *testing.T) {
126+ t.Parallel()
127+ ctx := context.Background()
128+ f := newBrowseFixture(t)
129+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic, CreatedAt: time.Unix(100, 0), PushedAt: time.Unix(200, 0)}
130+ var mu sync.Mutex
131+ f.meta.EXPECT().Get(mock.Anything, repo.Name).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
132+ mu.Lock()
133+ defer mu.Unlock()
134+ return repo, nil
135+ })
136+ now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137+ var reads atomic.Int32
138+ gate := make(chan struct{})
139+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(
140+ func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {
141+ reads.Add(1)
142+ <-gate
143+ return 900, nil
144+ })
145+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, mock.MatchedBy(func(s time.Time) bool { return !s.IsZero() })).Return(12, nil)
146+ tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148+
149+ stats := func(head string, at time.Time) ports.RepoStats {
150+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid(head), at)
151+ require.NoError(t, err)
152+ return st
153+ }
154+
155+ // Eight concurrent requests: one read.
156+ var wg sync.WaitGroup
157+ for range 8 {
158+ wg.Go(func() {
159+ st := stats("a", now)
160+ assert.Equal(t, 900, st.Commits)
161+ assert.Equal(t, 12, st.Recent)
162+ assert.Equal(t, tag, *st.LatestTag)
163+ })
164+ }
165+ for reads.Load() == 0 {
166+ runtime.Gosched()
107 }167 }
168+ close(gate)
169+ wg.Wait()
170+ assert.EqualValues(t, 1, reads.Load(), "concurrent callers share the read")
171+
172+ stats("a", now.Add(9*time.Minute))
173+ assert.EqualValues(t, 1, reads.Load(), "served from the cache")
174+ stats("a", now.Add(10*time.Minute))
175+ assert.EqualValues(t, 2, reads.Load(), "expired")
176+ stats("b", now.Add(10*time.Minute))
177+ assert.EqualValues(t, 3, reads.Load(), "another head")
178+ mu.Lock()
179+ repo.PushedAt = time.Unix(300, 0) // a push, a tag-only one included
180+ mu.Unlock()
181+ stats("b", now.Add(10*time.Minute))
182+ assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183+}
184+
185+func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186+ t.Parallel()
187+ ctx := context.Background()
188+ f := newBrowseFixture(t)
189+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191+ now := time.Now()
192+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()
193+ _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194+ require.Error(t, err)
195+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()
196+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198+ require.NoError(t, err)
199+ assert.Equal(t, 3, st.Commits)
200+ assert.Nil(t, st.LatestTag)
108 }201 }
109 202
110 // TestBrowseAccess is the access rule over every read use case and every203 // TestBrowseAccess is the access rule over every read use case and every

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close