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 generated file: collapsed
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
@@ -540,7 +540,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
540540 }()
541541 }
542542 ctx = templ.InitializeContext(ctx)
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><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></thead>")
543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><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></thead>")
544544 if templ_7745c5c3_Err != nil {
545545 return templ_7745c5c3_Err
546546 }
@@ -612,14 +612,14 @@ func refRow(kind string, r RefRow) templ.Component {
612612 return templ_7745c5c3_Err
613613 }
614614 }
615- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind mute\">")
615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"c-kind mute\">")
616616 if templ_7745c5c3_Err != nil {
617617 return templ_7745c5c3_Err
618618 }
619619 var templ_7745c5c3_Var27 string
620620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621621 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 138, Col: 32}
622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 137, Col: 32}
623623 }
624624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
625625 if templ_7745c5c3_Err != nil {
@@ -632,7 +632,7 @@ func refRow(kind string, r RefRow) templ.Component {
632632 var templ_7745c5c3_Var28 templ.SafeURL
633633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634634 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 140, Col: 34}
635+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 34}
636636 }
637637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
638638 if templ_7745c5c3_Err != nil {
@@ -645,7 +645,7 @@ func refRow(kind string, r RefRow) templ.Component {
645645 var templ_7745c5c3_Var29 string
646646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647647 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 140, Col: 45}
648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 45}
649649 }
650650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
651651 if templ_7745c5c3_Err != nil {
@@ -673,7 +673,7 @@ func refRow(kind string, r RefRow) templ.Component {
673673 var templ_7745c5c3_Var30 templ.SafeURL
674674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675675 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 147, Col: 36}
676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 36}
677677 }
678678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
679679 if templ_7745c5c3_Err != nil {
@@ -686,7 +686,7 @@ func refRow(kind string, r RefRow) templ.Component {
686686 var templ_7745c5c3_Var31 string
687687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688688 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 147, Col: 48}
689+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 48}
690690 }
691691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
692692 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func refRow(kind string, r RefRow) templ.Component {
700700 var templ_7745c5c3_Var32 string
701701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 13}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 13}
704704 }
705705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
706706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func refRow(kind string, r RefRow) templ.Component {
718718 var templ_7745c5c3_Var33 string
719719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 153, Col: 31}
721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 31}
722722 }
723723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
724724 if templ_7745c5c3_Err != nil {
@@ -736,7 +736,7 @@ func refRow(kind string, r RefRow) templ.Component {
736736 var templ_7745c5c3_Var34 string
737737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738738 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 29}
739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 29}
740740 }
741741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
742742 if templ_7745c5c3_Err != nil {
@@ -749,7 +749,7 @@ func refRow(kind string, r RefRow) templ.Component {
749749 var templ_7745c5c3_Var35 string
750750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751751 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 48}
752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 48}
753753 }
754754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
755755 if templ_7745c5c3_Err != nil {
@@ -762,7 +762,7 @@ func refRow(kind string, r RefRow) templ.Component {
762762 var templ_7745c5c3_Var36 string
763763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764764 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 59}
765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 59}
766766 }
767767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
768768 if templ_7745c5c3_Err != nil {
@@ -859,7 +859,7 @@ func State(l Layout, p StatePage) templ.Component {
859859 var templ_7745c5c3_Var40 string
860860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861861 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 30}
862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 172, Col: 30}
863863 }
864864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
865865 if templ_7745c5c3_Err != nil {
@@ -872,7 +872,7 @@ func State(l Layout, p StatePage) templ.Component {
872872 var templ_7745c5c3_Var41 string
873873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874874 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 33}
875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 33}
876876 }
877877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
878878 if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func State(l Layout, p StatePage) templ.Component {
890890 var templ_7745c5c3_Var42 string
891891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892892 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 176, Col: 25}
893+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 25}
894894 }
895895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
896896 if templ_7745c5c3_Err != nil {
@@ -919,7 +919,7 @@ func State(l Layout, p StatePage) templ.Component {
919919 var templ_7745c5c3_Var43 templ.SafeURL
920920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921921 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 36}
922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}
923923 }
924924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
925925 if templ_7745c5c3_Err != nil {
@@ -932,7 +932,7 @@ func State(l Layout, p StatePage) templ.Component {
932932 var templ_7745c5c3_Var44 string
933933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934934 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 48}
935+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}
936936 }
937937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
938938 if templ_7745c5c3_Err != nil {
@@ -1002,7 +1002,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10021002 var templ_7745c5c3_Var47 string
10031003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10041004 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 198, Col: 33}
1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 33}
10061006 }
10071007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
10081008 if templ_7745c5c3_Err != nil {
@@ -1020,7 +1020,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10201020 var templ_7745c5c3_Var48 string
10211021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)
10221022 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 200, Col: 25}
1023+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 199, Col: 25}
10241024 }
10251025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
10261026 if templ_7745c5c3_Err != nil {
@@ -1038,7 +1038,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10381038 var templ_7745c5c3_Var49 templ.SafeURL
10391039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
10401040 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 73}
1041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 73}
10421042 }
10431043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
10441044 if templ_7745c5c3_Err != nil {
@@ -1051,7 +1051,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10511051 var templ_7745c5c3_Var50 string
10521052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
10531053 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 203, Col: 56}
1054+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 56}
10551055 }
10561056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
10571057 if templ_7745c5c3_Err != nil {
@@ -1113,7 +1113,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11131113 var templ_7745c5c3_Var53 string
11141114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
11151115 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 215, Col: 33}
1116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 33}
11171117 }
11181118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
11191119 if templ_7745c5c3_Err != nil {
@@ -1126,7 +1126,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11261126 var templ_7745c5c3_Var54 string
11271127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
11281128 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 87}
1129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 87}
11301130 }
11311131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
11321132 if templ_7745c5c3_Err != nil {
@@ -1139,7 +1139,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11391139 var templ_7745c5c3_Var55 string
11401140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
11411141 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 112}
1142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 112}
11431143 }
11441144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
11451145 if templ_7745c5c3_Err != nil {
@@ -1152,7 +1152,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11521152 var templ_7745c5c3_Var56 string
11531153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
11541154 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 125}
1155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 125}
11561156 }
11571157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
11581158 if templ_7745c5c3_Err != nil {
@@ -1170,7 +1170,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11701170 var templ_7745c5c3_Var57 string
11711171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
11721172 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 25}
1173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 25}
11741174 }
11751175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
11761176 if templ_7745c5c3_Err != nil {
@@ -1236,7 +1236,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12361236 var templ_7745c5c3_Var60 string
12371237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
12381238 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 81}
1239+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 81}
12401240 }
12411241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
12421242 if templ_7745c5c3_Err != nil {
@@ -1247,19 +1247,19 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12471247 return templ_7745c5c3_Err
12481248 }
12491249 if len(p.Rows) > 0 {
1250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"cur\"></th><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1250+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
12511251 if templ_7745c5c3_Err != nil {
12521252 return templ_7745c5c3_Err
12531253 }
12541254 for _, r := range p.Rows {
1255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\">")
1255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"c-name\">")
12561256 if templ_7745c5c3_Err != nil {
12571257 return templ_7745c5c3_Err
12581258 }
12591259 var templ_7745c5c3_Var61 string
12601260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
12611261 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 34}
1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 34}
12631263 }
12641264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
12651265 if templ_7745c5c3_Err != nil {
@@ -1277,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12771277 var templ_7745c5c3_Var62 string
12781278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
12791279 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 253, Col: 32}
1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 32}
12811281 }
12821282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12831283 if templ_7745c5c3_Err != nil {
@@ -1295,7 +1295,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12951295 var templ_7745c5c3_Var63 string
12961296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
12971297 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 255, Col: 20}
1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 20}
12991299 }
13001300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
13011301 if templ_7745c5c3_Err != nil {
@@ -1313,7 +1313,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13131313 var templ_7745c5c3_Var64 string
13141314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
13151315 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 260, Col: 33}
1316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 33}
13171317 }
13181318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
13191319 if templ_7745c5c3_Err != nil {
@@ -1331,7 +1331,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13311331 var templ_7745c5c3_Var65 string
13321332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
13331333 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 262, Col: 21}
1334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 259, Col: 21}
13351335 }
13361336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13371337 if templ_7745c5c3_Err != nil {
@@ -1359,7 +1359,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13591359 var templ_7745c5c3_Var66 templ.SafeURL
13601360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
13611361 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 272, Col: 60}
1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 60}
13631363 }
13641364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
13651365 if templ_7745c5c3_Err != nil {
@@ -1372,7 +1372,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13721372 var templ_7745c5c3_Var67 string
13731373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
13741374 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 273, Col: 89}
1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 89}
13761376 }
13771377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
13781378 if templ_7745c5c3_Err != nil {
@@ -1405,7 +1405,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14051405 var templ_7745c5c3_Var68 string
14061406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
14071407 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 284, Col: 31}
1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 31}
14091409 }
14101410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
14111411 if templ_7745c5c3_Err != nil {
@@ -1423,7 +1423,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14231423 var templ_7745c5c3_Var69 string
14241424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
14251425 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 90}
1426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 90}
14271427 }
14281428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
14291429 if templ_7745c5c3_Err != nil {
@@ -1436,7 +1436,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14361436 var templ_7745c5c3_Var70 string
14371437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
14381438 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 115}
1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 115}
14401440 }
14411441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
14421442 if templ_7745c5c3_Err != nil {
@@ -540,7 +540,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
540 }()540 }()
541 }541 }
542 ctx = templ.InitializeContext(ctx)542 ctx = templ.InitializeContext(ctx)
543- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><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></thead>")543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"filter\"><label for=\"ref-filter\" class=\"sr\">filter</label> <input id=\"ref-filter\" type=\"search\" data-filter=\"#ref-rows > tr\" placeholder=\"Filter branches and tags…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><table class=\"list refs\" aria-label=\"Branches and tags\"><thead><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></thead>")
544 if templ_7745c5c3_Err != nil {544 if templ_7745c5c3_Err != nil {
545 return templ_7745c5c3_Err545 return templ_7745c5c3_Err
546 }546 }
@@ -612,14 +612,14 @@ func refRow(kind string, r RefRow) templ.Component {
612 return templ_7745c5c3_Err612 return templ_7745c5c3_Err
613 }613 }
614 }614 }
615- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind mute\">")615+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "><td class=\"c-kind mute\">")
616 if templ_7745c5c3_Err != nil {616 if templ_7745c5c3_Err != nil {
617 return templ_7745c5c3_Err617 return templ_7745c5c3_Err
618 }618 }
619 var templ_7745c5c3_Var27 string619 var templ_7745c5c3_Var27 string
620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621 if templ_7745c5c3_Err != nil {621 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 138, Col: 32}622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 137, Col: 32}
623 }623 }
624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
625 if templ_7745c5c3_Err != nil {625 if templ_7745c5c3_Err != nil {
@@ -632,7 +632,7 @@ func refRow(kind string, r RefRow) templ.Component {
632 var templ_7745c5c3_Var28 templ.SafeURL632 var templ_7745c5c3_Var28 templ.SafeURL
633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634 if templ_7745c5c3_Err != nil {634 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 140, Col: 34}635+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 34}
636 }636 }
637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
638 if templ_7745c5c3_Err != nil {638 if templ_7745c5c3_Err != nil {
@@ -645,7 +645,7 @@ func refRow(kind string, r RefRow) templ.Component {
645 var templ_7745c5c3_Var29 string645 var templ_7745c5c3_Var29 string
646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647 if templ_7745c5c3_Err != nil {647 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 140, Col: 45}648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 45}
649 }649 }
650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
651 if templ_7745c5c3_Err != nil {651 if templ_7745c5c3_Err != nil {
@@ -673,7 +673,7 @@ func refRow(kind string, r RefRow) templ.Component {
673 var templ_7745c5c3_Var30 templ.SafeURL673 var templ_7745c5c3_Var30 templ.SafeURL
674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 147, Col: 36}676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 36}
677 }677 }
678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
679 if templ_7745c5c3_Err != nil {679 if templ_7745c5c3_Err != nil {
@@ -686,7 +686,7 @@ func refRow(kind string, r RefRow) templ.Component {
686 var templ_7745c5c3_Var31 string686 var templ_7745c5c3_Var31 string
687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688 if templ_7745c5c3_Err != nil {688 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 147, Col: 48}689+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 48}
690 }690 }
691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
692 if templ_7745c5c3_Err != nil {692 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func refRow(kind string, r RefRow) templ.Component {
700 var templ_7745c5c3_Var32 string700 var templ_7745c5c3_Var32 string
701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 149, Col: 13}703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 13}
704 }704 }
705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
706 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func refRow(kind string, r RefRow) templ.Component {
718 var templ_7745c5c3_Var33 string718 var templ_7745c5c3_Var33 string
719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720 if templ_7745c5c3_Err != nil {720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 153, Col: 31}721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 31}
722 }722 }
723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
724 if templ_7745c5c3_Err != nil {724 if templ_7745c5c3_Err != nil {
@@ -736,7 +736,7 @@ func refRow(kind string, r RefRow) templ.Component {
736 var templ_7745c5c3_Var34 string736 var templ_7745c5c3_Var34 string
737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 29}739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 29}
740 }740 }
741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
742 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
@@ -749,7 +749,7 @@ func refRow(kind string, r RefRow) templ.Component {
749 var templ_7745c5c3_Var35 string749 var templ_7745c5c3_Var35 string
750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 48}752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 48}
753 }753 }
754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
755 if templ_7745c5c3_Err != nil {755 if templ_7745c5c3_Err != nil {
@@ -762,7 +762,7 @@ func refRow(kind string, r RefRow) templ.Component {
762 var templ_7745c5c3_Var36 string762 var templ_7745c5c3_Var36 string
763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 156, Col: 59}765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 59}
766 }766 }
767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
768 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
@@ -859,7 +859,7 @@ func State(l Layout, p StatePage) templ.Component {
859 var templ_7745c5c3_Var40 string859 var templ_7745c5c3_Var40 string
860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861 if templ_7745c5c3_Err != nil {861 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 30}862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 172, Col: 30}
863 }863 }
864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
865 if templ_7745c5c3_Err != nil {865 if templ_7745c5c3_Err != nil {
@@ -872,7 +872,7 @@ func State(l Layout, p StatePage) templ.Component {
872 var templ_7745c5c3_Var41 string872 var templ_7745c5c3_Var41 string
873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874 if templ_7745c5c3_Err != nil {874 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 174, Col: 33}875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 33}
876 }876 }
877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
878 if templ_7745c5c3_Err != nil {878 if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func State(l Layout, p StatePage) templ.Component {
890 var templ_7745c5c3_Var42 string890 var templ_7745c5c3_Var42 string
891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892 if templ_7745c5c3_Err != nil {892 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 176, Col: 25}893+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 25}
894 }894 }
895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
896 if templ_7745c5c3_Err != nil {896 if templ_7745c5c3_Err != nil {
@@ -919,7 +919,7 @@ func State(l Layout, p StatePage) templ.Component {
919 var templ_7745c5c3_Var43 templ.SafeURL919 var templ_7745c5c3_Var43 templ.SafeURL
920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 36}922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}
923 }923 }
924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
925 if templ_7745c5c3_Err != nil {925 if templ_7745c5c3_Err != nil {
@@ -932,7 +932,7 @@ func State(l Layout, p StatePage) templ.Component {
932 var templ_7745c5c3_Var44 string932 var templ_7745c5c3_Var44 string
933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934 if templ_7745c5c3_Err != nil {934 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 48}935+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}
936 }936 }
937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
938 if templ_7745c5c3_Err != nil {938 if templ_7745c5c3_Err != nil {
@@ -1002,7 +1002,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1002 var templ_7745c5c3_Var47 string1002 var templ_7745c5c3_Var47 string
1003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1004 if templ_7745c5c3_Err != nil {1004 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 198, Col: 33}1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 33}
1006 }1006 }
1007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))1007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1008 if templ_7745c5c3_Err != nil {1008 if templ_7745c5c3_Err != nil {
@@ -1020,7 +1020,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1020 var templ_7745c5c3_Var48 string1020 var templ_7745c5c3_Var48 string
1021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)1021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1022 if templ_7745c5c3_Err != nil {1022 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 200, Col: 25}1023+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 199, Col: 25}
1024 }1024 }
1025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))1025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1026 if templ_7745c5c3_Err != nil {1026 if templ_7745c5c3_Err != nil {
@@ -1038,7 +1038,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1038 var templ_7745c5c3_Var49 templ.SafeURL1038 var templ_7745c5c3_Var49 templ.SafeURL
1039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))1039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1040 if templ_7745c5c3_Err != nil {1040 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 73}1041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 73}
1042 }1042 }
1043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1044 if templ_7745c5c3_Err != nil {1044 if templ_7745c5c3_Err != nil {
@@ -1051,7 +1051,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1051 var templ_7745c5c3_Var50 string1051 var templ_7745c5c3_Var50 string
1052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1053 if templ_7745c5c3_Err != nil {1053 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 203, Col: 56}1054+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 56}
1055 }1055 }
1056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1057 if templ_7745c5c3_Err != nil {1057 if templ_7745c5c3_Err != nil {
@@ -1113,7 +1113,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1113 var templ_7745c5c3_Var53 string1113 var templ_7745c5c3_Var53 string
1114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1115 if templ_7745c5c3_Err != nil {1115 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 215, Col: 33}1116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 33}
1117 }1117 }
1118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1119 if templ_7745c5c3_Err != nil {1119 if templ_7745c5c3_Err != nil {
@@ -1126,7 +1126,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1126 var templ_7745c5c3_Var54 string1126 var templ_7745c5c3_Var54 string
1127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1128 if templ_7745c5c3_Err != nil {1128 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 87}1129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 87}
1130 }1130 }
1131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1132 if templ_7745c5c3_Err != nil {1132 if templ_7745c5c3_Err != nil {
@@ -1139,7 +1139,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1139 var templ_7745c5c3_Var55 string1139 var templ_7745c5c3_Var55 string
1140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1141 if templ_7745c5c3_Err != nil {1141 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 112}1142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 112}
1143 }1143 }
1144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)1144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
1145 if templ_7745c5c3_Err != nil {1145 if templ_7745c5c3_Err != nil {
@@ -1152,7 +1152,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1152 var templ_7745c5c3_Var56 string1152 var templ_7745c5c3_Var56 string
1153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1154 if templ_7745c5c3_Err != nil {1154 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 217, Col: 125}1155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 125}
1156 }1156 }
1157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1158 if templ_7745c5c3_Err != nil {1158 if templ_7745c5c3_Err != nil {
@@ -1170,7 +1170,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1170 var templ_7745c5c3_Var57 string1170 var templ_7745c5c3_Var57 string
1171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)1171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1172 if templ_7745c5c3_Err != nil {1172 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 221, Col: 25}1173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 25}
1174 }1174 }
1175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1176 if templ_7745c5c3_Err != nil {1176 if templ_7745c5c3_Err != nil {
@@ -1236,7 +1236,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1236 var templ_7745c5c3_Var60 string1236 var templ_7745c5c3_Var60 string
1237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))1237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1238 if templ_7745c5c3_Err != nil {1238 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 81}1239+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 81}
1240 }1240 }
1241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1242 if templ_7745c5c3_Err != nil {1242 if templ_7745c5c3_Err != nil {
@@ -1247,19 +1247,19 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1247 return templ_7745c5c3_Err1247 return templ_7745c5c3_Err
1248 }1248 }
1249 if len(p.Rows) > 0 {1249 if len(p.Rows) > 0 {
1250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"cur\"></th><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")1250+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1251 if templ_7745c5c3_Err != nil {1251 if templ_7745c5c3_Err != nil {
1252 return templ_7745c5c3_Err1252 return templ_7745c5c3_Err
1253 }1253 }
1254 for _, r := range p.Rows {1254 for _, r := range p.Rows {
1255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\">")1255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<tr><td class=\"c-name\">")
1256 if templ_7745c5c3_Err != nil {1256 if templ_7745c5c3_Err != nil {
1257 return templ_7745c5c3_Err1257 return templ_7745c5c3_Err
1258 }1258 }
1259 var templ_7745c5c3_Var61 string1259 var templ_7745c5c3_Var61 string
1260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1261 if templ_7745c5c3_Err != nil {1261 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 34}1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 34}
1263 }1263 }
1264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1265 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
@@ -1277,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1277 var templ_7745c5c3_Var62 string1277 var templ_7745c5c3_Var62 string
1278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)1278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1279 if templ_7745c5c3_Err != nil {1279 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 253, Col: 32}1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 32}
1281 }1281 }
1282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)1282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
1283 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
@@ -1295,7 +1295,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1295 var templ_7745c5c3_Var63 string1295 var templ_7745c5c3_Var63 string
1296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)1296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1297 if templ_7745c5c3_Err != nil {1297 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 255, Col: 20}1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 20}
1299 }1299 }
1300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1301 if templ_7745c5c3_Err != nil {1301 if templ_7745c5c3_Err != nil {
@@ -1313,7 +1313,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1313 var templ_7745c5c3_Var64 string1313 var templ_7745c5c3_Var64 string
1314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)1314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1315 if templ_7745c5c3_Err != nil {1315 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 260, Col: 33}1316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 33}
1317 }1317 }
1318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)1318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1319 if templ_7745c5c3_Err != nil {1319 if templ_7745c5c3_Err != nil {
@@ -1331,7 +1331,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1331 var templ_7745c5c3_Var65 string1331 var templ_7745c5c3_Var65 string
1332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)1332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1333 if templ_7745c5c3_Err != nil {1333 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 262, Col: 21}1334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 259, Col: 21}
1335 }1335 }
1336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))1336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1337 if templ_7745c5c3_Err != nil {1337 if templ_7745c5c3_Err != nil {
@@ -1359,7 +1359,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1359 var templ_7745c5c3_Var66 templ.SafeURL1359 var templ_7745c5c3_Var66 templ.SafeURL
1360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))1360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1361 if templ_7745c5c3_Err != nil {1361 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 272, Col: 60}1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 60}
1363 }1363 }
1364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1365 if templ_7745c5c3_Err != nil {1365 if templ_7745c5c3_Err != nil {
@@ -1372,7 +1372,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1372 var templ_7745c5c3_Var67 string1372 var templ_7745c5c3_Var67 string
1373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)1373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1374 if templ_7745c5c3_Err != nil {1374 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 273, Col: 89}1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 89}
1376 }1376 }
1377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)1377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1378 if templ_7745c5c3_Err != nil {1378 if templ_7745c5c3_Err != nil {
@@ -1405,7 +1405,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1405 var templ_7745c5c3_Var68 string1405 var templ_7745c5c3_Var68 string
1406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))1406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1407 if templ_7745c5c3_Err != nil {1407 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 284, Col: 31}1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 31}
1409 }1409 }
1410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))1410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1411 if templ_7745c5c3_Err != nil {1411 if templ_7745c5c3_Err != nil {
@@ -1423,7 +1423,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1423 var templ_7745c5c3_Var69 string1423 var templ_7745c5c3_Var69 string
1424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1425 if templ_7745c5c3_Err != nil {1425 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 90}1426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 90}
1427 }1427 }
1428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)1428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1429 if templ_7745c5c3_Err != nil {1429 if templ_7745c5c3_Err != nil {
@@ -1436,7 +1436,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1436 var templ_7745c5c3_Var70 string1436 var templ_7745c5c3_Var70 string
1437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1438 if templ_7745c5c3_Err != nil {1438 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 289, Col: 115}1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 115}
1440 }1440 }
1441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)1441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
1442 if templ_7745c5c3_Err != nil {1442 if templ_7745c5c3_Err != nil {
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