detached 166837a

feat(web): repository topics, activity heartbeat and figures top right on the home rows (#65)

commit
166837a4d3c5461a2b91e6cf84ab579d182a6688
author
brice · · 2026-10-18 12:50 UTC
committer
bnema · · 2026-10-18 12:50 UTC
parent
b9978b8
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.24.0
38 files · +1570 -389
M gen/ermite/storage/repo.pb.go +13 -3 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.4.0
16+ bnema.dev/ermite-api v0.5.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.4.016+ bnema.dev/ermite-api v0.5.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/ermitegit/ermitegit.go +7 -2
@@ -418,8 +418,13 @@ func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domai
418418
419419 // CountCommits is served by the wrapped reader: git counts from the commit
420420 // 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)
421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
422+ return a.cli.CountCommits(ctx, repo, head)
423+}
424+
425+// CommitTimes is served by the wrapped reader.
426+func (a *Adapter) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
427+ return a.cli.CommitTimes(ctx, repo, head, since)
423428 }
424429
425430 // LatestTag is served by the wrapped reader.
@@ -418,8 +418,13 @@ func (a *Adapter) GetCommit(ctx context.Context, repo domain.RepoName, rev domai
418 418
419 // CountCommits is served by the wrapped reader: git counts from the commit419 // CountCommits is served by the wrapped reader: git counts from the commit
420 // graph.420 // graph.
421-func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error) {421+func (a *Adapter) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
422- return a.cli.CountCommits(ctx, repo, head, since)422+ return a.cli.CountCommits(ctx, repo, head)
423+}
424+
425+// CommitTimes is served by the wrapped reader.
426+func (a *Adapter) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
427+ return a.cli.CommitTimes(ctx, repo, head, since)
423 }428 }
424 429
425 // LatestTag is served by the wrapped reader.430 // LatestTag is served by the wrapped reader.
M internal/adapters/gitcli/stats.go +55 -9
@@ -3,22 +3,24 @@
33 package gitcli
44
55 import (
6+ "bufio"
67 "context"
8+ "errors"
79 "fmt"
10+ "io"
811 "strconv"
912 "strings"
1013 "time"
1114
1215 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
1317 )
1418
1519 // 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.
20+// repository has one.
1921 //
2022 //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) {
23+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
2224 if head.IsZero() {
2325 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
2426 }
@@ -26,11 +28,7 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
2628 if err != nil {
2729 return 0, err
2830 }
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())...)...)
31+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--end-of-options", head.String())...)
3432 if err != nil {
3533 if exitCode(err) == 128 {
3634 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
@@ -44,6 +42,54 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
4442 return n, nil
4543 }
4644
45+// CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46+// looks at every reachable commit: --since stops the walk at the first older
47+// one, and misses newer commits behind it when dates are out of order.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
50+func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51+ if head.IsZero() {
52+ return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
53+ }
54+ dir, err := g.repoDir(repo)
55+ if err != nil {
56+ return nil, false, err
57+ }
58+ args := readArgs(dir, "rev-list", "--no-commit-header", "--format=%ct",
59+ "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10), "--end-of-options", head.String())
60+ var (
61+ times []time.Time
62+ truncated bool
63+ )
64+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
65+ for {
66+ line, err := r.ReadString('\n')
67+ if errors.Is(err, io.EOF) && line == "" {
68+ return nil
69+ }
70+ if err != nil && !errors.Is(err, io.EOF) {
71+ return err
72+ }
73+ sec, perr := strconv.ParseInt(strings.TrimSpace(line), 10, 64)
74+ if perr != nil {
75+ return fmt.Errorf("git rev-list: unexpected line %q", truncateText(line))
76+ }
77+ if len(times) == ports.MaxCommitTimes {
78+ truncated = true
79+ return errStop
80+ }
81+ times = append(times, time.Unix(sec, 0).UTC())
82+ }
83+ })
84+ if err != nil {
85+ if exitCode(err) == 128 {
86+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, domain.ErrNotFound)
87+ }
88+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, err)
89+ }
90+ return times, truncated, nil
91+}
92+
4793 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
4894 //
4995 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
@@ -3,22 +3,24 @@
3 package gitcli3 package gitcli
4 4
5 import (5 import (
6+ "bufio"
6 "context"7 "context"
8+ "errors"
7 "fmt"9 "fmt"
10+ "io"
8 "strconv"11 "strconv"
9 "strings"12 "strings"
10 "time"13 "time"
11 14
12 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
13 )17 )
14 18
15 // CountCommits runs `git rev-list --count`, from the commit graph when the19 // 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 every20+// repository has one.
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 //21 //
20 //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints22 //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) {23+func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
22 if head.IsZero() {24 if head.IsZero() {
23 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)25 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
24 }26 }
@@ -26,11 +28,7 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
26 if err != nil {28 if err != nil {
27 return 0, err29 return 0, err
28 }30 }
29- args := []string{"rev-list", "--count"}31+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--end-of-options", head.String())...)
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 {32 if err != nil {
35 if exitCode(err) == 128 {33 if exitCode(err) == 128 {
36 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)34 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
@@ -44,6 +42,54 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai
44 return n, nil42 return n, nil
45 }43 }
46 44
45+// CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46+// looks at every reachable commit: --since stops the walk at the first older
47+// one, and misses newer commits behind it when dates are out of order.
48+//
49+//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
50+func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51+ if head.IsZero() {
52+ return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
53+ }
54+ dir, err := g.repoDir(repo)
55+ if err != nil {
56+ return nil, false, err
57+ }
58+ args := readArgs(dir, "rev-list", "--no-commit-header", "--format=%ct",
59+ "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10), "--end-of-options", head.String())
60+ var (
61+ times []time.Time
62+ truncated bool
63+ )
64+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
65+ for {
66+ line, err := r.ReadString('\n')
67+ if errors.Is(err, io.EOF) && line == "" {
68+ return nil
69+ }
70+ if err != nil && !errors.Is(err, io.EOF) {
71+ return err
72+ }
73+ sec, perr := strconv.ParseInt(strings.TrimSpace(line), 10, 64)
74+ if perr != nil {
75+ return fmt.Errorf("git rev-list: unexpected line %q", truncateText(line))
76+ }
77+ if len(times) == ports.MaxCommitTimes {
78+ truncated = true
79+ return errStop
80+ }
81+ times = append(times, time.Unix(sec, 0).UTC())
82+ }
83+ })
84+ if err != nil {
85+ if exitCode(err) == 128 {
86+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, domain.ErrNotFound)
87+ }
88+ return nil, false, fmt.Errorf("commit times of %q: %w", repo, err)
89+ }
90+ return times, truncated, nil
91+}
92+
47 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.93 // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
48 //94 //
49 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints95 //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
M internal/adapters/kvstore/repos.go +3 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "slices"
910 "time"
1011 "unicode/utf8"
1112
@@ -201,6 +202,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
201202 pb := &storagepb.Repo{
202203 Name: r.Name.String(),
203204 Description: r.Description,
205+ Topics: r.Topics,
204206 Visibility: vis,
205207 CreatedAt: encodeTime(r.CreatedAt),
206208 PushedAt: encodeTime(r.PushedAt),
@@ -221,6 +223,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
221223 return domain.Repo{
222224 Name: name,
223225 Description: pb.GetDescription(),
226+ Topics: slices.Clone(pb.GetTopics()),
224227 Visibility: decodeVisibility(pb.GetVisibility()),
225228 CreatedAt: decodeTime(pb.GetCreatedAt()),
226229 PushedAt: decodeTime(pb.GetPushedAt()),
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "slices"
9 "time"10 "time"
10 "unicode/utf8"11 "unicode/utf8"
11 12
@@ -201,6 +202,7 @@ func encodeRepo(r domain.Repo) ([]byte, error) {
201 pb := &storagepb.Repo{202 pb := &storagepb.Repo{
202 Name: r.Name.String(),203 Name: r.Name.String(),
203 Description: r.Description,204 Description: r.Description,
205+ Topics: r.Topics,
204 Visibility: vis,206 Visibility: vis,
205 CreatedAt: encodeTime(r.CreatedAt),207 CreatedAt: encodeTime(r.CreatedAt),
206 PushedAt: encodeTime(r.PushedAt),208 PushedAt: encodeTime(r.PushedAt),
@@ -221,6 +223,7 @@ func decodeRepo(val []byte) (domain.Repo, error) {
221 return domain.Repo{223 return domain.Repo{
222 Name: name,224 Name: name,
223 Description: pb.GetDescription(),225 Description: pb.GetDescription(),
226+ Topics: slices.Clone(pb.GetTopics()),
224 Visibility: decodeVisibility(pb.GetVisibility()),227 Visibility: decodeVisibility(pb.GetVisibility()),
225 CreatedAt: decodeTime(pb.GetCreatedAt()),228 CreatedAt: decodeTime(pb.GetCreatedAt()),
226 PushedAt: decodeTime(pb.GetPushedAt()),229 PushedAt: decodeTime(pb.GetPushedAt()),
M internal/adapters/kvstore/store_test.go +23 -1
@@ -42,7 +42,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
4242 ctx := context.Background()
4343 s := openStore(t, t.TempDir())
4444
45- b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
45+ b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
4646 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
4747 require.NoError(t, s.Create(ctx, b))
4848 require.NoError(t, s.Create(ctx, a))
@@ -63,12 +63,19 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
6363 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
6464
6565 b.Description = "changed"
66+ b.Topics = []string{"forge"}
6667 b.Visibility = domain.VisibilityPrivate
6768 require.NoError(t, s.Update(ctx, b))
6869 got, err = s.Get(ctx, b.Name)
6970 require.NoError(t, err)
7071 assert.Equal(t, b, got)
7172
73+ b.Topics = nil
74+ require.NoError(t, s.Update(ctx, b))
75+ got, err = s.Get(ctx, b.Name)
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics, "cleared topics read back as nil")
78+
7279 require.NoError(t, s.Delete(ctx, b.Name))
7380 _, err = s.Get(ctx, b.Name)
7481 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -337,3 +344,18 @@ func TestCorruptRepoValue(t *testing.T) {
337344 _, err = s.List(ctx)
338345 assert.Error(t, err)
339346 }
347+
348+func TestRepoWithoutTopicsReadsNil(t *testing.T) {
349+ ctx := context.Background()
350+ dir := t.TempDir()
351+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
352+ putRaw(t, dir, "repo/new", mustProto(t, &storagepb.Repo{Name: "new", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, Topics: []string{"a", "b"}}))
353+ s := openStore(t, dir)
354+
355+ old, err := s.Get(ctx, name(t, "old"))
356+ require.NoError(t, err)
357+ assert.Nil(t, old.Topics)
358+ got, err := s.Get(ctx, name(t, "new"))
359+ require.NoError(t, err)
360+ assert.Equal(t, []string{"a", "b"}, got.Topics)
361+}
@@ -42,7 +42,7 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
42 ctx := context.Background()42 ctx := context.Background()
43 s := openStore(t, t.TempDir())43 s := openStore(t, t.TempDir())
44 44
45- b := domain.Repo{Name: name(t, "b"), Description: "second", Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}45+ b := domain.Repo{Name: name(t, "b"), Description: "second", Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPublic, CreatedAt: created, Settings: domain.DefaultRepoSettings()}
46 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}46 a := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPrivate, CreatedAt: created, PushedAt: created.Add(time.Hour), Settings: domain.DefaultRepoSettings()}
47 require.NoError(t, s.Create(ctx, b))47 require.NoError(t, s.Create(ctx, b))
48 require.NoError(t, s.Create(ctx, a))48 require.NoError(t, s.Create(ctx, a))
@@ -63,12 +63,19 @@ func TestCreateGetListUpdateDelete(t *testing.T) {
63 assert.ErrorIs(t, err, domain.ErrAlreadyExists)63 assert.ErrorIs(t, err, domain.ErrAlreadyExists)
64 64
65 b.Description = "changed"65 b.Description = "changed"
66+ b.Topics = []string{"forge"}
66 b.Visibility = domain.VisibilityPrivate67 b.Visibility = domain.VisibilityPrivate
67 require.NoError(t, s.Update(ctx, b))68 require.NoError(t, s.Update(ctx, b))
68 got, err = s.Get(ctx, b.Name)69 got, err = s.Get(ctx, b.Name)
69 require.NoError(t, err)70 require.NoError(t, err)
70 assert.Equal(t, b, got)71 assert.Equal(t, b, got)
71 72
73+ b.Topics = nil
74+ require.NoError(t, s.Update(ctx, b))
75+ got, err = s.Get(ctx, b.Name)
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics, "cleared topics read back as nil")
78+
72 require.NoError(t, s.Delete(ctx, b.Name))79 require.NoError(t, s.Delete(ctx, b.Name))
73 _, err = s.Get(ctx, b.Name)80 _, err = s.Get(ctx, b.Name)
74 assert.ErrorIs(t, err, domain.ErrNotFound)81 assert.ErrorIs(t, err, domain.ErrNotFound)
@@ -337,3 +344,18 @@ func TestCorruptRepoValue(t *testing.T) {
337 _, err = s.List(ctx)344 _, err = s.List(ctx)
338 assert.Error(t, err)345 assert.Error(t, err)
339 }346 }
347+
348+func TestRepoWithoutTopicsReadsNil(t *testing.T) {
349+ ctx := context.Background()
350+ dir := t.TempDir()
351+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
352+ putRaw(t, dir, "repo/new", mustProto(t, &storagepb.Repo{Name: "new", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC, Topics: []string{"a", "b"}}))
353+ s := openStore(t, dir)
354+
355+ old, err := s.Get(ctx, name(t, "old"))
356+ require.NoError(t, err)
357+ assert.Nil(t, old.Topics)
358+ got, err := s.Get(ctx, name(t, "new"))
359+ require.NoError(t, err)
360+ assert.Equal(t, []string{"a", "b"}, got.Topics)
361+}
M internal/adapters/rpc/convert.go +1 -0
@@ -65,6 +65,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
6565 return &pb.Repo{
6666 Name: v.Name.String(),
6767 Description: v.Description,
68+ Topics: v.Topics,
6869 Visibility: visibilityToPB(v.Visibility),
6970 DefaultBranch: v.DefaultBranch,
7071 CreatedAt: timestamp(v.CreatedAt),
@@ -65,6 +65,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
65 return &pb.Repo{65 return &pb.Repo{
66 Name: v.Name.String(),66 Name: v.Name.String(),
67 Description: v.Description,67 Description: v.Description,
68+ Topics: v.Topics,
68 Visibility: visibilityToPB(v.Visibility),69 Visibility: visibilityToPB(v.Visibility),
69 DefaultBranch: v.DefaultBranch,70 DefaultBranch: v.DefaultBranch,
70 CreatedAt: timestamp(v.CreatedAt),71 CreatedAt: timestamp(v.CreatedAt),
M internal/adapters/rpc/repo.go +4 -2
@@ -54,7 +54,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr
5454 return nil, connectError(ctx, err)
5555 }
5656 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{
57- Name: name, Description: req.Msg.GetDescription(), Visibility: vis,
57+ Name: name, Description: req.Msg.GetDescription(), Topics: req.Msg.GetTopics(), Visibility: vis,
5858 })
5959 if err != nil {
6060 return nil, connectError(ctx, err)
@@ -79,7 +79,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
7979 }
8080
8181 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility", "default_branch", "settings",
82+// one of "description", "topics", "visibility", "default_branch", "settings",
8383 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
8484 // nothing else.
8585 //
@@ -94,6 +94,8 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
9494 switch path {
9595 case "description":
9696 params.Description = new(msg.GetDescription())
97+ case "topics":
98+ params.Topics = new(msg.GetTopics())
9799 case "visibility":
98100 vis, err := visibilityFromPB(msg.GetVisibility())
99101 if err != nil {
@@ -54,7 +54,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr
54 return nil, connectError(ctx, err)54 return nil, connectError(ctx, err)
55 }55 }
56 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{56 repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{
57- Name: name, Description: req.Msg.GetDescription(), Visibility: vis,57+ Name: name, Description: req.Msg.GetDescription(), Topics: req.Msg.GetTopics(), Visibility: vis,
58 })58 })
59 if err != nil {59 if err != nil {
60 return nil, connectError(ctx, err)60 return nil, connectError(ctx, err)
@@ -79,7 +79,7 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up
79 }79 }
80 80
81 // updateParams maps the field mask of an update. The mask must name at least81 // updateParams maps the field mask of an update. The mask must name at least
82-// one of "description", "visibility", "default_branch", "settings",82+// one of "description", "topics", "visibility", "default_branch", "settings",
83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and83 // "settings.allowed_merge_modes" and "settings.failing_checks_block", and
84 // nothing else.84 // nothing else.
85 //85 //
@@ -94,6 +94,8 @@ func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) {
94 switch path {94 switch path {
95 case "description":95 case "description":
96 params.Description = new(msg.GetDescription())96 params.Description = new(msg.GetDescription())
97+ case "topics":
98+ params.Topics = new(msg.GetTopics())
97 case "visibility":99 case "visibility":
98 vis, err := visibilityFromPB(msg.GetVisibility())100 vis, err := visibilityFromPB(msg.GetVisibility())
99 if err != nil {101 if err != nil {
M internal/adapters/rpc/repo_test.go +30 -2
@@ -5,6 +5,7 @@ package rpc
55 import (
66 "context"
77 "errors"
8+ "slices"
89 "testing"
910
1011 "connectrpc.com/connect"
@@ -93,7 +94,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
9394 wantCode connect.Code
9495 wantCall bool
9596 }{
96- {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
97+ {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Topics: []string{"go", "cli"}, Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
9798 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},
9899 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},
99100 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
@@ -106,7 +107,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
106107 if tt.wantCall {
107108 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}
108109 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {
109- return p.Name.String() == "demo"
110+ return p.Name.String() == "demo" && slices.Equal(p.Topics, tt.req.GetTopics())
110111 })).Return(repo, tt.usecase).Once()
111112 if tt.usecase == nil {
112113 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
@@ -252,3 +253,30 @@ func TestRepoServiceListRepos(t *testing.T) {
252253 })
253254 }
254255 }
256+
257+func TestUpdateParamsTopics(t *testing.T) {
258+ t.Parallel()
259+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
260+
261+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics"), Topics: []string{"go", "cli"}})
262+ require.NoError(t, err)
263+ require.NotNil(t, params.Topics)
264+ assert.Equal(t, []string{"go", "cli"}, *params.Topics)
265+ assert.Nil(t, params.Description, "only the masked fields are set")
266+
267+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics")})
268+ require.NoError(t, err)
269+ require.NotNil(t, params.Topics, "a masked empty list is a clear, not an absent field")
270+ assert.Empty(t, *params.Topics)
271+
272+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("description"), Topics: []string{"go"}})
273+ require.NoError(t, err)
274+ assert.Nil(t, params.Topics, "topics outside the mask are ignored")
275+}
276+
277+func TestRepoToPBTopics(t *testing.T) {
278+ t.Parallel()
279+ got := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo"), Topics: []string{"cli", "go"}}})
280+ assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281+ assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282+}
@@ -5,6 +5,7 @@ package rpc
5 import (5 import (
6 "context"6 "context"
7 "errors"7 "errors"
8+ "slices"
8 "testing"9 "testing"
9 10
10 "connectrpc.com/connect"11 "connectrpc.com/connect"
@@ -93,7 +94,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
93 wantCode connect.Code94 wantCode connect.Code
94 wantCall bool95 wantCall bool
95 }{96 }{
96- {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},97+ {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Topics: []string{"go", "cli"}, Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
97 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},98 {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},
98 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},99 {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},
99 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},100 {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
@@ -106,7 +107,7 @@ func TestRepoServiceCreateRepo(t *testing.T) {
106 if tt.wantCall {107 if tt.wantCall {
107 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}108 repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}
108 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {109 repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {
109- return p.Name.String() == "demo"110+ return p.Name.String() == "demo" && slices.Equal(p.Topics, tt.req.GetTopics())
110 })).Return(repo, tt.usecase).Once()111 })).Return(repo, tt.usecase).Once()
111 if tt.usecase == nil {112 if tt.usecase == nil {
112 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()113 repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
@@ -252,3 +253,30 @@ func TestRepoServiceListRepos(t *testing.T) {
252 })253 })
253 }254 }
254 }255 }
256+
257+func TestUpdateParamsTopics(t *testing.T) {
258+ t.Parallel()
259+ mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
260+
261+ params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics"), Topics: []string{"go", "cli"}})
262+ require.NoError(t, err)
263+ require.NotNil(t, params.Topics)
264+ assert.Equal(t, []string{"go", "cli"}, *params.Topics)
265+ assert.Nil(t, params.Description, "only the masked fields are set")
266+
267+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics")})
268+ require.NoError(t, err)
269+ require.NotNil(t, params.Topics, "a masked empty list is a clear, not an absent field")
270+ assert.Empty(t, *params.Topics)
271+
272+ params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("description"), Topics: []string{"go"}})
273+ require.NoError(t, err)
274+ assert.Nil(t, params.Topics, "topics outside the mask are ignored")
275+}
276+
277+func TestRepoToPBTopics(t *testing.T) {
278+ t.Parallel()
279+ got := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo"), Topics: []string{"cli", "go"}}})
280+ assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281+ assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282+}
M internal/adapters/web/components.templ +52 -13
@@ -32,9 +32,10 @@ templ CloneBlock(c Clone) {
3232 </div>
3333 }
3434
35-// HomeRowView is the row of a repository in the home list: what it is, its
36-// latest run, and its newest commits. Its id is the target of SSE swaps; the
37-// name link comes first, `enter` follows it.
35+// HomeRowView is the row of a repository in the home list. On the left what
36+// it is: name, description, topics and the pulse of its activity; on the
37+// right its figures and CI state over its newest commits. Its id is the
38+// target of SSE swaps; the name link comes first, `enter` follows it.
3839 templ HomeRowView(r HomeRow) {
3940 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
4041 <div class="repo-main">
@@ -52,17 +53,29 @@ templ HomeRowView(r HomeRow) {
5253 } else {
5354 <p class="repo-desc mute">no description</p>
5455 }
56+ if len(r.Topics) > 0 {
57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>
58+ for _, t := range r.Topics {
59+ <li>{ t }</li>
60+ }
61+ </ul>
62+ }
63+ if r.Stats != nil && r.Stats.Pulse != nil {
64+ @pulse(r.Stats.Pulse)
65+ }
66+ </div>
67+ <div class="repo-head">
5568 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
5669 if s := r.Stats; s != nil {
5770 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
5871 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
5972 if s.Tag != "" {
60- <div>
73+ <div class="rs-tag">
6174 <dt>latest tag</dt>
6275 if s.TagHref != "" {
63- <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>
76+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
6477 } else {
65- <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>
78+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
6679 }
6780 </div>
6881 }
@@ -76,15 +89,19 @@ templ HomeRowView(r HomeRow) {
7689 }
7790 </div>
7891 </dl>
92+ if r.CI != nil {
93+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
94+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
95+ <span class="sr">CI: </span>{ r.CI.Label }
96+ </a>
97+ }
7998 </div>
80- if r.CI != nil {
81- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
82- <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
83- <span class="sr">CI: </span>{ r.CI.Label }
84- </a>
85- }
8699 if len(r.Commits) > 0 {
87- <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>
100+ <ul
101+ class="repo-commits"
102+ data-full?={ len(r.Commits) == homeRecent }
103+ aria-label={ "Recent commits of " + r.Name }
104+ >
88105 for _, c := range r.Commits {
89106 <li>
90107 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
@@ -101,6 +118,28 @@ templ HomeRowView(r HomeRow) {
101118 </li>
102119 }
103120
121+// pulse is the activity curve of a home row: a smooth line over a fading
122+// area on a faint grid, and a dot at today that beats while the repository
123+// is active. The SVG scales evenly with its column, so the dot stays round.
124+templ pulse(h *Heartbeat) {
125+ <figure class="repo-pulse" data-live?={ h.Live }>
126+ <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
127+ <defs>
128+ <linearGradient id={ h.GradID } x1="0" y1="0" x2="0" y2="1">
129+ <stop class="pulse-stop-top" offset="0"></stop>
130+ <stop class="pulse-stop-bottom" offset="1"></stop>
131+ </linearGradient>
132+ </defs>
133+ <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134+ <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135+ <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136+ <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137+ <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138+ </svg>
139+ <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140+ </figure>
141+}
142+
104143 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
105144 templ Outline(headings []Heading) {
106145 <nav class="outline" aria-label="Outline">
@@ -32,9 +32,10 @@ templ CloneBlock(c Clone) {
32 </div>32 </div>
33 }33 }
34 34
35-// HomeRowView is the row of a repository in the home list: what it is, its35+// HomeRowView is the row of a repository in the home list. On the left what
36-// latest run, and its newest commits. Its id is the target of SSE swaps; the36+// it is: name, description, topics and the pulse of its activity; on the
37-// name link comes first, `enter` follows it.37+// right its figures and CI state over its newest commits. Its id is the
38+// target of SSE swaps; the name link comes first, `enter` follows it.
38 templ HomeRowView(r HomeRow) {39 templ HomeRowView(r HomeRow) {
39 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>40 <li id={ r.ID } class="repo" data-cursor?={ r.Cursor }>
40 <div class="repo-main">41 <div class="repo-main">
@@ -52,17 +53,29 @@ templ HomeRowView(r HomeRow) {
52 } else {53 } else {
53 <p class="repo-desc mute">no description</p>54 <p class="repo-desc mute">no description</p>
54 }55 }
56+ if len(r.Topics) > 0 {
57+ <ul class="repo-topics" aria-label={ "Topics of " + r.Name }>
58+ for _, t := range r.Topics {
59+ <li>{ t }</li>
60+ }
61+ </ul>
62+ }
63+ if r.Stats != nil && r.Stats.Pulse != nil {
64+ @pulse(r.Stats.Pulse)
65+ }
66+ </div>
67+ <div class="repo-head">
55 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>68 <dl class="repo-stats" aria-label={ "Figures of " + r.Name }>
56 if s := r.Stats; s != nil {69 if s := r.Stats; s != nil {
57 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>70 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
58 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>71 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
59 if s.Tag != "" {72 if s.Tag != "" {
60- <div>73+ <div class="rs-tag">
61 <dt>latest tag</dt>74 <dt>latest tag</dt>
62 if s.TagHref != "" {75 if s.TagHref != "" {
63- <dd><a href={ templ.SafeURL(s.TagHref) } title={ "made " + s.TagWhen }>{ s.Tag }</a></dd>76+ <dd><a href={ templ.SafeURL(s.TagHref) } title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</a></dd>
64 } else {77 } else {
65- <dd title={ "made " + s.TagWhen }>{ s.Tag }</dd>78+ <dd title={ s.Tag + " · made " + s.TagWhen }>{ s.Tag }</dd>
66 }79 }
67 </div>80 </div>
68 }81 }
@@ -76,15 +89,19 @@ templ HomeRowView(r HomeRow) {
76 }89 }
77 </div>90 </div>
78 </dl>91 </dl>
92+ if r.CI != nil {
93+ <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
94+ <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
95+ <span class="sr">CI: </span>{ r.CI.Label }
96+ </a>
97+ }
79 </div>98 </div>
80- if r.CI != nil {
81- <a class="repo-ci" href={ templ.SafeURL(r.CI.Href) } title={ r.CI.Title }>
82- <span class={ "ck", "ck-" + r.CI.Kind } aria-hidden="true"></span>
83- <span class="sr">CI: </span>{ r.CI.Label }
84- </a>
85- }
86 if len(r.Commits) > 0 {99 if len(r.Commits) > 0 {
87- <ul class="repo-commits" aria-label={ "Recent commits of " + r.Name }>100+ <ul
101+ class="repo-commits"
102+ data-full?={ len(r.Commits) == homeRecent }
103+ aria-label={ "Recent commits of " + r.Name }
104+ >
88 for _, c := range r.Commits {105 for _, c := range r.Commits {
89 <li>106 <li>
90 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>107 <a class="rc-sha" href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
@@ -101,6 +118,28 @@ templ HomeRowView(r HomeRow) {
101 </li>118 </li>
102 }119 }
103 120
121+// pulse is the activity curve of a home row: a smooth line over a fading
122+// area on a faint grid, and a dot at today that beats while the repository
123+// is active. The SVG scales evenly with its column, so the dot stays round.
124+templ pulse(h *Heartbeat) {
125+ <figure class="repo-pulse" data-live?={ h.Live }>
126+ <svg viewBox="0 0 600 64" role="img" aria-label={ h.Label } focusable="false">
127+ <defs>
128+ <linearGradient id={ h.GradID } x1="0" y1="0" x2="0" y2="1">
129+ <stop class="pulse-stop-top" offset="0"></stop>
130+ <stop class="pulse-stop-bottom" offset="1"></stop>
131+ </linearGradient>
132+ </defs>
133+ <path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke"></path>
134+ <path class="pulse-area" d={ h.Area } fill={ "url(#" + h.GradID + ")" }></path>
135+ <path class="pulse-line" d={ h.Line } vector-effect="non-scaling-stroke"></path>
136+ <circle class="pulse-ring" cx={ h.X } cy={ h.Y } r="4"></circle>
137+ <circle class="pulse-dot" cx={ h.X } cy={ h.Y } r="3.5"></circle>
138+ </svg>
139+ <figcaption class="pulse-cap" aria-hidden="true"><span>90 days ago</span><span>today</span></figcaption>
140+ </figure>
141+}
142+
104 // Outline is the README outline; levels indent by 2ch, the h1 is bold.143 // Outline is the README outline; levels indent by 2ch, the h1 is bold.
105 templ Outline(headings []Heading) {144 templ Outline(headings []Heading) {
106 <nav class="outline" aria-label="Outline">145 <nav class="outline" aria-label="Outline">
M internal/adapters/web/components_templ.go +476 -254
@@ -147,9 +147,10 @@ func CloneBlock(c Clone) templ.Component {
147147 })
148148 }
149149
150-// HomeRowView is the row of a repository in the home list: what it is, its
151-// latest run, and its newest commits. Its id is the target of SSE swaps; the
152-// name link comes first, `enter` follows it.
150+// HomeRowView is the row of a repository in the home list. On the left what
151+// it is: name, description, topics and the pulse of its activity; on the
152+// right its figures and CI state over its newest commits. Its id is the
153+// target of SSE swaps; the name link comes first, `enter` follows it.
153154 func HomeRowView(r HomeRow) templ.Component {
154155 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
155156 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -178,7 +179,7 @@ func HomeRowView(r HomeRow) templ.Component {
178179 var templ_7745c5c3_Var8 string
179180 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
180181 if templ_7745c5c3_Err != nil {
181- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 14}
182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 14}
182183 }
183184 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
184185 if templ_7745c5c3_Err != nil {
@@ -201,7 +202,7 @@ func HomeRowView(r HomeRow) templ.Component {
201202 var templ_7745c5c3_Var9 templ.SafeURL
202203 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
203204 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 35}
205+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 43, Col: 35}
205206 }
206207 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
207208 if templ_7745c5c3_Err != nil {
@@ -214,7 +215,7 @@ func HomeRowView(r HomeRow) templ.Component {
214215 var templ_7745c5c3_Var10 string
215216 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
216217 if templ_7745c5c3_Err != nil {
217- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 46}
218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 43, Col: 46}
218219 }
219220 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
220221 if templ_7745c5c3_Err != nil {
@@ -248,7 +249,7 @@ func HomeRowView(r HomeRow) templ.Component {
248249 var templ_7745c5c3_Var11 string
249250 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
250251 if templ_7745c5c3_Err != nil {
251- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 33}
252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 33}
252253 }
253254 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
254255 if templ_7745c5c3_Err != nil {
@@ -264,402 +265,623 @@ func HomeRowView(r HomeRow) templ.Component {
264265 return templ_7745c5c3_Err
265266 }
266267 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<dl class=\"repo-stats\" aria-label=\"")
268+ if len(r.Topics) > 0 {
269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<ul class=\"repo-topics\" aria-label=\"")
270+ if templ_7745c5c3_Err != nil {
271+ return templ_7745c5c3_Err
272+ }
273+ var templ_7745c5c3_Var12 string
274+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)
275+ if templ_7745c5c3_Err != nil {
276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 62}
277+ }
278+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
283+ if templ_7745c5c3_Err != nil {
284+ return templ_7745c5c3_Err
285+ }
286+ for _, t := range r.Topics {
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li>")
288+ if templ_7745c5c3_Err != nil {
289+ return templ_7745c5c3_Err
290+ }
291+ var templ_7745c5c3_Var13 string
292+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t)
293+ if templ_7745c5c3_Err != nil {
294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 59, Col: 13}
295+ }
296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
297+ if templ_7745c5c3_Err != nil {
298+ return templ_7745c5c3_Err
299+ }
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</li>")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ }
305+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</ul>")
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ }
310+ if r.Stats != nil && r.Stats.Pulse != nil {
311+ templ_7745c5c3_Err = pulse(r.Stats.Pulse).Render(ctx, templ_7745c5c3_Buffer)
312+ if templ_7745c5c3_Err != nil {
313+ return templ_7745c5c3_Err
314+ }
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")
268317 if templ_7745c5c3_Err != nil {
269318 return templ_7745c5c3_Err
270319 }
271- var templ_7745c5c3_Var12 string
272- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
320+ var templ_7745c5c3_Var14 string
321+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
273322 if templ_7745c5c3_Err != nil {
274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 61}
323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 68, Col: 61}
275324 }
276- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
325+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
277326 if templ_7745c5c3_Err != nil {
278327 return templ_7745c5c3_Err
279328 }
280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
281330 if templ_7745c5c3_Err != nil {
282331 return templ_7745c5c3_Err
283332 }
284333 if s := r.Stats; s != nil {
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div><dt>commits</dt><dd>")
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div><dt>commits</dt><dd>")
286335 if templ_7745c5c3_Err != nil {
287336 return templ_7745c5c3_Err
288337 }
289- var templ_7745c5c3_Var13 string
290- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
338+ var templ_7745c5c3_Var15 string
339+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
291340 if templ_7745c5c3_Err != nil {
292- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 41}
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 41}
293342 }
294- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
295344 if templ_7745c5c3_Err != nil {
296345 return templ_7745c5c3_Err
297346 }
298- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</dd></div><div><dt>last 30 days</dt><dd>")
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</dd></div><div><dt>last 30 days</dt><dd>")
299348 if templ_7745c5c3_Err != nil {
300349 return templ_7745c5c3_Err
301350 }
302- var templ_7745c5c3_Var14 string
303- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
351+ var templ_7745c5c3_Var16 string
352+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
304353 if templ_7745c5c3_Err != nil {
305- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 45}
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 71, Col: 45}
306355 }
307- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
308357 if templ_7745c5c3_Err != nil {
309358 return templ_7745c5c3_Err
310359 }
311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</dd></div>")
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dd></div>")
312361 if templ_7745c5c3_Err != nil {
313362 return templ_7745c5c3_Err
314363 }
315364 if s.Tag != "" {
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div><dt>latest tag</dt>")
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"rs-tag\"><dt>latest tag</dt>")
317366 if templ_7745c5c3_Err != nil {
318367 return templ_7745c5c3_Err
319368 }
320369 if s.TagHref != "" {
321- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<dd><a href=\"")
370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<dd><a href=\"")
322371 if templ_7745c5c3_Err != nil {
323372 return templ_7745c5c3_Err
324373 }
325- var templ_7745c5c3_Var15 templ.SafeURL
326- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
374+ var templ_7745c5c3_Var17 templ.SafeURL
375+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
327376 if templ_7745c5c3_Err != nil {
328- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 46}
377+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 46}
329378 }
330- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
379+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
331380 if templ_7745c5c3_Err != nil {
332381 return templ_7745c5c3_Err
333382 }
334- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")
383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" title=\"")
335384 if templ_7745c5c3_Err != nil {
336385 return templ_7745c5c3_Err
337386 }
338- var templ_7745c5c3_Var16 string
339- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
387+ var templ_7745c5c3_Var18 string
388+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
340389 if templ_7745c5c3_Err != nil {
341- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 76}
390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 88}
342391 }
343- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
344393 if templ_7745c5c3_Err != nil {
345394 return templ_7745c5c3_Err
346395 }
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
348397 if templ_7745c5c3_Err != nil {
349398 return templ_7745c5c3_Err
350399 }
351- var templ_7745c5c3_Var17 string
352- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
400+ var templ_7745c5c3_Var19 string
401+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
353402 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 86}
403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 98}
355404 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
405+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
357406 if templ_7745c5c3_Err != nil {
358407 return templ_7745c5c3_Err
359408 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a></dd>")
409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a></dd>")
361410 if templ_7745c5c3_Err != nil {
362411 return templ_7745c5c3_Err
363412 }
364413 } else {
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<dd title=\"")
414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd title=\"")
366415 if templ_7745c5c3_Err != nil {
367416 return templ_7745c5c3_Err
368417 }
369- var templ_7745c5c3_Var18 string
370- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)
418+ var templ_7745c5c3_Var20 string
419+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
371420 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 39}
421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 51}
373422 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
375424 if templ_7745c5c3_Err != nil {
376425 return templ_7745c5c3_Err
377426 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
379428 if templ_7745c5c3_Err != nil {
380429 return templ_7745c5c3_Err
381430 }
382- var templ_7745c5c3_Var19 string
383- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
431+ var templ_7745c5c3_Var21 string
432+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
384433 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 49}
434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 61}
386435 }
387- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
388437 if templ_7745c5c3_Err != nil {
389438 return templ_7745c5c3_Err
390439 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd>")
440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</dd>")
392441 if templ_7745c5c3_Err != nil {
393442 return templ_7745c5c3_Err
394443 }
395444 }
396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")
445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
397446 if templ_7745c5c3_Err != nil {
398447 return templ_7745c5c3_Err
399448 }
400449 }
401450 }
402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div><dt>last activity</dt>")
451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div><dt>last activity</dt>")
403452 if templ_7745c5c3_Err != nil {
404453 return templ_7745c5c3_Err
405454 }
406455 if r.ActiveAt != "" {
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd><time datetime=\"")
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<dd><time datetime=\"")
408457 if templ_7745c5c3_Err != nil {
409458 return templ_7745c5c3_Err
410459 }
411- var templ_7745c5c3_Var20 string
412- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
460+ var templ_7745c5c3_Var22 string
461+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
413462 if templ_7745c5c3_Err != nil {
414- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 37}
463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 37}
415464 }
416- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
417466 if templ_7745c5c3_Err != nil {
418467 return templ_7745c5c3_Err
419468 }
420- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"")
469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" title=\"")
421470 if templ_7745c5c3_Err != nil {
422471 return templ_7745c5c3_Err
423472 }
424- var templ_7745c5c3_Var21 string
425- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
473+ var templ_7745c5c3_Var23 string
474+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
426475 if templ_7745c5c3_Err != nil {
427- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 58}
476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 58}
428477 }
429- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
430479 if templ_7745c5c3_Err != nil {
431480 return templ_7745c5c3_Err
432481 }
433- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
434483 if templ_7745c5c3_Err != nil {
435484 return templ_7745c5c3_Err
436485 }
437- var templ_7745c5c3_Var22 string
438- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
486+ var templ_7745c5c3_Var24 string
487+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
439488 if templ_7745c5c3_Err != nil {
440- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 71}
489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 71}
441490 }
442- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
491+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
443492 if templ_7745c5c3_Err != nil {
444493 return templ_7745c5c3_Err
445494 }
446- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</time></dd>")
495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</time></dd>")
447496 if templ_7745c5c3_Err != nil {
448497 return templ_7745c5c3_Err
449498 }
450499 } else {
451- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<dd class=\"mute\">never</dd>")
500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<dd class=\"mute\">never</dd>")
452501 if templ_7745c5c3_Err != nil {
453502 return templ_7745c5c3_Err
454503 }
455504 }
456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></dl></div>")
505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div></dl>")
457506 if templ_7745c5c3_Err != nil {
458507 return templ_7745c5c3_Err
459508 }
460509 if r.CI != nil {
461- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"repo-ci\" href=\"")
510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"repo-ci\" href=\"")
462511 if templ_7745c5c3_Err != nil {
463512 return templ_7745c5c3_Err
464513 }
465- var templ_7745c5c3_Var23 templ.SafeURL
466- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
514+ var templ_7745c5c3_Var25 templ.SafeURL
515+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
467516 if templ_7745c5c3_Err != nil {
468- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 53}
517+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 54}
469518 }
470- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
519+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
471520 if templ_7745c5c3_Err != nil {
472521 return templ_7745c5c3_Err
473522 }
474- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")
523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" title=\"")
475524 if templ_7745c5c3_Err != nil {
476525 return templ_7745c5c3_Err
477526 }
478- var templ_7745c5c3_Var24 string
479- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
527+ var templ_7745c5c3_Var26 string
528+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
480529 if templ_7745c5c3_Err != nil {
481- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 74}
530+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 75}
482531 }
483- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
532+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
484533 if templ_7745c5c3_Err != nil {
485534 return templ_7745c5c3_Err
486535 }
487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
488537 if templ_7745c5c3_Err != nil {
489538 return templ_7745c5c3_Err
490539 }
491- var templ_7745c5c3_Var25 = []any{"ck", "ck-" + r.CI.Kind}
492- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)
540+ var templ_7745c5c3_Var27 = []any{"ck", "ck-" + r.CI.Kind}
541+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...)
493542 if templ_7745c5c3_Err != nil {
494543 return templ_7745c5c3_Err
495544 }
496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"")
545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"")
497546 if templ_7745c5c3_Err != nil {
498547 return templ_7745c5c3_Err
499548 }
500- var templ_7745c5c3_Var26 string
501- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())
549+ var templ_7745c5c3_Var28 string
550+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var27).String())
502551 if templ_7745c5c3_Err != nil {
503552 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
504553 }
505- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
554+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
506555 if templ_7745c5c3_Err != nil {
507556 return templ_7745c5c3_Err
508557 }
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
510559 if templ_7745c5c3_Err != nil {
511560 return templ_7745c5c3_Err
512561 }
513- var templ_7745c5c3_Var27 string
514- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
562+ var templ_7745c5c3_Var29 string
563+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
515564 if templ_7745c5c3_Err != nil {
516- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 83, Col: 44}
565+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 45}
517566 }
518- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
567+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
519568 if templ_7745c5c3_Err != nil {
520569 return templ_7745c5c3_Err
521570 }
522- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")
571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a>")
523572 if templ_7745c5c3_Err != nil {
524573 return templ_7745c5c3_Err
525574 }
526575 }
576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div>")
577+ if templ_7745c5c3_Err != nil {
578+ return templ_7745c5c3_Err
579+ }
527580 if len(r.Commits) > 0 {
528- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<ul class=\"repo-commits\" aria-label=\"")
581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<ul class=\"repo-commits\"")
529582 if templ_7745c5c3_Err != nil {
530583 return templ_7745c5c3_Err
531584 }
532- var templ_7745c5c3_Var28 string
533- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
585+ if len(r.Commits) == homeRecent {
586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " data-full")
587+ if templ_7745c5c3_Err != nil {
588+ return templ_7745c5c3_Err
589+ }
590+ }
591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " aria-label=\"")
534592 if templ_7745c5c3_Err != nil {
535- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 87, Col: 70}
593+ return templ_7745c5c3_Err
536594 }
537- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
595+ var templ_7745c5c3_Var30 string
596+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
597+ if templ_7745c5c3_Err != nil {
598+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 46}
599+ }
600+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
538601 if templ_7745c5c3_Err != nil {
539602 return templ_7745c5c3_Err
540603 }
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
542605 if templ_7745c5c3_Err != nil {
543606 return templ_7745c5c3_Err
544607 }
545608 for _, c := range r.Commits {
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<li><a class=\"rc-sha\" href=\"")
609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<li><a class=\"rc-sha\" href=\"")
547610 if templ_7745c5c3_Err != nil {
548611 return templ_7745c5c3_Err
549612 }
550- var templ_7745c5c3_Var29 templ.SafeURL
551- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
613+ var templ_7745c5c3_Var31 templ.SafeURL
614+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
552615 if templ_7745c5c3_Err != nil {
553- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 53}
616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 53}
554617 }
555- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
618+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
556619 if templ_7745c5c3_Err != nil {
557620 return templ_7745c5c3_Err
558621 }
559- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
560623 if templ_7745c5c3_Err != nil {
561624 return templ_7745c5c3_Err
562625 }
563- var templ_7745c5c3_Var30 string
564- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
626+ var templ_7745c5c3_Var32 string
627+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
565628 if templ_7745c5c3_Err != nil {
566- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 65}
629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 65}
567630 }
568- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
631+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
569632 if templ_7745c5c3_Err != nil {
570633 return templ_7745c5c3_Err
571634 }
572- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> <span class=\"rc-subject\" title=\"")
635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</a> <span class=\"rc-subject\" title=\"")
573636 if templ_7745c5c3_Err != nil {
574637 return templ_7745c5c3_Err
575638 }
576- var templ_7745c5c3_Var31 string
577- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
639+ var templ_7745c5c3_Var33 string
640+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
578641 if templ_7745c5c3_Err != nil {
579- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 48}
642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 48}
580643 }
581- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
644+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
582645 if templ_7745c5c3_Err != nil {
583646 return templ_7745c5c3_Err
584647 }
585- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")
648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
586649 if templ_7745c5c3_Err != nil {
587650 return templ_7745c5c3_Err
588651 }
589- var templ_7745c5c3_Var32 string
590- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
652+ var templ_7745c5c3_Var34 string
653+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
591654 if templ_7745c5c3_Err != nil {
592- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 62}
655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 62}
593656 }
594- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
595658 if templ_7745c5c3_Err != nil {
596659 return templ_7745c5c3_Err
597660 }
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")
661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> ")
599662 if templ_7745c5c3_Err != nil {
600663 return templ_7745c5c3_Err
601664 }
602665 if c.WhenAt != "" {
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<time class=\"dim\" datetime=\"")
666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<time class=\"dim\" datetime=\"")
604667 if templ_7745c5c3_Err != nil {
605668 return templ_7745c5c3_Err
606669 }
607- var templ_7745c5c3_Var33 string
608- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
670+ var templ_7745c5c3_Var35 string
671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
609672 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 44}
673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 44}
611674 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
613676 if templ_7745c5c3_Err != nil {
614677 return templ_7745c5c3_Err
615678 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" title=\"")
679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" title=\"")
617680 if templ_7745c5c3_Err != nil {
618681 return templ_7745c5c3_Err
619682 }
620- var templ_7745c5c3_Var34 string
621- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
683+ var templ_7745c5c3_Var36 string
684+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
622685 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 63}
686+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 63}
624687 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
688+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
626689 if templ_7745c5c3_Err != nil {
627690 return templ_7745c5c3_Err
628691 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
692+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
630693 if templ_7745c5c3_Err != nil {
631694 return templ_7745c5c3_Err
632695 }
633- var templ_7745c5c3_Var35 string
634- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
696+ var templ_7745c5c3_Var37 string
697+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
635698 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 74}
699+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 74}
637700 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
701+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
639702 if templ_7745c5c3_Err != nil {
640703 return templ_7745c5c3_Err
641704 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time>")
705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</time>")
643706 if templ_7745c5c3_Err != nil {
644707 return templ_7745c5c3_Err
645708 }
646709 }
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>")
710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</li>")
648711 if templ_7745c5c3_Err != nil {
649712 return templ_7745c5c3_Err
650713 }
651714 }
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>")
715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</ul>")
653716 if templ_7745c5c3_Err != nil {
654717 return templ_7745c5c3_Err
655718 }
656719 } else if r.NoCommits {
657- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"repo-commits mute\">no commits yet</p>")
720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"repo-commits mute\">no commits yet</p>")
721+ if templ_7745c5c3_Err != nil {
722+ return templ_7745c5c3_Err
723+ }
724+ }
725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</li>")
726+ if templ_7745c5c3_Err != nil {
727+ return templ_7745c5c3_Err
728+ }
729+ return nil
730+ })
731+}
732+
733+// pulse is the activity curve of a home row: a smooth line over a fading
734+// area on a faint grid, and a dot at today that beats while the repository
735+// is active. The SVG scales evenly with its column, so the dot stays round.
736+func pulse(h *Heartbeat) templ.Component {
737+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
738+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
739+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
740+ return templ_7745c5c3_CtxErr
741+ }
742+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
743+ if !templ_7745c5c3_IsBuffer {
744+ defer func() {
745+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
746+ if templ_7745c5c3_Err == nil {
747+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
748+ }
749+ }()
750+ }
751+ ctx = templ.InitializeContext(ctx)
752+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
753+ if templ_7745c5c3_Var38 == nil {
754+ templ_7745c5c3_Var38 = templ.NopComponent
755+ }
756+ ctx = templ.ClearChildren(ctx)
757+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<figure class=\"repo-pulse\"")
758+ if templ_7745c5c3_Err != nil {
759+ return templ_7745c5c3_Err
760+ }
761+ if h.Live {
762+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " data-live")
658763 if templ_7745c5c3_Err != nil {
659764 return templ_7745c5c3_Err
660765 }
661766 }
662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")
768+ if templ_7745c5c3_Err != nil {
769+ return templ_7745c5c3_Err
770+ }
771+ var templ_7745c5c3_Var39 string
772+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)
773+ if templ_7745c5c3_Err != nil {
774+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 59}
775+ }
776+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
777+ if templ_7745c5c3_Err != nil {
778+ return templ_7745c5c3_Err
779+ }
780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" focusable=\"false\"><defs><linearGradient id=\"")
781+ if templ_7745c5c3_Err != nil {
782+ return templ_7745c5c3_Err
783+ }
784+ var templ_7745c5c3_Var40 string
785+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)
786+ if templ_7745c5c3_Err != nil {
787+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 128, Col: 33}
788+ }
789+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
790+ if templ_7745c5c3_Err != nil {
791+ return templ_7745c5c3_Err
792+ }
793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")
794+ if templ_7745c5c3_Err != nil {
795+ return templ_7745c5c3_Err
796+ }
797+ var templ_7745c5c3_Var41 string
798+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)
799+ if templ_7745c5c3_Err != nil {
800+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 134, Col: 38}
801+ }
802+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
803+ if templ_7745c5c3_Err != nil {
804+ return templ_7745c5c3_Err
805+ }
806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" fill=\"")
807+ if templ_7745c5c3_Err != nil {
808+ return templ_7745c5c3_Err
809+ }
810+ var templ_7745c5c3_Var42 string
811+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")
812+ if templ_7745c5c3_Err != nil {
813+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 134, Col: 72}
814+ }
815+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
816+ if templ_7745c5c3_Err != nil {
817+ return templ_7745c5c3_Err
818+ }
819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"></path> <path class=\"pulse-line\" d=\"")
820+ if templ_7745c5c3_Err != nil {
821+ return templ_7745c5c3_Err
822+ }
823+ var templ_7745c5c3_Var43 string
824+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)
825+ if templ_7745c5c3_Err != nil {
826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 38}
827+ }
828+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
829+ if templ_7745c5c3_Err != nil {
830+ return templ_7745c5c3_Err
831+ }
832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-ring\" cx=\"")
833+ if templ_7745c5c3_Err != nil {
834+ return templ_7745c5c3_Err
835+ }
836+ var templ_7745c5c3_Var44 string
837+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
838+ if templ_7745c5c3_Err != nil {
839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 136, Col: 38}
840+ }
841+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
842+ if templ_7745c5c3_Err != nil {
843+ return templ_7745c5c3_Err
844+ }
845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" cy=\"")
846+ if templ_7745c5c3_Err != nil {
847+ return templ_7745c5c3_Err
848+ }
849+ var templ_7745c5c3_Var45 string
850+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
851+ if templ_7745c5c3_Err != nil {
852+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 136, Col: 49}
853+ }
854+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
855+ if templ_7745c5c3_Err != nil {
856+ return templ_7745c5c3_Err
857+ }
858+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" r=\"4\"></circle> <circle class=\"pulse-dot\" cx=\"")
859+ if templ_7745c5c3_Err != nil {
860+ return templ_7745c5c3_Err
861+ }
862+ var templ_7745c5c3_Var46 string
863+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
864+ if templ_7745c5c3_Err != nil {
865+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 37}
866+ }
867+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
868+ if templ_7745c5c3_Err != nil {
869+ return templ_7745c5c3_Err
870+ }
871+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" cy=\"")
872+ if templ_7745c5c3_Err != nil {
873+ return templ_7745c5c3_Err
874+ }
875+ var templ_7745c5c3_Var47 string
876+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
877+ if templ_7745c5c3_Err != nil {
878+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 48}
879+ }
880+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
881+ if templ_7745c5c3_Err != nil {
882+ return templ_7745c5c3_Err
883+ }
884+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" r=\"3.5\"></circle></svg><figcaption class=\"pulse-cap\" aria-hidden=\"true\"><span>90 days ago</span><span>today</span></figcaption></figure>")
663885 if templ_7745c5c3_Err != nil {
664886 return templ_7745c5c3_Err
665887 }
@@ -684,66 +906,66 @@ func Outline(headings []Heading) templ.Component {
684906 }()
685907 }
686908 ctx = templ.InitializeContext(ctx)
687- templ_7745c5c3_Var36 := templ.GetChildren(ctx)
688- if templ_7745c5c3_Var36 == nil {
689- templ_7745c5c3_Var36 = templ.NopComponent
909+ templ_7745c5c3_Var48 := templ.GetChildren(ctx)
910+ if templ_7745c5c3_Var48 == nil {
911+ templ_7745c5c3_Var48 = templ.NopComponent
690912 }
691913 ctx = templ.ClearChildren(ctx)
692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<nav class=\"outline\" aria-label=\"Outline\">")
914+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<nav class=\"outline\" aria-label=\"Outline\">")
693915 if templ_7745c5c3_Err != nil {
694916 return templ_7745c5c3_Err
695917 }
696918 for _, h := range headings {
697- var templ_7745c5c3_Var37 = []any{"l" + itoa(h.Level)}
698- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
919+ var templ_7745c5c3_Var49 = []any{"l" + itoa(h.Level)}
920+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var49...)
699921 if templ_7745c5c3_Err != nil {
700922 return templ_7745c5c3_Err
701923 }
702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"")
924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<a class=\"")
703925 if templ_7745c5c3_Err != nil {
704926 return templ_7745c5c3_Err
705927 }
706- var templ_7745c5c3_Var38 string
707- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String())
928+ var templ_7745c5c3_Var50 string
929+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var49).String())
708930 if templ_7745c5c3_Err != nil {
709931 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
710932 }
711- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
933+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
712934 if templ_7745c5c3_Err != nil {
713935 return templ_7745c5c3_Err
714936 }
715- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" href=\"")
937+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" href=\"")
716938 if templ_7745c5c3_Err != nil {
717939 return templ_7745c5c3_Err
718940 }
719- var templ_7745c5c3_Var39 templ.SafeURL
720- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
941+ var templ_7745c5c3_Var51 templ.SafeURL
942+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
721943 if templ_7745c5c3_Err != nil {
722- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 68}
944+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 68}
723945 }
724- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
946+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
725947 if templ_7745c5c3_Err != nil {
726948 return templ_7745c5c3_Err
727949 }
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")
950+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">")
729951 if templ_7745c5c3_Err != nil {
730952 return templ_7745c5c3_Err
731953 }
732- var templ_7745c5c3_Var40 string
733- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
954+ var templ_7745c5c3_Var52 string
955+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
734956 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 79}
957+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 79}
736958 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
959+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
738960 if templ_7745c5c3_Err != nil {
739961 return templ_7745c5c3_Err
740962 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")
963+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</a>")
742964 if templ_7745c5c3_Err != nil {
743965 return templ_7745c5c3_Err
744966 }
745967 }
746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav>")
968+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</nav>")
747969 if templ_7745c5c3_Err != nil {
748970 return templ_7745c5c3_Err
749971 }
@@ -768,25 +990,25 @@ func ReadmeCard(r Readme) templ.Component {
768990 }()
769991 }
770992 ctx = templ.InitializeContext(ctx)
771- templ_7745c5c3_Var41 := templ.GetChildren(ctx)
772- if templ_7745c5c3_Var41 == nil {
773- templ_7745c5c3_Var41 = templ.NopComponent
993+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
994+ if templ_7745c5c3_Var53 == nil {
995+ templ_7745c5c3_Var53 = templ.NopComponent
774996 }
775997 ctx = templ.ClearChildren(ctx)
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
777999 if templ_7745c5c3_Err != nil {
7781000 return templ_7745c5c3_Err
7791001 }
780- var templ_7745c5c3_Var42 string
781- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1002+ var templ_7745c5c3_Var54 string
1003+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
7821004 if templ_7745c5c3_Err != nil {
783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 11}
1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 157, Col: 11}
7841006 }
785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
1007+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
7861008 if templ_7745c5c3_Err != nil {
7871009 return templ_7745c5c3_Err
7881010 }
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</h2>")
1011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</h2>")
7901012 if templ_7745c5c3_Err != nil {
7911013 return templ_7745c5c3_Err
7921014 }
@@ -794,7 +1016,7 @@ func ReadmeCard(r Readme) templ.Component {
7941016 if templ_7745c5c3_Err != nil {
7951017 return templ_7745c5c3_Err
7961018 }
797- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</section>")
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</section>")
7981020 if templ_7745c5c3_Err != nil {
7991021 return templ_7745c5c3_Err
8001022 }
@@ -820,43 +1042,43 @@ func ReadmeView(r Readme) templ.Component {
8201042 }()
8211043 }
8221044 ctx = templ.InitializeContext(ctx)
823- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
824- if templ_7745c5c3_Var43 == nil {
825- templ_7745c5c3_Var43 = templ.NopComponent
1045+ templ_7745c5c3_Var55 := templ.GetChildren(ctx)
1046+ if templ_7745c5c3_Var55 == nil {
1047+ templ_7745c5c3_Var55 = templ.NopComponent
8261048 }
8271049 ctx = templ.ClearChildren(ctx)
828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<article class=\"prose\" aria-label=\"")
1050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<article class=\"prose\" aria-label=\"")
8291051 if templ_7745c5c3_Err != nil {
8301052 return templ_7745c5c3_Err
8311053 }
832- var templ_7745c5c3_Var44 string
833- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
1054+ var templ_7745c5c3_Var56 string
1055+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
8341056 if templ_7745c5c3_Err != nil {
835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 127, Col: 43}
1057+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 166, Col: 43}
8361058 }
837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
1059+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
8381060 if templ_7745c5c3_Err != nil {
8391061 return templ_7745c5c3_Err
8401062 }
841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-ignore>")
1063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-ignore>")
8421064 if templ_7745c5c3_Err != nil {
8431065 return templ_7745c5c3_Err
8441066 }
8451067 if r.Note != "" {
846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"dim\">")
1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<p class=\"dim\">")
8471069 if templ_7745c5c3_Err != nil {
8481070 return templ_7745c5c3_Err
8491071 }
850- var templ_7745c5c3_Var45 string
851- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
1072+ var templ_7745c5c3_Var57 string
1073+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
8521074 if templ_7745c5c3_Err != nil {
853- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 129, Col: 26}
1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 168, Col: 26}
8541076 }
855- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
1077+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
8561078 if templ_7745c5c3_Err != nil {
8571079 return templ_7745c5c3_Err
8581080 }
859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</p>")
1081+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</p>")
8601082 if templ_7745c5c3_Err != nil {
8611083 return templ_7745c5c3_Err
8621084 }
@@ -866,7 +1088,7 @@ func ReadmeView(r Readme) templ.Component {
8661088 return templ_7745c5c3_Err
8671089 }
8681090 }
869- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</article>")
1091+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</article>")
8701092 if templ_7745c5c3_Err != nil {
8711093 return templ_7745c5c3_Err
8721094 }
@@ -892,133 +1114,133 @@ func CommitRows(rows []CommitRow) templ.Component {
8921114 }()
8931115 }
8941116 ctx = templ.InitializeContext(ctx)
895- templ_7745c5c3_Var46 := templ.GetChildren(ctx)
896- if templ_7745c5c3_Var46 == nil {
897- templ_7745c5c3_Var46 = templ.NopComponent
1117+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1118+ if templ_7745c5c3_Var58 == nil {
1119+ templ_7745c5c3_Var58 = templ.NopComponent
8981120 }
8991121 ctx = templ.ClearChildren(ctx)
900- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
1122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
9011123 if templ_7745c5c3_Err != nil {
9021124 return templ_7745c5c3_Err
9031125 }
9041126 for _, c := range rows {
905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<tr")
1127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<tr")
9061128 if templ_7745c5c3_Err != nil {
9071129 return templ_7745c5c3_Err
9081130 }
9091131 if c.ID != "" {
910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " id=\"")
1132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " id=\"")
9111133 if templ_7745c5c3_Err != nil {
9121134 return templ_7745c5c3_Err
9131135 }
914- var templ_7745c5c3_Var47 string
915- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
1136+ var templ_7745c5c3_Var59 string
1137+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
9161138 if templ_7745c5c3_Err != nil {
917- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 144, Col: 15}
1139+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 183, Col: 15}
9181140 }
919- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
1141+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
9201142 if templ_7745c5c3_Err != nil {
9211143 return templ_7745c5c3_Err
9221144 }
923- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"")
1145+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\"")
9241146 if templ_7745c5c3_Err != nil {
9251147 return templ_7745c5c3_Err
9261148 }
9271149 }
9281150 if c.Cursor {
929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " data-cursor")
1151+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " data-cursor")
9301152 if templ_7745c5c3_Err != nil {
9311153 return templ_7745c5c3_Err
9321154 }
9331155 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "><td class=\"c-sha mute\">")
1156+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "><td class=\"c-sha mute\">")
9351157 if templ_7745c5c3_Err != nil {
9361158 return templ_7745c5c3_Err
9371159 }
938- var templ_7745c5c3_Var48 string
939- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
1160+ var templ_7745c5c3_Var60 string
1161+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
9401162 if templ_7745c5c3_Err != nil {
941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 148, Col: 37}
1163+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 187, Col: 37}
9421164 }
943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1165+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
9441166 if templ_7745c5c3_Err != nil {
9451167 return templ_7745c5c3_Err
9461168 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</td><td class=\"c-subject\"><a href=\"")
1169+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td><td class=\"c-subject\"><a href=\"")
9481170 if templ_7745c5c3_Err != nil {
9491171 return templ_7745c5c3_Err
9501172 }
951- var templ_7745c5c3_Var49 templ.SafeURL
952- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
1173+ var templ_7745c5c3_Var61 templ.SafeURL
1174+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
9531175 if templ_7745c5c3_Err != nil {
954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 38}
1176+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 38}
9551177 }
956- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1178+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
9571179 if templ_7745c5c3_Err != nil {
9581180 return templ_7745c5c3_Err
9591181 }
960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")
1182+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">")
9611183 if templ_7745c5c3_Err != nil {
9621184 return templ_7745c5c3_Err
9631185 }
964- var templ_7745c5c3_Var50 string
965- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
1186+ var templ_7745c5c3_Var62 string
1187+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
9661188 if templ_7745c5c3_Err != nil {
967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 52}
1189+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 52}
9681190 }
969- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1191+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
9701192 if templ_7745c5c3_Err != nil {
9711193 return templ_7745c5c3_Err
9721194 }
973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a> ")
1195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</a> ")
9741196 if templ_7745c5c3_Err != nil {
9751197 return templ_7745c5c3_Err
9761198 }
9771199 if c.Merge {
978- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"pill\">merge</span> ")
1200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<span class=\"pill\">merge</span> ")
9791201 if templ_7745c5c3_Err != nil {
9801202 return templ_7745c5c3_Err
9811203 }
9821204 }
9831205 for _, b := range c.Branches {
984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"pill\">")
1206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<span class=\"pill\">")
9851207 if templ_7745c5c3_Err != nil {
9861208 return templ_7745c5c3_Err
9871209 }
988- var templ_7745c5c3_Var51 string
989- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(b)
1210+ var templ_7745c5c3_Var63 string
1211+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(b)
9901212 if templ_7745c5c3_Err != nil {
991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 155, Col: 29}
1213+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 194, Col: 29}
9921214 }
993- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1215+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
9941216 if templ_7745c5c3_Err != nil {
9951217 return templ_7745c5c3_Err
9961218 }
997- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")
1219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span> ")
9981220 if templ_7745c5c3_Err != nil {
9991221 return templ_7745c5c3_Err
10001222 }
10011223 }
10021224 for _, t := range c.Tags {
1003- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
1225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
10041226 if templ_7745c5c3_Err != nil {
10051227 return templ_7745c5c3_Err
10061228 }
1007- var templ_7745c5c3_Var52 string
1008- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(t)
1229+ var templ_7745c5c3_Var64 string
1230+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(t)
10091231 if templ_7745c5c3_Err != nil {
1010- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 158, Col: 61}
1232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 197, Col: 61}
10111233 }
1012- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1234+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
10131235 if templ_7745c5c3_Err != nil {
10141236 return templ_7745c5c3_Err
10151237 }
1016- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span>")
1238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span>")
10171239 if templ_7745c5c3_Err != nil {
10181240 return templ_7745c5c3_Err
10191241 }
10201242 }
1021- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td class=\"c-ck\">")
1243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</td><td class=\"c-ck\">")
10221244 if templ_7745c5c3_Err != nil {
10231245 return templ_7745c5c3_Err
10241246 }
@@ -1026,64 +1248,64 @@ func CommitRows(rows []CommitRow) templ.Component {
10261248 if templ_7745c5c3_Err != nil {
10271249 return templ_7745c5c3_Err
10281250 }
1029- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td class=\"c-who dim\">")
1251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</td><td class=\"c-who dim\">")
10301252 if templ_7745c5c3_Err != nil {
10311253 return templ_7745c5c3_Err
10321254 }
1033- var templ_7745c5c3_Var53 string
1034- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1255+ var templ_7745c5c3_Var65 string
1256+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
10351257 if templ_7745c5c3_Err != nil {
1036- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 162, Col: 37}
1258+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 201, Col: 37}
10371259 }
1038- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1260+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
10391261 if templ_7745c5c3_Err != nil {
10401262 return templ_7745c5c3_Err
10411263 }
1042- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
1264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
10431265 if templ_7745c5c3_Err != nil {
10441266 return templ_7745c5c3_Err
10451267 }
1046- var templ_7745c5c3_Var54 string
1047- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1268+ var templ_7745c5c3_Var66 string
1269+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
10481270 if templ_7745c5c3_Err != nil {
1049- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 60}
1271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 60}
10501272 }
1051- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1273+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
10521274 if templ_7745c5c3_Err != nil {
10531275 return templ_7745c5c3_Err
10541276 }
1055- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" title=\"")
1277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" title=\"")
10561278 if templ_7745c5c3_Err != nil {
10571279 return templ_7745c5c3_Err
10581280 }
1059- var templ_7745c5c3_Var55 string
1060- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1281+ var templ_7745c5c3_Var67 string
1282+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
10611283 if templ_7745c5c3_Err != nil {
1062- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 79}
1284+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 79}
10631285 }
1064- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
1286+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
10651287 if templ_7745c5c3_Err != nil {
10661288 return templ_7745c5c3_Err
10671289 }
1068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\">")
1290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\">")
10691291 if templ_7745c5c3_Err != nil {
10701292 return templ_7745c5c3_Err
10711293 }
1072- var templ_7745c5c3_Var56 string
1073- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1294+ var templ_7745c5c3_Var68 string
1295+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
10741296 if templ_7745c5c3_Err != nil {
1075- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 90}
1297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 90}
10761298 }
1077- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
10781300 if templ_7745c5c3_Err != nil {
10791301 return templ_7745c5c3_Err
10801302 }
1081- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</time></td></tr>")
1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</time></td></tr>")
10821304 if templ_7745c5c3_Err != nil {
10831305 return templ_7745c5c3_Err
10841306 }
10851307 }
1086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</tbody></table>")
1308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</tbody></table>")
10871309 if templ_7745c5c3_Err != nil {
10881310 return templ_7745c5c3_Err
10891311 }
@@ -147,9 +147,10 @@ func CloneBlock(c Clone) templ.Component {
147 })147 })
148 }148 }
149 149
150-// HomeRowView is the row of a repository in the home list: what it is, its150+// HomeRowView is the row of a repository in the home list. On the left what
151-// latest run, and its newest commits. Its id is the target of SSE swaps; the151+// it is: name, description, topics and the pulse of its activity; on the
152-// name link comes first, `enter` follows it.152+// right its figures and CI state over its newest commits. Its id is the
153+// target of SSE swaps; the name link comes first, `enter` follows it.
153 func HomeRowView(r HomeRow) templ.Component {154 func HomeRowView(r HomeRow) templ.Component {
154 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {155 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
155 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context156 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -178,7 +179,7 @@ func HomeRowView(r HomeRow) templ.Component {
178 var templ_7745c5c3_Var8 string179 var templ_7745c5c3_Var8 string
179 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)180 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
180 if templ_7745c5c3_Err != nil {181 if templ_7745c5c3_Err != nil {
181- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 14}182+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 40, Col: 14}
182 }183 }
183 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)184 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
184 if templ_7745c5c3_Err != nil {185 if templ_7745c5c3_Err != nil {
@@ -201,7 +202,7 @@ func HomeRowView(r HomeRow) templ.Component {
201 var templ_7745c5c3_Var9 templ.SafeURL202 var templ_7745c5c3_Var9 templ.SafeURL
202 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))203 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
203 if templ_7745c5c3_Err != nil {204 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 35}205+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 43, Col: 35}
205 }206 }
206 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))207 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
207 if templ_7745c5c3_Err != nil {208 if templ_7745c5c3_Err != nil {
@@ -214,7 +215,7 @@ func HomeRowView(r HomeRow) templ.Component {
214 var templ_7745c5c3_Var10 string215 var templ_7745c5c3_Var10 string
215 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)216 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
216 if templ_7745c5c3_Err != nil {217 if templ_7745c5c3_Err != nil {
217- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 42, Col: 46}218+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 43, Col: 46}
218 }219 }
219 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))220 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
220 if templ_7745c5c3_Err != nil {221 if templ_7745c5c3_Err != nil {
@@ -248,7 +249,7 @@ func HomeRowView(r HomeRow) templ.Component {
248 var templ_7745c5c3_Var11 string249 var templ_7745c5c3_Var11 string
249 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)250 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
250 if templ_7745c5c3_Err != nil {251 if templ_7745c5c3_Err != nil {
251- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 51, Col: 33}252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 33}
252 }253 }
253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))254 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
254 if templ_7745c5c3_Err != nil {255 if templ_7745c5c3_Err != nil {
@@ -264,402 +265,623 @@ func HomeRowView(r HomeRow) templ.Component {
264 return templ_7745c5c3_Err265 return templ_7745c5c3_Err
265 }266 }
266 }267 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<dl class=\"repo-stats\" aria-label=\"")268+ if len(r.Topics) > 0 {
269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<ul class=\"repo-topics\" aria-label=\"")
270+ if templ_7745c5c3_Err != nil {
271+ return templ_7745c5c3_Err
272+ }
273+ var templ_7745c5c3_Var12 string
274+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Topics of " + r.Name)
275+ if templ_7745c5c3_Err != nil {
276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 62}
277+ }
278+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
283+ if templ_7745c5c3_Err != nil {
284+ return templ_7745c5c3_Err
285+ }
286+ for _, t := range r.Topics {
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<li>")
288+ if templ_7745c5c3_Err != nil {
289+ return templ_7745c5c3_Err
290+ }
291+ var templ_7745c5c3_Var13 string
292+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t)
293+ if templ_7745c5c3_Err != nil {
294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 59, Col: 13}
295+ }
296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
297+ if templ_7745c5c3_Err != nil {
298+ return templ_7745c5c3_Err
299+ }
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</li>")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ }
305+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</ul>")
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ }
310+ if r.Stats != nil && r.Stats.Pulse != nil {
311+ templ_7745c5c3_Err = pulse(r.Stats.Pulse).Render(ctx, templ_7745c5c3_Buffer)
312+ if templ_7745c5c3_Err != nil {
313+ return templ_7745c5c3_Err
314+ }
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div><div class=\"repo-head\"><dl class=\"repo-stats\" aria-label=\"")
268 if templ_7745c5c3_Err != nil {317 if templ_7745c5c3_Err != nil {
269 return templ_7745c5c3_Err318 return templ_7745c5c3_Err
270 }319 }
271- var templ_7745c5c3_Var12 string320+ var templ_7745c5c3_Var14 string
272- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)321+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Figures of " + r.Name)
273 if templ_7745c5c3_Err != nil {322 if templ_7745c5c3_Err != nil {
274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 61}323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 68, Col: 61}
275 }324 }
276- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)325+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
277 if templ_7745c5c3_Err != nil {326 if templ_7745c5c3_Err != nil {
278 return templ_7745c5c3_Err327 return templ_7745c5c3_Err
279 }328 }
280- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
281 if templ_7745c5c3_Err != nil {330 if templ_7745c5c3_Err != nil {
282 return templ_7745c5c3_Err331 return templ_7745c5c3_Err
283 }332 }
284 if s := r.Stats; s != nil {333 if s := r.Stats; s != nil {
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div><dt>commits</dt><dd>")334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div><dt>commits</dt><dd>")
286 if templ_7745c5c3_Err != nil {335 if templ_7745c5c3_Err != nil {
287 return templ_7745c5c3_Err336 return templ_7745c5c3_Err
288 }337 }
289- var templ_7745c5c3_Var13 string338+ var templ_7745c5c3_Var15 string
290- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)339+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(s.Commits)
291 if templ_7745c5c3_Err != nil {340 if templ_7745c5c3_Err != nil {
292- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 41}341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 41}
293 }342 }
294- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
295 if templ_7745c5c3_Err != nil {344 if templ_7745c5c3_Err != nil {
296 return templ_7745c5c3_Err345 return templ_7745c5c3_Err
297 }346 }
298- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</dd></div><div><dt>last 30 days</dt><dd>")347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</dd></div><div><dt>last 30 days</dt><dd>")
299 if templ_7745c5c3_Err != nil {348 if templ_7745c5c3_Err != nil {
300 return templ_7745c5c3_Err349 return templ_7745c5c3_Err
301 }350 }
302- var templ_7745c5c3_Var14 string351+ var templ_7745c5c3_Var16 string
303- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)352+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(s.Recent)
304 if templ_7745c5c3_Err != nil {353 if templ_7745c5c3_Err != nil {
305- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 58, Col: 45}354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 71, Col: 45}
306 }355 }
307- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
308 if templ_7745c5c3_Err != nil {357 if templ_7745c5c3_Err != nil {
309 return templ_7745c5c3_Err358 return templ_7745c5c3_Err
310 }359 }
311- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</dd></div>")360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</dd></div>")
312 if templ_7745c5c3_Err != nil {361 if templ_7745c5c3_Err != nil {
313 return templ_7745c5c3_Err362 return templ_7745c5c3_Err
314 }363 }
315 if s.Tag != "" {364 if s.Tag != "" {
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div><dt>latest tag</dt>")365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"rs-tag\"><dt>latest tag</dt>")
317 if templ_7745c5c3_Err != nil {366 if templ_7745c5c3_Err != nil {
318 return templ_7745c5c3_Err367 return templ_7745c5c3_Err
319 }368 }
320 if s.TagHref != "" {369 if s.TagHref != "" {
321- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<dd><a href=\"")370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<dd><a href=\"")
322 if templ_7745c5c3_Err != nil {371 if templ_7745c5c3_Err != nil {
323 return templ_7745c5c3_Err372 return templ_7745c5c3_Err
324 }373 }
325- var templ_7745c5c3_Var15 templ.SafeURL374+ var templ_7745c5c3_Var17 templ.SafeURL
326- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))375+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.TagHref))
327 if templ_7745c5c3_Err != nil {376 if templ_7745c5c3_Err != nil {
328- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 46}377+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 46}
329 }378 }
330- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))379+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
331 if templ_7745c5c3_Err != nil {380 if templ_7745c5c3_Err != nil {
332 return templ_7745c5c3_Err381 return templ_7745c5c3_Err
333 }382 }
334- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" title=\"")383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" title=\"")
335 if templ_7745c5c3_Err != nil {384 if templ_7745c5c3_Err != nil {
336 return templ_7745c5c3_Err385 return templ_7745c5c3_Err
337 }386 }
338- var templ_7745c5c3_Var16 string387+ var templ_7745c5c3_Var18 string
339- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)388+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
340 if templ_7745c5c3_Err != nil {389 if templ_7745c5c3_Err != nil {
341- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 76}390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 88}
342 }391 }
343- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
344 if templ_7745c5c3_Err != nil {393 if templ_7745c5c3_Err != nil {
345 return templ_7745c5c3_Err394 return templ_7745c5c3_Err
346 }395 }
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">")396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">")
348 if templ_7745c5c3_Err != nil {397 if templ_7745c5c3_Err != nil {
349 return templ_7745c5c3_Err398 return templ_7745c5c3_Err
350 }399 }
351- var templ_7745c5c3_Var17 string400+ var templ_7745c5c3_Var19 string
352- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)401+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
353 if templ_7745c5c3_Err != nil {402 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 63, Col: 86}403+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 76, Col: 98}
355 }404 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))405+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
357 if templ_7745c5c3_Err != nil {406 if templ_7745c5c3_Err != nil {
358 return templ_7745c5c3_Err407 return templ_7745c5c3_Err
359 }408 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</a></dd>")409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a></dd>")
361 if templ_7745c5c3_Err != nil {410 if templ_7745c5c3_Err != nil {
362 return templ_7745c5c3_Err411 return templ_7745c5c3_Err
363 }412 }
364 } else {413 } else {
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<dd title=\"")414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd title=\"")
366 if templ_7745c5c3_Err != nil {415 if templ_7745c5c3_Err != nil {
367 return templ_7745c5c3_Err416 return templ_7745c5c3_Err
368 }417 }
369- var templ_7745c5c3_Var18 string418+ var templ_7745c5c3_Var20 string
370- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("made " + s.TagWhen)419+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Tag + " · made " + s.TagWhen)
371 if templ_7745c5c3_Err != nil {420 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 39}421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 51}
373 }422 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
375 if templ_7745c5c3_Err != nil {424 if templ_7745c5c3_Err != nil {
376 return templ_7745c5c3_Err425 return templ_7745c5c3_Err
377 }426 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">")
379 if templ_7745c5c3_Err != nil {428 if templ_7745c5c3_Err != nil {
380 return templ_7745c5c3_Err429 return templ_7745c5c3_Err
381 }430 }
382- var templ_7745c5c3_Var19 string431+ var templ_7745c5c3_Var21 string
383- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)432+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(s.Tag)
384 if templ_7745c5c3_Err != nil {433 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 49}434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 78, Col: 61}
386 }435 }
387- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
388 if templ_7745c5c3_Err != nil {437 if templ_7745c5c3_Err != nil {
389 return templ_7745c5c3_Err438 return templ_7745c5c3_Err
390 }439 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</dd>")440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</dd>")
392 if templ_7745c5c3_Err != nil {441 if templ_7745c5c3_Err != nil {
393 return templ_7745c5c3_Err442 return templ_7745c5c3_Err
394 }443 }
395 }444 }
396- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</div>")445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
397 if templ_7745c5c3_Err != nil {446 if templ_7745c5c3_Err != nil {
398 return templ_7745c5c3_Err447 return templ_7745c5c3_Err
399 }448 }
400 }449 }
401 }450 }
402- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div><dt>last activity</dt>")451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<div><dt>last activity</dt>")
403 if templ_7745c5c3_Err != nil {452 if templ_7745c5c3_Err != nil {
404 return templ_7745c5c3_Err453 return templ_7745c5c3_Err
405 }454 }
406 if r.ActiveAt != "" {455 if r.ActiveAt != "" {
407- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<dd><time datetime=\"")456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<dd><time datetime=\"")
408 if templ_7745c5c3_Err != nil {457 if templ_7745c5c3_Err != nil {
409 return templ_7745c5c3_Err458 return templ_7745c5c3_Err
410 }459 }
411- var templ_7745c5c3_Var20 string460+ var templ_7745c5c3_Var22 string
412- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)461+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
413 if templ_7745c5c3_Err != nil {462 if templ_7745c5c3_Err != nil {
414- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 37}463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 37}
415 }464 }
416- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
417 if templ_7745c5c3_Err != nil {466 if templ_7745c5c3_Err != nil {
418 return templ_7745c5c3_Err467 return templ_7745c5c3_Err
419 }468 }
420- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" title=\"")469+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" title=\"")
421 if templ_7745c5c3_Err != nil {470 if templ_7745c5c3_Err != nil {
422 return templ_7745c5c3_Err471 return templ_7745c5c3_Err
423 }472 }
424- var templ_7745c5c3_Var21 string473+ var templ_7745c5c3_Var23 string
425- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)474+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ActiveAt)
426 if templ_7745c5c3_Err != nil {475 if templ_7745c5c3_Err != nil {
427- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 58}476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 58}
428 }477 }
429- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
430 if templ_7745c5c3_Err != nil {479 if templ_7745c5c3_Err != nil {
431 return templ_7745c5c3_Err480 return templ_7745c5c3_Err
432 }481 }
433- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
434 if templ_7745c5c3_Err != nil {483 if templ_7745c5c3_Err != nil {
435 return templ_7745c5c3_Err484 return templ_7745c5c3_Err
436 }485 }
437- var templ_7745c5c3_Var22 string486+ var templ_7745c5c3_Var24 string
438- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)487+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.Active)
439 if templ_7745c5c3_Err != nil {488 if templ_7745c5c3_Err != nil {
440- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 71}489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 86, Col: 71}
441 }490 }
442- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))491+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
443 if templ_7745c5c3_Err != nil {492 if templ_7745c5c3_Err != nil {
444 return templ_7745c5c3_Err493 return templ_7745c5c3_Err
445 }494 }
446- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</time></dd>")495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</time></dd>")
447 if templ_7745c5c3_Err != nil {496 if templ_7745c5c3_Err != nil {
448 return templ_7745c5c3_Err497 return templ_7745c5c3_Err
449 }498 }
450 } else {499 } else {
451- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<dd class=\"mute\">never</dd>")500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<dd class=\"mute\">never</dd>")
452 if templ_7745c5c3_Err != nil {501 if templ_7745c5c3_Err != nil {
453 return templ_7745c5c3_Err502 return templ_7745c5c3_Err
454 }503 }
455 }504 }
456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></dl></div>")505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div></dl>")
457 if templ_7745c5c3_Err != nil {506 if templ_7745c5c3_Err != nil {
458 return templ_7745c5c3_Err507 return templ_7745c5c3_Err
459 }508 }
460 if r.CI != nil {509 if r.CI != nil {
461- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<a class=\"repo-ci\" href=\"")510+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"repo-ci\" href=\"")
462 if templ_7745c5c3_Err != nil {511 if templ_7745c5c3_Err != nil {
463 return templ_7745c5c3_Err512 return templ_7745c5c3_Err
464 }513 }
465- var templ_7745c5c3_Var23 templ.SafeURL514+ var templ_7745c5c3_Var25 templ.SafeURL
466- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))515+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CI.Href))
467 if templ_7745c5c3_Err != nil {516 if templ_7745c5c3_Err != nil {
468- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 53}517+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 54}
469 }518 }
470- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))519+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
471 if templ_7745c5c3_Err != nil {520 if templ_7745c5c3_Err != nil {
472 return templ_7745c5c3_Err521 return templ_7745c5c3_Err
473 }522 }
474- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" title=\"")523+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" title=\"")
475 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
476 return templ_7745c5c3_Err525 return templ_7745c5c3_Err
477 }526 }
478- var templ_7745c5c3_Var24 string527+ var templ_7745c5c3_Var26 string
479- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)528+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CI.Title)
480 if templ_7745c5c3_Err != nil {529 if templ_7745c5c3_Err != nil {
481- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 81, Col: 74}530+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 75}
482 }531 }
483- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)532+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
484 if templ_7745c5c3_Err != nil {533 if templ_7745c5c3_Err != nil {
485 return templ_7745c5c3_Err534 return templ_7745c5c3_Err
486 }535 }
487- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")536+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
488 if templ_7745c5c3_Err != nil {537 if templ_7745c5c3_Err != nil {
489 return templ_7745c5c3_Err538 return templ_7745c5c3_Err
490 }539 }
491- var templ_7745c5c3_Var25 = []any{"ck", "ck-" + r.CI.Kind}540+ var templ_7745c5c3_Var27 = []any{"ck", "ck-" + r.CI.Kind}
492- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...)541+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...)
493 if templ_7745c5c3_Err != nil {542 if templ_7745c5c3_Err != nil {
494 return templ_7745c5c3_Err543 return templ_7745c5c3_Err
495 }544 }
496- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"")545+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"")
497 if templ_7745c5c3_Err != nil {546 if templ_7745c5c3_Err != nil {
498 return templ_7745c5c3_Err547 return templ_7745c5c3_Err
499 }548 }
500- var templ_7745c5c3_Var26 string549+ var templ_7745c5c3_Var28 string
501- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var25).String())550+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var27).String())
502 if templ_7745c5c3_Err != nil {551 if templ_7745c5c3_Err != nil {
503 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}552 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
504 }553 }
505- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)554+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
506 if templ_7745c5c3_Err != nil {555 if templ_7745c5c3_Err != nil {
507 return templ_7745c5c3_Err556 return templ_7745c5c3_Err
508 }557 }
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" aria-hidden=\"true\"></span> <span class=\"sr\">CI: </span>")
510 if templ_7745c5c3_Err != nil {559 if templ_7745c5c3_Err != nil {
511 return templ_7745c5c3_Err560 return templ_7745c5c3_Err
512 }561 }
513- var templ_7745c5c3_Var27 string562+ var templ_7745c5c3_Var29 string
514- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)563+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.CI.Label)
515 if templ_7745c5c3_Err != nil {564 if templ_7745c5c3_Err != nil {
516- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 83, Col: 44}565+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 45}
517 }566 }
518- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))567+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
519 if templ_7745c5c3_Err != nil {568 if templ_7745c5c3_Err != nil {
520 return templ_7745c5c3_Err569 return templ_7745c5c3_Err
521 }570 }
522- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")571+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a>")
523 if templ_7745c5c3_Err != nil {572 if templ_7745c5c3_Err != nil {
524 return templ_7745c5c3_Err573 return templ_7745c5c3_Err
525 }574 }
526 }575 }
576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</div>")
577+ if templ_7745c5c3_Err != nil {
578+ return templ_7745c5c3_Err
579+ }
527 if len(r.Commits) > 0 {580 if len(r.Commits) > 0 {
528- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<ul class=\"repo-commits\" aria-label=\"")581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<ul class=\"repo-commits\"")
529 if templ_7745c5c3_Err != nil {582 if templ_7745c5c3_Err != nil {
530 return templ_7745c5c3_Err583 return templ_7745c5c3_Err
531 }584 }
532- var templ_7745c5c3_Var28 string585+ if len(r.Commits) == homeRecent {
533- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " data-full")
587+ if templ_7745c5c3_Err != nil {
588+ return templ_7745c5c3_Err
589+ }
590+ }
591+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " aria-label=\"")
534 if templ_7745c5c3_Err != nil {592 if templ_7745c5c3_Err != nil {
535- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 87, Col: 70}593+ return templ_7745c5c3_Err
536 }594 }
537- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)595+ var templ_7745c5c3_Var30 string
596+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Recent commits of " + r.Name)
597+ if templ_7745c5c3_Err != nil {
598+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 46}
599+ }
600+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
538 if templ_7745c5c3_Err != nil {601 if templ_7745c5c3_Err != nil {
539 return templ_7745c5c3_Err602 return templ_7745c5c3_Err
540 }603 }
541- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")
542 if templ_7745c5c3_Err != nil {605 if templ_7745c5c3_Err != nil {
543 return templ_7745c5c3_Err606 return templ_7745c5c3_Err
544 }607 }
545 for _, c := range r.Commits {608 for _, c := range r.Commits {
546- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<li><a class=\"rc-sha\" href=\"")609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<li><a class=\"rc-sha\" href=\"")
547 if templ_7745c5c3_Err != nil {610 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err611 return templ_7745c5c3_Err
549 }612 }
550- var templ_7745c5c3_Var29 templ.SafeURL613+ var templ_7745c5c3_Var31 templ.SafeURL
551- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))614+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
552 if templ_7745c5c3_Err != nil {615 if templ_7745c5c3_Err != nil {
553- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 53}616+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 53}
554 }617 }
555- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))618+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
556 if templ_7745c5c3_Err != nil {619 if templ_7745c5c3_Err != nil {
557 return templ_7745c5c3_Err620 return templ_7745c5c3_Err
558 }621 }
559- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")622+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">")
560 if templ_7745c5c3_Err != nil {623 if templ_7745c5c3_Err != nil {
561 return templ_7745c5c3_Err624 return templ_7745c5c3_Err
562 }625 }
563- var templ_7745c5c3_Var30 string626+ var templ_7745c5c3_Var32 string
564- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)627+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
565 if templ_7745c5c3_Err != nil {628 if templ_7745c5c3_Err != nil {
566- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 65}629+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 107, Col: 65}
567 }630 }
568- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))631+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
569 if templ_7745c5c3_Err != nil {632 if templ_7745c5c3_Err != nil {
570 return templ_7745c5c3_Err633 return templ_7745c5c3_Err
571 }634 }
572- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a> <span class=\"rc-subject\" title=\"")635+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</a> <span class=\"rc-subject\" title=\"")
573 if templ_7745c5c3_Err != nil {636 if templ_7745c5c3_Err != nil {
574 return templ_7745c5c3_Err637 return templ_7745c5c3_Err
575 }638 }
576- var templ_7745c5c3_Var31 string639+ var templ_7745c5c3_Var33 string
577- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)640+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Subject)
578 if templ_7745c5c3_Err != nil {641 if templ_7745c5c3_Err != nil {
579- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 48}642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 48}
580 }643 }
581- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)644+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
582 if templ_7745c5c3_Err != nil {645 if templ_7745c5c3_Err != nil {
583 return templ_7745c5c3_Err646 return templ_7745c5c3_Err
584 }647 }
585- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">")648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
586 if templ_7745c5c3_Err != nil {649 if templ_7745c5c3_Err != nil {
587 return templ_7745c5c3_Err650 return templ_7745c5c3_Err
588 }651 }
589- var templ_7745c5c3_Var32 string652+ var templ_7745c5c3_Var34 string
590- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)653+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
591 if templ_7745c5c3_Err != nil {654 if templ_7745c5c3_Err != nil {
592- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 91, Col: 62}655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 62}
593 }656 }
594- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
595 if templ_7745c5c3_Err != nil {658 if templ_7745c5c3_Err != nil {
596 return templ_7745c5c3_Err659 return templ_7745c5c3_Err
597 }660 }
598- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</span> ")
599 if templ_7745c5c3_Err != nil {662 if templ_7745c5c3_Err != nil {
600 return templ_7745c5c3_Err663 return templ_7745c5c3_Err
601 }664 }
602 if c.WhenAt != "" {665 if c.WhenAt != "" {
603- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<time class=\"dim\" datetime=\"")666+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<time class=\"dim\" datetime=\"")
604 if templ_7745c5c3_Err != nil {667 if templ_7745c5c3_Err != nil {
605 return templ_7745c5c3_Err668 return templ_7745c5c3_Err
606 }669 }
607- var templ_7745c5c3_Var33 string670+ var templ_7745c5c3_Var35 string
608- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)671+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
609 if templ_7745c5c3_Err != nil {672 if templ_7745c5c3_Err != nil {
610- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 44}673+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 44}
611 }674 }
612- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)675+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
613 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
614 return templ_7745c5c3_Err677 return templ_7745c5c3_Err
615 }678 }
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" title=\"")679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" title=\"")
617 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
618 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
619 }682 }
620- var templ_7745c5c3_Var34 string683+ var templ_7745c5c3_Var36 string
621- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)684+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
622 if templ_7745c5c3_Err != nil {685 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 63}686+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 63}
624 }687 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)688+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
626 if templ_7745c5c3_Err != nil {689 if templ_7745c5c3_Err != nil {
627 return templ_7745c5c3_Err690 return templ_7745c5c3_Err
628 }691 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">")692+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
630 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
631 return templ_7745c5c3_Err694 return templ_7745c5c3_Err
632 }695 }
633- var templ_7745c5c3_Var35 string696+ var templ_7745c5c3_Var37 string
634- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)697+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
635 if templ_7745c5c3_Err != nil {698 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 93, Col: 74}699+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 110, Col: 74}
637 }700 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))701+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
639 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err703 return templ_7745c5c3_Err
641 }704 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</time>")705+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</time>")
643 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err707 return templ_7745c5c3_Err
645 }708 }
646 }709 }
647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>")710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</li>")
648 if templ_7745c5c3_Err != nil {711 if templ_7745c5c3_Err != nil {
649 return templ_7745c5c3_Err712 return templ_7745c5c3_Err
650 }713 }
651 }714 }
652- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>")715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</ul>")
653 if templ_7745c5c3_Err != nil {716 if templ_7745c5c3_Err != nil {
654 return templ_7745c5c3_Err717 return templ_7745c5c3_Err
655 }718 }
656 } else if r.NoCommits {719 } else if r.NoCommits {
657- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"repo-commits mute\">no commits yet</p>")720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<p class=\"repo-commits mute\">no commits yet</p>")
721+ if templ_7745c5c3_Err != nil {
722+ return templ_7745c5c3_Err
723+ }
724+ }
725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</li>")
726+ if templ_7745c5c3_Err != nil {
727+ return templ_7745c5c3_Err
728+ }
729+ return nil
730+ })
731+}
732+
733+// pulse is the activity curve of a home row: a smooth line over a fading
734+// area on a faint grid, and a dot at today that beats while the repository
735+// is active. The SVG scales evenly with its column, so the dot stays round.
736+func pulse(h *Heartbeat) templ.Component {
737+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
738+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
739+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
740+ return templ_7745c5c3_CtxErr
741+ }
742+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
743+ if !templ_7745c5c3_IsBuffer {
744+ defer func() {
745+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
746+ if templ_7745c5c3_Err == nil {
747+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
748+ }
749+ }()
750+ }
751+ ctx = templ.InitializeContext(ctx)
752+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
753+ if templ_7745c5c3_Var38 == nil {
754+ templ_7745c5c3_Var38 = templ.NopComponent
755+ }
756+ ctx = templ.ClearChildren(ctx)
757+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<figure class=\"repo-pulse\"")
758+ if templ_7745c5c3_Err != nil {
759+ return templ_7745c5c3_Err
760+ }
761+ if h.Live {
762+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " data-live")
658 if templ_7745c5c3_Err != nil {763 if templ_7745c5c3_Err != nil {
659 return templ_7745c5c3_Err764 return templ_7745c5c3_Err
660 }765 }
661 }766 }
662- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")767+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "><svg viewBox=\"0 0 600 64\" role=\"img\" aria-label=\"")
768+ if templ_7745c5c3_Err != nil {
769+ return templ_7745c5c3_Err
770+ }
771+ var templ_7745c5c3_Var39 string
772+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Label)
773+ if templ_7745c5c3_Err != nil {
774+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 126, Col: 59}
775+ }
776+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
777+ if templ_7745c5c3_Err != nil {
778+ return templ_7745c5c3_Err
779+ }
780+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" focusable=\"false\"><defs><linearGradient id=\"")
781+ if templ_7745c5c3_Err != nil {
782+ return templ_7745c5c3_Err
783+ }
784+ var templ_7745c5c3_Var40 string
785+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.GradID)
786+ if templ_7745c5c3_Err != nil {
787+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 128, Col: 33}
788+ }
789+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
790+ if templ_7745c5c3_Err != nil {
791+ return templ_7745c5c3_Err
792+ }
793+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop class=\"pulse-stop-top\" offset=\"0\"></stop> <stop class=\"pulse-stop-bottom\" offset=\"1\"></stop></linearGradient></defs> <path class=\"pulse-grid\" d=\"M0 21.5 H600 M0 42.5 H600 M0 63.5 H600\" vector-effect=\"non-scaling-stroke\"></path> <path class=\"pulse-area\" d=\"")
794+ if templ_7745c5c3_Err != nil {
795+ return templ_7745c5c3_Err
796+ }
797+ var templ_7745c5c3_Var41 string
798+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Area)
799+ if templ_7745c5c3_Err != nil {
800+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 134, Col: 38}
801+ }
802+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
803+ if templ_7745c5c3_Err != nil {
804+ return templ_7745c5c3_Err
805+ }
806+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" fill=\"")
807+ if templ_7745c5c3_Err != nil {
808+ return templ_7745c5c3_Err
809+ }
810+ var templ_7745c5c3_Var42 string
811+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue("url(#" + h.GradID + ")")
812+ if templ_7745c5c3_Err != nil {
813+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 134, Col: 72}
814+ }
815+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
816+ if templ_7745c5c3_Err != nil {
817+ return templ_7745c5c3_Err
818+ }
819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\"></path> <path class=\"pulse-line\" d=\"")
820+ if templ_7745c5c3_Err != nil {
821+ return templ_7745c5c3_Err
822+ }
823+ var templ_7745c5c3_Var43 string
824+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Line)
825+ if templ_7745c5c3_Err != nil {
826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 135, Col: 38}
827+ }
828+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
829+ if templ_7745c5c3_Err != nil {
830+ return templ_7745c5c3_Err
831+ }
832+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" vector-effect=\"non-scaling-stroke\"></path> <circle class=\"pulse-ring\" cx=\"")
833+ if templ_7745c5c3_Err != nil {
834+ return templ_7745c5c3_Err
835+ }
836+ var templ_7745c5c3_Var44 string
837+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
838+ if templ_7745c5c3_Err != nil {
839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 136, Col: 38}
840+ }
841+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
842+ if templ_7745c5c3_Err != nil {
843+ return templ_7745c5c3_Err
844+ }
845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" cy=\"")
846+ if templ_7745c5c3_Err != nil {
847+ return templ_7745c5c3_Err
848+ }
849+ var templ_7745c5c3_Var45 string
850+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
851+ if templ_7745c5c3_Err != nil {
852+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 136, Col: 49}
853+ }
854+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
855+ if templ_7745c5c3_Err != nil {
856+ return templ_7745c5c3_Err
857+ }
858+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" r=\"4\"></circle> <circle class=\"pulse-dot\" cx=\"")
859+ if templ_7745c5c3_Err != nil {
860+ return templ_7745c5c3_Err
861+ }
862+ var templ_7745c5c3_Var46 string
863+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.X)
864+ if templ_7745c5c3_Err != nil {
865+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 37}
866+ }
867+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
868+ if templ_7745c5c3_Err != nil {
869+ return templ_7745c5c3_Err
870+ }
871+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" cy=\"")
872+ if templ_7745c5c3_Err != nil {
873+ return templ_7745c5c3_Err
874+ }
875+ var templ_7745c5c3_Var47 string
876+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(h.Y)
877+ if templ_7745c5c3_Err != nil {
878+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 137, Col: 48}
879+ }
880+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
881+ if templ_7745c5c3_Err != nil {
882+ return templ_7745c5c3_Err
883+ }
884+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" r=\"3.5\"></circle></svg><figcaption class=\"pulse-cap\" aria-hidden=\"true\"><span>90 days ago</span><span>today</span></figcaption></figure>")
663 if templ_7745c5c3_Err != nil {885 if templ_7745c5c3_Err != nil {
664 return templ_7745c5c3_Err886 return templ_7745c5c3_Err
665 }887 }
@@ -684,66 +906,66 @@ func Outline(headings []Heading) templ.Component {
684 }()906 }()
685 }907 }
686 ctx = templ.InitializeContext(ctx)908 ctx = templ.InitializeContext(ctx)
687- templ_7745c5c3_Var36 := templ.GetChildren(ctx)909+ templ_7745c5c3_Var48 := templ.GetChildren(ctx)
688- if templ_7745c5c3_Var36 == nil {910+ if templ_7745c5c3_Var48 == nil {
689- templ_7745c5c3_Var36 = templ.NopComponent911+ templ_7745c5c3_Var48 = templ.NopComponent
690 }912 }
691 ctx = templ.ClearChildren(ctx)913 ctx = templ.ClearChildren(ctx)
692- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<nav class=\"outline\" aria-label=\"Outline\">")914+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<nav class=\"outline\" aria-label=\"Outline\">")
693 if templ_7745c5c3_Err != nil {915 if templ_7745c5c3_Err != nil {
694 return templ_7745c5c3_Err916 return templ_7745c5c3_Err
695 }917 }
696 for _, h := range headings {918 for _, h := range headings {
697- var templ_7745c5c3_Var37 = []any{"l" + itoa(h.Level)}919+ var templ_7745c5c3_Var49 = []any{"l" + itoa(h.Level)}
698- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)920+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var49...)
699 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
700 return templ_7745c5c3_Err922 return templ_7745c5c3_Err
701 }923 }
702- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<a class=\"")924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<a class=\"")
703 if templ_7745c5c3_Err != nil {925 if templ_7745c5c3_Err != nil {
704 return templ_7745c5c3_Err926 return templ_7745c5c3_Err
705 }927 }
706- var templ_7745c5c3_Var38 string928+ var templ_7745c5c3_Var50 string
707- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var37).String())929+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var49).String())
708 if templ_7745c5c3_Err != nil {930 if templ_7745c5c3_Err != nil {
709 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}931 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
710 }932 }
711- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)933+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
712 if templ_7745c5c3_Err != nil {934 if templ_7745c5c3_Err != nil {
713 return templ_7745c5c3_Err935 return templ_7745c5c3_Err
714 }936 }
715- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\" href=\"")937+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" href=\"")
716 if templ_7745c5c3_Err != nil {938 if templ_7745c5c3_Err != nil {
717 return templ_7745c5c3_Err939 return templ_7745c5c3_Err
718 }940 }
719- var templ_7745c5c3_Var39 templ.SafeURL941+ var templ_7745c5c3_Var51 templ.SafeURL
720- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))942+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
721 if templ_7745c5c3_Err != nil {943 if templ_7745c5c3_Err != nil {
722- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 68}944+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 68}
723 }945 }
724- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))946+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
725 if templ_7745c5c3_Err != nil {947 if templ_7745c5c3_Err != nil {
726 return templ_7745c5c3_Err948 return templ_7745c5c3_Err
727 }949 }
728- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">")950+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">")
729 if templ_7745c5c3_Err != nil {951 if templ_7745c5c3_Err != nil {
730 return templ_7745c5c3_Err952 return templ_7745c5c3_Err
731 }953 }
732- var templ_7745c5c3_Var40 string954+ var templ_7745c5c3_Var52 string
733- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)955+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
734 if templ_7745c5c3_Err != nil {956 if templ_7745c5c3_Err != nil {
735- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 79}957+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 147, Col: 79}
736 }958 }
737- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))959+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
738 if templ_7745c5c3_Err != nil {960 if templ_7745c5c3_Err != nil {
739 return templ_7745c5c3_Err961 return templ_7745c5c3_Err
740 }962 }
741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</a>")963+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</a>")
742 if templ_7745c5c3_Err != nil {964 if templ_7745c5c3_Err != nil {
743 return templ_7745c5c3_Err965 return templ_7745c5c3_Err
744 }966 }
745 }967 }
746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</nav>")968+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</nav>")
747 if templ_7745c5c3_Err != nil {969 if templ_7745c5c3_Err != nil {
748 return templ_7745c5c3_Err970 return templ_7745c5c3_Err
749 }971 }
@@ -768,25 +990,25 @@ func ReadmeCard(r Readme) templ.Component {
768 }()990 }()
769 }991 }
770 ctx = templ.InitializeContext(ctx)992 ctx = templ.InitializeContext(ctx)
771- templ_7745c5c3_Var41 := templ.GetChildren(ctx)993+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
772- if templ_7745c5c3_Var41 == nil {994+ if templ_7745c5c3_Var53 == nil {
773- templ_7745c5c3_Var41 = templ.NopComponent995+ templ_7745c5c3_Var53 = templ.NopComponent
774 }996 }
775 ctx = templ.ClearChildren(ctx)997 ctx = templ.ClearChildren(ctx)
776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<section class=\"card readme\" aria-labelledby=\"readme-h\"><h2 class=\"card-h\" id=\"readme-h\"><span class=\"ico i-file\" aria-hidden=\"true\"></span> ")
777 if templ_7745c5c3_Err != nil {999 if templ_7745c5c3_Err != nil {
778 return templ_7745c5c3_Err1000 return templ_7745c5c3_Err
779 }1001 }
780- var templ_7745c5c3_Var42 string1002+ var templ_7745c5c3_Var54 string
781- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1003+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
782 if templ_7745c5c3_Err != nil {1004 if templ_7745c5c3_Err != nil {
783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 118, Col: 11}1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 157, Col: 11}
784 }1006 }
785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))1007+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
786 if templ_7745c5c3_Err != nil {1008 if templ_7745c5c3_Err != nil {
787 return templ_7745c5c3_Err1009 return templ_7745c5c3_Err
788 }1010 }
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</h2>")1011+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</h2>")
790 if templ_7745c5c3_Err != nil {1012 if templ_7745c5c3_Err != nil {
791 return templ_7745c5c3_Err1013 return templ_7745c5c3_Err
792 }1014 }
@@ -794,7 +1016,7 @@ func ReadmeCard(r Readme) templ.Component {
794 if templ_7745c5c3_Err != nil {1016 if templ_7745c5c3_Err != nil {
795 return templ_7745c5c3_Err1017 return templ_7745c5c3_Err
796 }1018 }
797- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</section>")1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</section>")
798 if templ_7745c5c3_Err != nil {1020 if templ_7745c5c3_Err != nil {
799 return templ_7745c5c3_Err1021 return templ_7745c5c3_Err
800 }1022 }
@@ -820,43 +1042,43 @@ func ReadmeView(r Readme) templ.Component {
820 }()1042 }()
821 }1043 }
822 ctx = templ.InitializeContext(ctx)1044 ctx = templ.InitializeContext(ctx)
823- templ_7745c5c3_Var43 := templ.GetChildren(ctx)1045+ templ_7745c5c3_Var55 := templ.GetChildren(ctx)
824- if templ_7745c5c3_Var43 == nil {1046+ if templ_7745c5c3_Var55 == nil {
825- templ_7745c5c3_Var43 = templ.NopComponent1047+ templ_7745c5c3_Var55 = templ.NopComponent
826 }1048 }
827 ctx = templ.ClearChildren(ctx)1049 ctx = templ.ClearChildren(ctx)
828- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<article class=\"prose\" aria-label=\"")1050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<article class=\"prose\" aria-label=\"")
829 if templ_7745c5c3_Err != nil {1051 if templ_7745c5c3_Err != nil {
830 return templ_7745c5c3_Err1052 return templ_7745c5c3_Err
831 }1053 }
832- var templ_7745c5c3_Var44 string1054+ var templ_7745c5c3_Var56 string
833- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)1055+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
834 if templ_7745c5c3_Err != nil {1056 if templ_7745c5c3_Err != nil {
835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 127, Col: 43}1057+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 166, Col: 43}
836 }1058 }
837- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)1059+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
838 if templ_7745c5c3_Err != nil {1060 if templ_7745c5c3_Err != nil {
839 return templ_7745c5c3_Err1061 return templ_7745c5c3_Err
840 }1062 }
841- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-ignore>")1063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" hx-ignore>")
842 if templ_7745c5c3_Err != nil {1064 if templ_7745c5c3_Err != nil {
843 return templ_7745c5c3_Err1065 return templ_7745c5c3_Err
844 }1066 }
845 if r.Note != "" {1067 if r.Note != "" {
846- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<p class=\"dim\">")1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<p class=\"dim\">")
847 if templ_7745c5c3_Err != nil {1069 if templ_7745c5c3_Err != nil {
848 return templ_7745c5c3_Err1070 return templ_7745c5c3_Err
849 }1071 }
850- var templ_7745c5c3_Var45 string1072+ var templ_7745c5c3_Var57 string
851- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)1073+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
852 if templ_7745c5c3_Err != nil {1074 if templ_7745c5c3_Err != nil {
853- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 129, Col: 26}1075+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 168, Col: 26}
854 }1076 }
855- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))1077+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
856 if templ_7745c5c3_Err != nil {1078 if templ_7745c5c3_Err != nil {
857 return templ_7745c5c3_Err1079 return templ_7745c5c3_Err
858 }1080 }
859- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</p>")1081+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</p>")
860 if templ_7745c5c3_Err != nil {1082 if templ_7745c5c3_Err != nil {
861 return templ_7745c5c3_Err1083 return templ_7745c5c3_Err
862 }1084 }
@@ -866,7 +1088,7 @@ func ReadmeView(r Readme) templ.Component {
866 return templ_7745c5c3_Err1088 return templ_7745c5c3_Err
867 }1089 }
868 }1090 }
869- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</article>")1091+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</article>")
870 if templ_7745c5c3_Err != nil {1092 if templ_7745c5c3_Err != nil {
871 return templ_7745c5c3_Err1093 return templ_7745c5c3_Err
872 }1094 }
@@ -892,133 +1114,133 @@ func CommitRows(rows []CommitRow) templ.Component {
892 }()1114 }()
893 }1115 }
894 ctx = templ.InitializeContext(ctx)1116 ctx = templ.InitializeContext(ctx)
895- templ_7745c5c3_Var46 := templ.GetChildren(ctx)1117+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
896- if templ_7745c5c3_Var46 == nil {1118+ if templ_7745c5c3_Var58 == nil {
897- templ_7745c5c3_Var46 = templ.NopComponent1119+ templ_7745c5c3_Var58 = templ.NopComponent
898 }1120 }
899 ctx = templ.ClearChildren(ctx)1121 ctx = templ.ClearChildren(ctx)
900- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")1122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
901 if templ_7745c5c3_Err != nil {1123 if templ_7745c5c3_Err != nil {
902 return templ_7745c5c3_Err1124 return templ_7745c5c3_Err
903 }1125 }
904 for _, c := range rows {1126 for _, c := range rows {
905- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<tr")1127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<tr")
906 if templ_7745c5c3_Err != nil {1128 if templ_7745c5c3_Err != nil {
907 return templ_7745c5c3_Err1129 return templ_7745c5c3_Err
908 }1130 }
909 if c.ID != "" {1131 if c.ID != "" {
910- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " id=\"")1132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " id=\"")
911 if templ_7745c5c3_Err != nil {1133 if templ_7745c5c3_Err != nil {
912 return templ_7745c5c3_Err1134 return templ_7745c5c3_Err
913 }1135 }
914- var templ_7745c5c3_Var47 string1136+ var templ_7745c5c3_Var59 string
915- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)1137+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
916 if templ_7745c5c3_Err != nil {1138 if templ_7745c5c3_Err != nil {
917- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 144, Col: 15}1139+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 183, Col: 15}
918 }1140 }
919- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)1141+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
920 if templ_7745c5c3_Err != nil {1142 if templ_7745c5c3_Err != nil {
921 return templ_7745c5c3_Err1143 return templ_7745c5c3_Err
922 }1144 }
923- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"")1145+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\"")
924 if templ_7745c5c3_Err != nil {1146 if templ_7745c5c3_Err != nil {
925 return templ_7745c5c3_Err1147 return templ_7745c5c3_Err
926 }1148 }
927 }1149 }
928 if c.Cursor {1150 if c.Cursor {
929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " data-cursor")1151+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " data-cursor")
930 if templ_7745c5c3_Err != nil {1152 if templ_7745c5c3_Err != nil {
931 return templ_7745c5c3_Err1153 return templ_7745c5c3_Err
932 }1154 }
933 }1155 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "><td class=\"c-sha mute\">")1156+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "><td class=\"c-sha mute\">")
935 if templ_7745c5c3_Err != nil {1157 if templ_7745c5c3_Err != nil {
936 return templ_7745c5c3_Err1158 return templ_7745c5c3_Err
937 }1159 }
938- var templ_7745c5c3_Var48 string1160+ var templ_7745c5c3_Var60 string
939- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)1161+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
940 if templ_7745c5c3_Err != nil {1162 if templ_7745c5c3_Err != nil {
941- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 148, Col: 37}1163+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 187, Col: 37}
942 }1164 }
943- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))1165+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
944 if templ_7745c5c3_Err != nil {1166 if templ_7745c5c3_Err != nil {
945 return templ_7745c5c3_Err1167 return templ_7745c5c3_Err
946 }1168 }
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</td><td class=\"c-subject\"><a href=\"")1169+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td><td class=\"c-subject\"><a href=\"")
948 if templ_7745c5c3_Err != nil {1170 if templ_7745c5c3_Err != nil {
949 return templ_7745c5c3_Err1171 return templ_7745c5c3_Err
950 }1172 }
951- var templ_7745c5c3_Var49 templ.SafeURL1173+ var templ_7745c5c3_Var61 templ.SafeURL
952- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))1174+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
953 if templ_7745c5c3_Err != nil {1175 if templ_7745c5c3_Err != nil {
954- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 38}1176+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 38}
955 }1177 }
956- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1178+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
957 if templ_7745c5c3_Err != nil {1179 if templ_7745c5c3_Err != nil {
958 return templ_7745c5c3_Err1180 return templ_7745c5c3_Err
959 }1181 }
960- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">")1182+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">")
961 if templ_7745c5c3_Err != nil {1183 if templ_7745c5c3_Err != nil {
962 return templ_7745c5c3_Err1184 return templ_7745c5c3_Err
963 }1185 }
964- var templ_7745c5c3_Var50 string1186+ var templ_7745c5c3_Var62 string
965- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)1187+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
966 if templ_7745c5c3_Err != nil {1188 if templ_7745c5c3_Err != nil {
967- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 150, Col: 52}1189+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 189, Col: 52}
968 }1190 }
969- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1191+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
970 if templ_7745c5c3_Err != nil {1192 if templ_7745c5c3_Err != nil {
971 return templ_7745c5c3_Err1193 return templ_7745c5c3_Err
972 }1194 }
973- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a> ")1195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</a> ")
974 if templ_7745c5c3_Err != nil {1196 if templ_7745c5c3_Err != nil {
975 return templ_7745c5c3_Err1197 return templ_7745c5c3_Err
976 }1198 }
977 if c.Merge {1199 if c.Merge {
978- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"pill\">merge</span> ")1200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<span class=\"pill\">merge</span> ")
979 if templ_7745c5c3_Err != nil {1201 if templ_7745c5c3_Err != nil {
980 return templ_7745c5c3_Err1202 return templ_7745c5c3_Err
981 }1203 }
982 }1204 }
983 for _, b := range c.Branches {1205 for _, b := range c.Branches {
984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<span class=\"pill\">")1206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<span class=\"pill\">")
985 if templ_7745c5c3_Err != nil {1207 if templ_7745c5c3_Err != nil {
986 return templ_7745c5c3_Err1208 return templ_7745c5c3_Err
987 }1209 }
988- var templ_7745c5c3_Var51 string1210+ var templ_7745c5c3_Var63 string
989- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(b)1211+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(b)
990 if templ_7745c5c3_Err != nil {1212 if templ_7745c5c3_Err != nil {
991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 155, Col: 29}1213+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 194, Col: 29}
992 }1214 }
993- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))1215+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
994 if templ_7745c5c3_Err != nil {1216 if templ_7745c5c3_Err != nil {
995 return templ_7745c5c3_Err1217 return templ_7745c5c3_Err
996 }1218 }
997- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> ")1219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span> ")
998 if templ_7745c5c3_Err != nil {1220 if templ_7745c5c3_Err != nil {
999 return templ_7745c5c3_Err1221 return templ_7745c5c3_Err
1000 }1222 }
1001 }1223 }
1002 for _, t := range c.Tags {1224 for _, t := range c.Tags {
1003- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")1225+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<span class=\"pill tag\"><span class=\"sr\">tag </span>")
1004 if templ_7745c5c3_Err != nil {1226 if templ_7745c5c3_Err != nil {
1005 return templ_7745c5c3_Err1227 return templ_7745c5c3_Err
1006 }1228 }
1007- var templ_7745c5c3_Var52 string1229+ var templ_7745c5c3_Var64 string
1008- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(t)1230+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(t)
1009 if templ_7745c5c3_Err != nil {1231 if templ_7745c5c3_Err != nil {
1010- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 158, Col: 61}1232+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 197, Col: 61}
1011 }1233 }
1012- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))1234+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1013 if templ_7745c5c3_Err != nil {1235 if templ_7745c5c3_Err != nil {
1014 return templ_7745c5c3_Err1236 return templ_7745c5c3_Err
1015 }1237 }
1016- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span>")1238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "</span>")
1017 if templ_7745c5c3_Err != nil {1239 if templ_7745c5c3_Err != nil {
1018 return templ_7745c5c3_Err1240 return templ_7745c5c3_Err
1019 }1241 }
1020 }1242 }
1021- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</td><td class=\"c-ck\">")1243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</td><td class=\"c-ck\">")
1022 if templ_7745c5c3_Err != nil {1244 if templ_7745c5c3_Err != nil {
1023 return templ_7745c5c3_Err1245 return templ_7745c5c3_Err
1024 }1246 }
@@ -1026,64 +1248,64 @@ func CommitRows(rows []CommitRow) templ.Component {
1026 if templ_7745c5c3_Err != nil {1248 if templ_7745c5c3_Err != nil {
1027 return templ_7745c5c3_Err1249 return templ_7745c5c3_Err
1028 }1250 }
1029- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</td><td class=\"c-who dim\">")1251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</td><td class=\"c-who dim\">")
1030 if templ_7745c5c3_Err != nil {1252 if templ_7745c5c3_Err != nil {
1031 return templ_7745c5c3_Err1253 return templ_7745c5c3_Err
1032 }1254 }
1033- var templ_7745c5c3_Var53 string1255+ var templ_7745c5c3_Var65 string
1034- templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)1256+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1035 if templ_7745c5c3_Err != nil {1257 if templ_7745c5c3_Err != nil {
1036- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 162, Col: 37}1258+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 201, Col: 37}
1037 }1259 }
1038- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1260+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1039 if templ_7745c5c3_Err != nil {1261 if templ_7745c5c3_Err != nil {
1040 return templ_7745c5c3_Err1262 return templ_7745c5c3_Err
1041 }1263 }
1042- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")1264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
1043 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
1044 return templ_7745c5c3_Err1266 return templ_7745c5c3_Err
1045 }1267 }
1046- var templ_7745c5c3_Var54 string1268+ var templ_7745c5c3_Var66 string
1047- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1269+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1048 if templ_7745c5c3_Err != nil {1270 if templ_7745c5c3_Err != nil {
1049- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 60}1271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 60}
1050 }1272 }
1051- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1273+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1052 if templ_7745c5c3_Err != nil {1274 if templ_7745c5c3_Err != nil {
1053 return templ_7745c5c3_Err1275 return templ_7745c5c3_Err
1054 }1276 }
1055- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" title=\"")1277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" title=\"")
1056 if templ_7745c5c3_Err != nil {1278 if templ_7745c5c3_Err != nil {
1057 return templ_7745c5c3_Err1279 return templ_7745c5c3_Err
1058 }1280 }
1059- var templ_7745c5c3_Var55 string1281+ var templ_7745c5c3_Var67 string
1060- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1282+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1061 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
1062- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 79}1284+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 79}
1063 }1285 }
1064- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)1286+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1065 if templ_7745c5c3_Err != nil {1287 if templ_7745c5c3_Err != nil {
1066 return templ_7745c5c3_Err1288 return templ_7745c5c3_Err
1067 }1289 }
1068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\">")1290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\">")
1069 if templ_7745c5c3_Err != nil {1291 if templ_7745c5c3_Err != nil {
1070 return templ_7745c5c3_Err1292 return templ_7745c5c3_Err
1071 }1293 }
1072- var templ_7745c5c3_Var56 string1294+ var templ_7745c5c3_Var68 string
1073- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)1295+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1074 if templ_7745c5c3_Err != nil {1296 if templ_7745c5c3_Err != nil {
1075- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 163, Col: 90}1297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 202, Col: 90}
1076 }1298 }
1077- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1078 if templ_7745c5c3_Err != nil {1300 if templ_7745c5c3_Err != nil {
1079 return templ_7745c5c3_Err1301 return templ_7745c5c3_Err
1080 }1302 }
1081- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</time></td></tr>")1303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</time></td></tr>")
1082 if templ_7745c5c3_Err != nil {1304 if templ_7745c5c3_Err != nil {
1083 return templ_7745c5c3_Err1305 return templ_7745c5c3_Err
1084 }1306 }
1085 }1307 }
1086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</tbody></table>")1308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</tbody></table>")
1087 if templ_7745c5c3_Err != nil {1309 if templ_7745c5c3_Err != nil {
1088 return templ_7745c5c3_Err1310 return templ_7745c5c3_Err
1089 }1311 }
A internal/adapters/web/heartbeat.go +191 -0
@@ -0,0 +1,191 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "encoding/binary"
7+ "math"
8+ "strconv"
9+ "strings"
10+ "sync"
11+)
12+
13+// Heartbeat is the activity curve of a home row, drawn on a box of
14+// heartbeatW by heartbeatH that the page scales to its width.
15+type Heartbeat struct {
16+ // Line is the curve, Area the same closed down to the bottom edge.
17+ Line, Area string
18+ // X and Y are where the curve ends: the pulse of today.
19+ X, Y string
20+ // GradID names the fill gradient of this row (ids are page-wide).
21+ GradID string
22+ // Label tells the curve in words.
23+ Label string
24+ // Live is set when the last week had commits: the pulse beats.
25+ Live bool
26+}
27+
28+const (
29+ heartbeatW = 600.0
30+ heartbeatH = 64.0
31+ // heartbeatPad keeps the stroke and the dot inside the box; the right
32+ // end stops before the edge so that the beating ring fits.
33+ heartbeatPad = 6.0
34+ heartbeatEndX = heartbeatW - 10
35+)
36+
37+// heartbeatOf draws the commits per day, oldest first. The days are smoothed
38+// over about a week so that the curve breathes instead of spiking, then
39+// scaled by the square root so that one big day does not flatten the rest.
40+// nil for no data.
41+// partial says the days count only some of the commits (heartbeatLabel).
42+func heartbeatOf(id string, days []int, partial bool) *Heartbeat {
43+ n := len(days)
44+ if n < 2 {
45+ return nil
46+ }
47+ total, lastWeek := 0, 0
48+ for i, d := range days {
49+ total += d
50+ if i >= n-7 {
51+ lastWeek += d
52+ }
53+ }
54+ top, bottom := heartbeatPad, heartbeatH-heartbeatPad
55+ var pts [][2]float64
56+ if total == 0 {
57+ // Asleep: a straight line on the base, two points.
58+ pts = [][2]float64{{0, bottom}, {heartbeatEndX, bottom}}
59+ } else {
60+ smooth := gaussian(days, 2.2)
61+ peak := 0.0
62+ for _, v := range smooth {
63+ peak = max(peak, math.Sqrt(v))
64+ }
65+ // One point every heartbeatStep days, and today: the smoothing
66+ // leaves nothing finer to draw, and the row goes out on every live
67+ // update.
68+ for i := 0; i < n; i += heartbeatStep {
69+ pts = append(pts, heartbeatPoint(i, n, smooth[i], peak, top, bottom))
70+ }
71+ if (n-1)%heartbeatStep != 0 {
72+ pts = append(pts, heartbeatPoint(n-1, n, smooth[n-1], peak, top, bottom))
73+ }
74+ }
75+ line := curve(pts)
76+ end := pts[len(pts)-1]
77+ return &Heartbeat{
78+ Line: line,
79+ Area: line + " L" + num(heartbeatEndX) + " " + num(heartbeatH) + " L0 " + num(heartbeatH) + " Z",
80+ X: num(end[0]),
81+ Y: num(end[1]),
82+ GradID: "hb-" + id,
83+ Label: heartbeatLabel(total, n, partial),
84+ Live: lastWeek > 0,
85+ }
86+}
87+
88+// heartbeatStep is the number of days between two points of the curve.
89+const heartbeatStep = 2
90+
91+func heartbeatPoint(i, n int, v, peak, top, bottom float64) [2]float64 {
92+ return [2]float64{float64(i) / float64(n-1) * heartbeatEndX, bottom - math.Sqrt(v)/peak*(bottom-top)}
93+}
94+
95+func heartbeatLabel(total, days int, partial bool) string {
96+ count := plural(total, "commit", "commits")
97+ if partial {
98+ count = "at least " + count
99+ }
100+ return "Activity: " + count + " in the last " + itoa(days) + " days"
101+}
102+
103+// pulseCache keeps the curves already drawn: the figures of a row stay the
104+// same for minutes (the stats cache of Browse) while the home page and its
105+// live updates render the row on every request. Keyed by the repository and
106+// its days, so a new count draws a new curve; bounded, cleared when full.
107+type pulseCache struct {
108+ mu sync.Mutex
109+ m map[pulseKey]*Heartbeat
110+}
111+
112+type pulseKey struct {
113+ id string
114+ days string // the counts, packed
115+ partial bool
116+}
117+
118+// maxPulses bounds pulseCache; a few per repository at most are live.
119+const maxPulses = 4096
120+
121+// get is heartbeatOf, from the cache when it was drawn already. The
122+// Heartbeat returned is shared: callers must not change it.
123+func (c *pulseCache) get(id string, days []int, partial bool) *Heartbeat {
124+ if len(days) < 2 {
125+ return nil
126+ }
127+ packed := make([]byte, 0, len(days)*2)
128+ for _, d := range days {
129+ packed = binary.AppendUvarint(packed, uint64(max(d, 0)))
130+ }
131+ key := pulseKey{id: id, days: string(packed), partial: partial}
132+ c.mu.Lock()
133+ h, ok := c.m[key]
134+ c.mu.Unlock()
135+ if ok {
136+ return h
137+ }
138+ h = heartbeatOf(id, days, partial)
139+ c.mu.Lock()
140+ if c.m == nil || len(c.m) >= maxPulses {
141+ c.m = make(map[pulseKey]*Heartbeat)
142+ }
143+ c.m[key] = h
144+ c.mu.Unlock()
145+ return h
146+}
147+
148+// gaussian blurs the values with a gaussian of the given width, the edges
149+// clamped: a quiet start or end does not drag the curve to zero.
150+func gaussian(days []int, sigma float64) []float64 {
151+ r := int(math.Ceil(3 * sigma))
152+ k := make([]float64, 2*r+1)
153+ for i := range k {
154+ d := float64(i - r)
155+ k[i] = math.Exp(-d * d / (2 * sigma * sigma))
156+ }
157+ out := make([]float64, len(days))
158+ for i := range days {
159+ sum, w := 0.0, 0.0
160+ for j, kj := range k {
161+ at := min(max(i+j-r, 0), len(days)-1)
162+ sum += kj * float64(days[at])
163+ w += kj
164+ }
165+ out[i] = sum / w
166+ }
167+ return out
168+}
169+
170+// curve is a smooth path through the points: a Catmull-Rom spline as cubic
171+// Béziers, its handles clamped to the box so that it never overshoots below
172+// the base line.
173+func curve(p [][2]float64) string {
174+ var b strings.Builder
175+ b.WriteString("M" + num(p[0][0]) + " " + num(p[0][1]))
176+ clampY := func(y float64) float64 { return min(max(y, heartbeatPad/2), heartbeatH-heartbeatPad) }
177+ for i := 0; i < len(p)-1; i++ {
178+ p0 := p[max(i-1, 0)]
179+ p1, p2 := p[i], p[i+1]
180+ p3 := p[min(i+2, len(p)-1)]
181+ c1x, c1y := p1[0]+(p2[0]-p0[0])/6, clampY(p1[1]+(p2[1]-p0[1])/6)
182+ c2x, c2y := p2[0]-(p3[0]-p1[0])/6, clampY(p2[1]-(p3[1]-p1[1])/6)
183+ b.WriteString(" C" + num(c1x) + " " + num(c1y) + " " + num(c2x) + " " + num(c2y) + " " + num(p2[0]) + " " + num(p2[1]))
184+ }
185+ return b.String()
186+}
187+
188+// num writes a coordinate with one decimal at most.
189+func num(f float64) string {
190+ return strconv.FormatFloat(math.Round(f*10)/10, 'f', -1, 64)
191+}
@@ -0,0 +1,191 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "encoding/binary"
7+ "math"
8+ "strconv"
9+ "strings"
10+ "sync"
11+)
12+
13+// Heartbeat is the activity curve of a home row, drawn on a box of
14+// heartbeatW by heartbeatH that the page scales to its width.
15+type Heartbeat struct {
16+ // Line is the curve, Area the same closed down to the bottom edge.
17+ Line, Area string
18+ // X and Y are where the curve ends: the pulse of today.
19+ X, Y string
20+ // GradID names the fill gradient of this row (ids are page-wide).
21+ GradID string
22+ // Label tells the curve in words.
23+ Label string
24+ // Live is set when the last week had commits: the pulse beats.
25+ Live bool
26+}
27+
28+const (
29+ heartbeatW = 600.0
30+ heartbeatH = 64.0
31+ // heartbeatPad keeps the stroke and the dot inside the box; the right
32+ // end stops before the edge so that the beating ring fits.
33+ heartbeatPad = 6.0
34+ heartbeatEndX = heartbeatW - 10
35+)
36+
37+// heartbeatOf draws the commits per day, oldest first. The days are smoothed
38+// over about a week so that the curve breathes instead of spiking, then
39+// scaled by the square root so that one big day does not flatten the rest.
40+// nil for no data.
41+// partial says the days count only some of the commits (heartbeatLabel).
42+func heartbeatOf(id string, days []int, partial bool) *Heartbeat {
43+ n := len(days)
44+ if n < 2 {
45+ return nil
46+ }
47+ total, lastWeek := 0, 0
48+ for i, d := range days {
49+ total += d
50+ if i >= n-7 {
51+ lastWeek += d
52+ }
53+ }
54+ top, bottom := heartbeatPad, heartbeatH-heartbeatPad
55+ var pts [][2]float64
56+ if total == 0 {
57+ // Asleep: a straight line on the base, two points.
58+ pts = [][2]float64{{0, bottom}, {heartbeatEndX, bottom}}
59+ } else {
60+ smooth := gaussian(days, 2.2)
61+ peak := 0.0
62+ for _, v := range smooth {
63+ peak = max(peak, math.Sqrt(v))
64+ }
65+ // One point every heartbeatStep days, and today: the smoothing
66+ // leaves nothing finer to draw, and the row goes out on every live
67+ // update.
68+ for i := 0; i < n; i += heartbeatStep {
69+ pts = append(pts, heartbeatPoint(i, n, smooth[i], peak, top, bottom))
70+ }
71+ if (n-1)%heartbeatStep != 0 {
72+ pts = append(pts, heartbeatPoint(n-1, n, smooth[n-1], peak, top, bottom))
73+ }
74+ }
75+ line := curve(pts)
76+ end := pts[len(pts)-1]
77+ return &Heartbeat{
78+ Line: line,
79+ Area: line + " L" + num(heartbeatEndX) + " " + num(heartbeatH) + " L0 " + num(heartbeatH) + " Z",
80+ X: num(end[0]),
81+ Y: num(end[1]),
82+ GradID: "hb-" + id,
83+ Label: heartbeatLabel(total, n, partial),
84+ Live: lastWeek > 0,
85+ }
86+}
87+
88+// heartbeatStep is the number of days between two points of the curve.
89+const heartbeatStep = 2
90+
91+func heartbeatPoint(i, n int, v, peak, top, bottom float64) [2]float64 {
92+ return [2]float64{float64(i) / float64(n-1) * heartbeatEndX, bottom - math.Sqrt(v)/peak*(bottom-top)}
93+}
94+
95+func heartbeatLabel(total, days int, partial bool) string {
96+ count := plural(total, "commit", "commits")
97+ if partial {
98+ count = "at least " + count
99+ }
100+ return "Activity: " + count + " in the last " + itoa(days) + " days"
101+}
102+
103+// pulseCache keeps the curves already drawn: the figures of a row stay the
104+// same for minutes (the stats cache of Browse) while the home page and its
105+// live updates render the row on every request. Keyed by the repository and
106+// its days, so a new count draws a new curve; bounded, cleared when full.
107+type pulseCache struct {
108+ mu sync.Mutex
109+ m map[pulseKey]*Heartbeat
110+}
111+
112+type pulseKey struct {
113+ id string
114+ days string // the counts, packed
115+ partial bool
116+}
117+
118+// maxPulses bounds pulseCache; a few per repository at most are live.
119+const maxPulses = 4096
120+
121+// get is heartbeatOf, from the cache when it was drawn already. The
122+// Heartbeat returned is shared: callers must not change it.
123+func (c *pulseCache) get(id string, days []int, partial bool) *Heartbeat {
124+ if len(days) < 2 {
125+ return nil
126+ }
127+ packed := make([]byte, 0, len(days)*2)
128+ for _, d := range days {
129+ packed = binary.AppendUvarint(packed, uint64(max(d, 0)))
130+ }
131+ key := pulseKey{id: id, days: string(packed), partial: partial}
132+ c.mu.Lock()
133+ h, ok := c.m[key]
134+ c.mu.Unlock()
135+ if ok {
136+ return h
137+ }
138+ h = heartbeatOf(id, days, partial)
139+ c.mu.Lock()
140+ if c.m == nil || len(c.m) >= maxPulses {
141+ c.m = make(map[pulseKey]*Heartbeat)
142+ }
143+ c.m[key] = h
144+ c.mu.Unlock()
145+ return h
146+}
147+
148+// gaussian blurs the values with a gaussian of the given width, the edges
149+// clamped: a quiet start or end does not drag the curve to zero.
150+func gaussian(days []int, sigma float64) []float64 {
151+ r := int(math.Ceil(3 * sigma))
152+ k := make([]float64, 2*r+1)
153+ for i := range k {
154+ d := float64(i - r)
155+ k[i] = math.Exp(-d * d / (2 * sigma * sigma))
156+ }
157+ out := make([]float64, len(days))
158+ for i := range days {
159+ sum, w := 0.0, 0.0
160+ for j, kj := range k {
161+ at := min(max(i+j-r, 0), len(days)-1)
162+ sum += kj * float64(days[at])
163+ w += kj
164+ }
165+ out[i] = sum / w
166+ }
167+ return out
168+}
169+
170+// curve is a smooth path through the points: a Catmull-Rom spline as cubic
171+// Béziers, its handles clamped to the box so that it never overshoots below
172+// the base line.
173+func curve(p [][2]float64) string {
174+ var b strings.Builder
175+ b.WriteString("M" + num(p[0][0]) + " " + num(p[0][1]))
176+ clampY := func(y float64) float64 { return min(max(y, heartbeatPad/2), heartbeatH-heartbeatPad) }
177+ for i := 0; i < len(p)-1; i++ {
178+ p0 := p[max(i-1, 0)]
179+ p1, p2 := p[i], p[i+1]
180+ p3 := p[min(i+2, len(p)-1)]
181+ c1x, c1y := p1[0]+(p2[0]-p0[0])/6, clampY(p1[1]+(p2[1]-p0[1])/6)
182+ c2x, c2y := p2[0]-(p3[0]-p1[0])/6, clampY(p2[1]-(p3[1]-p1[1])/6)
183+ b.WriteString(" C" + num(c1x) + " " + num(c1y) + " " + num(c2x) + " " + num(c2y) + " " + num(p2[0]) + " " + num(p2[1]))
184+ }
185+ return b.String()
186+}
187+
188+// num writes a coordinate with one decimal at most.
189+func num(f float64) string {
190+ return strconv.FormatFloat(math.Round(f*10)/10, 'f', -1, 64)
191+}
A internal/adapters/web/heartbeat_test.go +79 -0
@@ -0,0 +1,79 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strconv"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestHeartbeat(t *testing.T) {
15+ t.Parallel()
16+ assert.Nil(t, heartbeatOf("x", nil, false), "no data, no curve")
17+
18+ days := make([]int, 90)
19+ days[10], days[60], days[88] = 30, 4, 2
20+ h := heartbeatOf("proj", days, false)
21+ require.NotNil(t, h)
22+ assert.True(t, strings.HasPrefix(h.Line, "M0 "), h.Line)
23+ assert.Equal(t, 45, strings.Count(h.Line, " C"), "a point every two days, and today")
24+ assert.Less(t, len(h.Line)+len(h.Area), 4000, "light enough for every live update")
25+ assert.True(t, strings.HasPrefix(h.Area, h.Line) && strings.HasSuffix(h.Area, " Z"))
26+ assert.Equal(t, "hb-proj", h.GradID)
27+ assert.Equal(t, "Activity: 36 commits in the last 90 days", h.Label)
28+ assert.True(t, h.Live, "commits in the last week")
29+ assert.Equal(t, num(heartbeatEndX), h.X)
30+
31+ // Every point stays inside the box: no overshoot below the base line or
32+ // above the top.
33+ for _, f := range strings.FieldsFunc(h.Line, func(r rune) bool { return r == 'M' || r == 'C' || r == ' ' }) {
34+ v, err := strconv.ParseFloat(f, 64)
35+ require.NoError(t, err, f)
36+ assert.GreaterOrEqual(t, v, 0.0)
37+ assert.LessOrEqual(t, v, heartbeatW)
38+ }
39+
40+ quiet := heartbeatOf("q", make([]int, 90), false)
41+ require.NotNil(t, quiet)
42+ assert.False(t, quiet.Live)
43+ assert.Equal(t, num(heartbeatH-heartbeatPad), quiet.Y, "a flat line on the base")
44+ assert.Equal(t, 1, strings.Count(quiet.Line, " C"), "two points")
45+ assert.Equal(t, "Activity: 0 commits in the last 90 days", quiet.Label)
46+
47+ assert.Equal(t, "Activity: at least 36 commits in the last 90 days", heartbeatOf("proj", days, true).Label)
48+}
49+
50+func TestPulseCache(t *testing.T) {
51+ t.Parallel()
52+ var c pulseCache
53+ days := make([]int, 90)
54+ days[80] = 3
55+ a := c.get("proj", days, false)
56+ require.NotNil(t, a)
57+ assert.Same(t, a, c.get("proj", days, false), "same figures, same curve")
58+ assert.Equal(t, heartbeatOf("proj", days, false), a)
59+
60+ assert.NotSame(t, a, c.get("other", days, false), "per repository: the gradient id differs")
61+ assert.NotSame(t, a, c.get("proj", days, true), "partial changes the label")
62+ days[89] = 1
63+ b := c.get("proj", days, false)
64+ assert.NotSame(t, a, b, "new figures, new curve")
65+ assert.True(t, b.Live)
66+ assert.Nil(t, c.get("proj", nil, false))
67+}
68+
69+func BenchmarkPulseCache(b *testing.B) {
70+ var c pulseCache
71+ days := make([]int, 90)
72+ for i := range days {
73+ days[i] = i * 7 % 11
74+ }
75+ b.ReportAllocs()
76+ for b.Loop() {
77+ c.get("neferwl", days, false)
78+ }
79+}
@@ -0,0 +1,79 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "strconv"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestHeartbeat(t *testing.T) {
15+ t.Parallel()
16+ assert.Nil(t, heartbeatOf("x", nil, false), "no data, no curve")
17+
18+ days := make([]int, 90)
19+ days[10], days[60], days[88] = 30, 4, 2
20+ h := heartbeatOf("proj", days, false)
21+ require.NotNil(t, h)
22+ assert.True(t, strings.HasPrefix(h.Line, "M0 "), h.Line)
23+ assert.Equal(t, 45, strings.Count(h.Line, " C"), "a point every two days, and today")
24+ assert.Less(t, len(h.Line)+len(h.Area), 4000, "light enough for every live update")
25+ assert.True(t, strings.HasPrefix(h.Area, h.Line) && strings.HasSuffix(h.Area, " Z"))
26+ assert.Equal(t, "hb-proj", h.GradID)
27+ assert.Equal(t, "Activity: 36 commits in the last 90 days", h.Label)
28+ assert.True(t, h.Live, "commits in the last week")
29+ assert.Equal(t, num(heartbeatEndX), h.X)
30+
31+ // Every point stays inside the box: no overshoot below the base line or
32+ // above the top.
33+ for _, f := range strings.FieldsFunc(h.Line, func(r rune) bool { return r == 'M' || r == 'C' || r == ' ' }) {
34+ v, err := strconv.ParseFloat(f, 64)
35+ require.NoError(t, err, f)
36+ assert.GreaterOrEqual(t, v, 0.0)
37+ assert.LessOrEqual(t, v, heartbeatW)
38+ }
39+
40+ quiet := heartbeatOf("q", make([]int, 90), false)
41+ require.NotNil(t, quiet)
42+ assert.False(t, quiet.Live)
43+ assert.Equal(t, num(heartbeatH-heartbeatPad), quiet.Y, "a flat line on the base")
44+ assert.Equal(t, 1, strings.Count(quiet.Line, " C"), "two points")
45+ assert.Equal(t, "Activity: 0 commits in the last 90 days", quiet.Label)
46+
47+ assert.Equal(t, "Activity: at least 36 commits in the last 90 days", heartbeatOf("proj", days, true).Label)
48+}
49+
50+func TestPulseCache(t *testing.T) {
51+ t.Parallel()
52+ var c pulseCache
53+ days := make([]int, 90)
54+ days[80] = 3
55+ a := c.get("proj", days, false)
56+ require.NotNil(t, a)
57+ assert.Same(t, a, c.get("proj", days, false), "same figures, same curve")
58+ assert.Equal(t, heartbeatOf("proj", days, false), a)
59+
60+ assert.NotSame(t, a, c.get("other", days, false), "per repository: the gradient id differs")
61+ assert.NotSame(t, a, c.get("proj", days, true), "partial changes the label")
62+ days[89] = 1
63+ b := c.get("proj", days, false)
64+ assert.NotSame(t, a, b, "new figures, new curve")
65+ assert.True(t, b.Live)
66+ assert.Nil(t, c.get("proj", nil, false))
67+}
68+
69+func BenchmarkPulseCache(b *testing.B) {
70+ var c pulseCache
71+ days := make([]int, 90)
72+ for i := range days {
73+ days[i] = i * 7 % 11
74+ }
75+ b.ReportAllocs()
76+ for b.Loop() {
77+ c.get("neferwl", days, false)
78+ }
79+}
M internal/adapters/web/home.go +5 -2
@@ -211,7 +211,10 @@ func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, n
211211 }
212212 return nil
213213 }
214- hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}
214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
215+ if st.Partial {
216+ hs.Recent += "+" // a lower bound: the walk stopped at its cap
217+ }
215218 if t := st.LatestTag; t != nil {
216219 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217220 if !t.CommitID.IsZero() {
@@ -259,7 +262,7 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
259262 name := r.Name.String()
260263 row := HomeRow{
261264 ID: "repo-" + name, Name: name, Href: repoHref(name),
262- Desc: r.Description, Cursor: cursor,
265+ Desc: r.Description, Topics: r.Topics, Cursor: cursor,
263266 Private: signed && r.Visibility == domain.VisibilityPrivate,
264267 }
265268 if !r.PushedAt.IsZero() {
@@ -211,7 +211,10 @@ func (s *Server) homeStats(ctx context.Context, r ports.RepoView, head string, n
211 }211 }
212 return nil212 return nil
213 }213 }
214- hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent)}214+ hs := &HomeStats{Commits: grouped(st.Commits), Recent: grouped(st.Recent), Pulse: s.pulses.get(r.Name.String(), st.Activity, st.Partial)}
215+ if st.Partial {
216+ hs.Recent += "+" // a lower bound: the walk stopped at its cap
217+ }
215 if t := st.LatestTag; t != nil {218 if t := st.LatestTag; t != nil {
216 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)219 hs.Tag, hs.TagWhen = t.Name, ago(now, t.At)
217 if !t.CommitID.IsZero() {220 if !t.CommitID.IsZero() {
@@ -259,7 +262,7 @@ func homeRow(r ports.RepoView, now time.Time, cursor, signed bool) HomeRow {
259 name := r.Name.String()262 name := r.Name.String()
260 row := HomeRow{263 row := HomeRow{
261 ID: "repo-" + name, Name: name, Href: repoHref(name),264 ID: "repo-" + name, Name: name, Href: repoHref(name),
262- Desc: r.Description, Cursor: cursor,265+ Desc: r.Description, Topics: r.Topics, Cursor: cursor,
263 Private: signed && r.Visibility == domain.VisibilityPrivate,266 Private: signed && r.Visibility == domain.VisibilityPrivate,
264 }267 }
265 if !r.PushedAt.IsZero() {268 if !r.PushedAt.IsZero() {
M internal/adapters/web/home_test.go +17 -2
@@ -66,19 +66,33 @@ func TestHomeRowFigures(t *testing.T) {
6666 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
6767 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
6868 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ Activity: make([]int, ports.ActivityDays),
6970 }, nil).Once()
7071 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
7172
72- row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
73+ view := pushedRepo(t, "proj")
74+ view.Topics = []string{"go", "git"}
75+ row := srv.homeRowOf(context.Background(), view, now, false)
7376 require.NotNil(t, row.Stats)
7477 out := renderRow(t, row)
7578 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
7679 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")
80+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
7881 assert.Contains(t, out, "<dt>last activity</dt>")
7982 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)
84+ assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85+ assert.Contains(t, out, `<linearGradient id="hb-proj"`)
8086 assert.NotContains(t, out, "pushed")
8187
88+ // Past the cap of the walk, the figures are lower bounds.
89+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
90+ Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
91+ }, nil).Once()
92+ out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
93+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
94+ assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
95+
8296 // A failed read leaves the figures out, not the row.
8397 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
8498 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
@@ -126,6 +140,7 @@ func TestHomeRowShowsFiveCommits(t *testing.T) {
126140
127141 out := renderRow(t, row)
128142 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
143+ assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
129144 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
130145 assert.Contains(t, out, `aria-label="Recent commits of proj"`)
131146 assert.NotContains(t, out, `class="repo-ci"`)
@@ -66,19 +66,33 @@ func TestHomeRowFigures(t *testing.T) {
66 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)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{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},68 Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69+ Activity: make([]int, ports.ActivityDays),
69 }, nil).Once()70 }, nil).Once()
70 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})71 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71 72
72- row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)73+ view := pushedRepo(t, "proj")
74+ view.Topics = []string{"go", "git"}
75+ row := srv.homeRowOf(context.Background(), view, now, false)
73 require.NotNil(t, row.Stats)76 require.NotNil(t, row.Stats)
74 out := renderRow(t, row)77 out := renderRow(t, row)
75 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")78 assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
76 assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")79 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")80+ assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · 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>")81 assert.Contains(t, out, "<dt>last activity</dt>")
79 assert.NotContains(t, out, "i-branch", "the default branch is not shown")82 assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83+ assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj"><li>go</li><li>git</li></ul>`)
84+ assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85+ assert.Contains(t, out, `<linearGradient id="hb-proj"`)
80 assert.NotContains(t, out, "pushed")86 assert.NotContains(t, out, "pushed")
81 87
88+ // Past the cap of the walk, the figures are lower bounds.
89+ browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
90+ Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
91+ }, nil).Once()
92+ out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
93+ assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
94+ assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
95+
82 // A failed read leaves the figures out, not the row.96 // 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()97 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)98 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
@@ -126,6 +140,7 @@ func TestHomeRowShowsFiveCommits(t *testing.T) {
126 140
127 out := renderRow(t, row)141 out := renderRow(t, row)
128 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))142 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
143+ assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
129 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)144 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
130 assert.Contains(t, out, `aria-label="Recent commits of proj"`)145 assert.Contains(t, out, `aria-label="Recent commits of proj"`)
131 assert.NotContains(t, out, `class="repo-ci"`)146 assert.NotContains(t, out, `class="repo-ci"`)
M internal/adapters/web/static/css/30-list.css +40 -8
@@ -48,8 +48,8 @@
4848 display: flex; flex-direction: column; gap: var(--s3);
4949 margin: 0; padding: 0; list-style: none;
5050 & .repo {
51- display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";
52- grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;
51+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main head" "main recent";
52+ grid-template-rows: auto 1fr; column-gap: var(--s6); row-gap: var(--s3); min-width: 0;
5353 padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
5454 }
5555 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
@@ -65,17 +65,43 @@
6565 display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
6666 }
6767 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68+ /* topics: small quiet chips, as on GitHub */
69+ & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
70+ & .repo-topics li {
71+ padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
72+ font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
73+ }
74+ /* the pulse: the commits per day over 90 days, smoothed, under the text */
75+ & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }
76+ & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
77+ & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
78+ & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
79+ & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
80+ & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
81+ & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82+ & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
83+ & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84+ & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85+ & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86+ & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87+ & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88+ @media (prefers-reduced-motion: no-preference) {
89+ & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90+ }
91+ /* the right column: figures, then the CI state, over the commits */
92+ & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
6893 /* 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; }
94+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: 0; min-width: 0; }
7095 & .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); }
96+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); white-space: nowrap; }
7297 & .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; }
98+ & .repo-stats .rs-tag dd { max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
7399 & .repo-stats dd.mute { color: var(--ink-mute); }
74100 & .repo-stats dd a { color: inherit; text-decoration: none; }
75101 & .repo-stats dd a:hover { text-decoration: underline; }
76102 & .repo-ci {
77- grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);
78- font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;
103+ flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
104+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
79105 }
80106 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
81107 & .repo-ci .ck { margin: 0; }
@@ -86,11 +112,17 @@
86112 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
87113 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
88114 & .repo-commits time { flex: none; white-space: nowrap; }
115+ /* a full list goes on: its last commit fades */
116+ & .repo-commits[data-full] li:last-child { opacity: .65; }
89117 @media (max-width: 720px) {
90- & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }
91- & .repo-ci { justify-self: start; margin-bottom: 0; }
118+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
119+ & .repo-head { flex-wrap: wrap; }
92120 }
93121 }
122+ @keyframes pulse-beat {
123+ 0% { opacity: .9; transform: scale(1); }
124+ 70%, 100% { opacity: 0; transform: scale(3.2); }
125+ }
94126 .list.passkeys {
95127 & .c-name { width: auto; font-weight: 600; }
96128 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -48,8 +48,8 @@
48 display: flex; flex-direction: column; gap: var(--s3);48 display: flex; flex-direction: column; gap: var(--s3);
49 margin: 0; padding: 0; list-style: none;49 margin: 0; padding: 0; list-style: none;
50 & .repo {50 & .repo {
51- display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main ci" "main recent";51+ display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-areas: "main head" "main recent";
52- grid-template-rows: auto 1fr; column-gap: var(--s6); min-width: 0;52+ grid-template-rows: auto 1fr; column-gap: var(--s6); row-gap: var(--s3); min-width: 0;
53 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);
54 }54 }
55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }55 & .repo:hover { background: var(--bg-raised); border-color: var(--border-strong); }
@@ -65,17 +65,43 @@
65 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;
66 }66 }
67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }67 & .repo-desc.mute { color: var(--ink-mute); font-style: italic; }
68+ /* topics: small quiet chips, as on GitHub */
69+ & .repo-topics { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
70+ & .repo-topics li {
71+ padding: 1px 9px; border: 1px solid var(--border-strong); border-radius: 999px;
72+ font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--teal); background: var(--bg-raised);
73+ }
74+ /* the pulse: the commits per day over 90 days, smoothed, under the text */
75+ & .repo-pulse { position: relative; margin: auto 0 0; padding-top: var(--s2); }
76+ & .repo-pulse svg { display: block; width: 100%; height: auto; overflow: visible; }
77+ & .pulse-grid { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 4; }
78+ & .pulse-stop-top { stop-color: var(--teal); stop-opacity: .32; }
79+ & .pulse-stop-bottom { stop-color: var(--teal); stop-opacity: 0; }
80+ & .pulse-line { fill: none; stroke: var(--teal); stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
81+ & .pulse-dot { fill: var(--teal); stroke: var(--bg-surface); stroke-width: 1.5; }
82+ & .pulse-ring { fill: none; stroke: var(--teal); stroke-width: 1.5; opacity: 0; transform-box: fill-box; transform-origin: center; }
83+ & .repo:hover .pulse-dot, & .repo[data-cursor] .pulse-dot { stroke: var(--bg-raised); }
84+ & .pulse-cap { display: flex; justify-content: space-between; margin-top: 2px; font-size: 10px; line-height: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); }
85+ & .repo-pulse:not([data-live]) .pulse-line { stroke: var(--ink-mute); }
86+ & .repo-pulse:not([data-live]) .pulse-dot { fill: var(--ink-mute); }
87+ & .repo-pulse:not([data-live]) .pulse-stop-top { stop-color: var(--ink-mute); stop-opacity: .18; }
88+ @media (prefers-reduced-motion: no-preference) {
89+ & .repo-pulse[data-live] .pulse-ring { animation: pulse-beat 2.4s cubic-bezier(.2, .6, .3, 1) infinite; }
90+ }
91+ /* the right column: figures, then the CI state, over the commits */
92+ & .repo-head { grid-area: head; display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3) var(--s5); min-width: 0; }
68 /* the figures: a small label over a mono value, side by side */93 /* 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; }94+ & .repo-stats { display: flex; flex-wrap: wrap; gap: var(--s2) var(--s5); margin: 0; min-width: 0; }
70 & .repo-stats div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }95 & .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); }96+ & .repo-stats dt { font-size: 11px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-mute); white-space: nowrap; }
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; }97 & .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; }
98+ & .repo-stats .rs-tag dd { max-width: 18ch; overflow: hidden; text-overflow: ellipsis; }
73 & .repo-stats dd.mute { color: var(--ink-mute); }99 & .repo-stats dd.mute { color: var(--ink-mute); }
74 & .repo-stats dd a { color: inherit; text-decoration: none; }100 & .repo-stats dd a { color: inherit; text-decoration: none; }
75 & .repo-stats dd a:hover { text-decoration: underline; }101 & .repo-stats dd a:hover { text-decoration: underline; }
76 & .repo-ci {102 & .repo-ci {
77- grid-area: ci; justify-self: end; display: inline-flex; align-items: center; gap: var(--s1); margin-bottom: var(--s2);103+ flex: none; display: inline-flex; align-items: center; gap: var(--s1); padding-top: 2px;
78- font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none;104+ font-size: var(--fs-s); color: var(--ink-dim); text-decoration: none; white-space: nowrap;
79 }105 }
80 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }106 & .repo-ci:hover { color: var(--ink-hi); text-decoration: underline; }
81 & .repo-ci .ck { margin: 0; }107 & .repo-ci .ck { margin: 0; }
@@ -86,11 +112,17 @@
86 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }112 & .rc-sha:hover { color: var(--ink-hi); text-decoration: underline; }
87 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }113 & .rc-subject { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-hi); }
88 & .repo-commits time { flex: none; white-space: nowrap; }114 & .repo-commits time { flex: none; white-space: nowrap; }
115+ /* a full list goes on: its last commit fades */
116+ & .repo-commits[data-full] li:last-child { opacity: .65; }
89 @media (max-width: 720px) {117 @media (max-width: 720px) {
90- & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "ci" "recent"; grid-template-rows: none; row-gap: var(--s3); }118+ & .repo { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main" "head" "recent"; grid-template-rows: none; }
91- & .repo-ci { justify-self: start; margin-bottom: 0; }119+ & .repo-head { flex-wrap: wrap; }
92 }120 }
93 }121 }
122+ @keyframes pulse-beat {
123+ 0% { opacity: .9; transform: scale(1); }
124+ 70%, 100% { opacity: 0; transform: scale(3.2); }
125+ }
94 .list.passkeys {126 .list.passkeys {
95 & .c-name { width: auto; font-weight: 600; }127 & .c-name { width: auto; font-weight: 600; }
96 & .c-when { width: 120px; font-size: var(--fs-s); }128 & .c-when { width: 120px; font-size: var(--fs-s); }
M internal/adapters/web/view.go +4 -0
@@ -80,6 +80,8 @@ type HomeRow struct {
8080 Name string
8181 Href string
8282 Desc string
83+ // Topics classify the repository, as on GitHub.
84+ Topics []string
8385 // Active is when the repository last changed (its last push or merge),
8486 // ActiveAt its RFC 3339 form, "" when it never got a commit.
8587 Active string
@@ -107,6 +109,8 @@ type HomeStats struct {
107109 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108110 // tells when it was made.
109111 Tag, TagHref, TagWhen string
112+ // Pulse is the activity curve; nil when it could not be drawn.
113+ Pulse *Heartbeat
110114 }
111115
112116 // HomeCI is the state of the latest run of the default branch of a repository.
@@ -80,6 +80,8 @@ type HomeRow struct {
80 Name string80 Name string
81 Href string81 Href string
82 Desc string82 Desc string
83+ // Topics classify the repository, as on GitHub.
84+ Topics []string
83 // Active is when the repository last changed (its last push or merge),85 // Active is when the repository last changed (its last push or merge),
84 // ActiveAt its RFC 3339 form, "" when it never got a commit.86 // ActiveAt its RFC 3339 form, "" when it never got a commit.
85 Active string87 Active string
@@ -107,6 +109,8 @@ type HomeStats struct {
107 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen109 // Tag is the latest tag, "" without tags; TagHref links its tree, TagWhen
108 // tells when it was made.110 // tells when it was made.
109 Tag, TagHref, TagWhen string111 Tag, TagHref, TagWhen string
112+ // Pulse is the activity curve; nil when it could not be drawn.
113+ Pulse *Heartbeat
110 }114 }
111 115
112 // HomeCI is the state of the latest run of the default branch of a repository.116 // HomeCI is the state of the latest run of the default branch of a repository.
M internal/adapters/web/web.go +2 -0
@@ -115,6 +115,8 @@ type Server struct {
115115 // refsFan shares the fragments of the refs page between the watchers of
116116 // a push; entries are few and short-lived.
117117 refsFan fanout
118+ // pulses holds the activity curves of the home rows.
119+ pulses pulseCache
118120 }
119121
120122 // New builds the web handler.
@@ -115,6 +115,8 @@ type Server struct {
115 // refsFan shares the fragments of the refs page between the watchers of115 // refsFan shares the fragments of the refs page between the watchers of
116 // a push; entries are few and short-lived.116 // a push; entries are few and short-lived.
117 refsFan fanout117 refsFan fanout
118+ // pulses holds the activity curves of the home rows.
119+ pulses pulseCache
118 }120 }
119 121
120 // New builds the web handler.122 // New builds the web handler.
M internal/app/cli_e2e_test.go +20 -8
@@ -168,12 +168,13 @@ type tokenOut struct {
168168 }
169169
170170 type repoOut struct {
171- Name string `json:"name"`
172- Description string `json:"description"`
173- Visibility string `json:"visibility"`
174- DefaultBranch string `json:"default_branch"`
175- CreatedAt *string `json:"created_at"`
176- PushedAt *string `json:"pushed_at"`
171+ Name string `json:"name"`
172+ Description string `json:"description"`
173+ Topics []string `json:"topics"`
174+ Visibility string `json:"visibility"`
175+ DefaultBranch string `json:"default_branch"`
176+ CreatedAt *string `json:"created_at"`
177+ PushedAt *string `json:"pushed_at"`
177178 }
178179
179180 type eventOut struct {
@@ -190,7 +191,7 @@ type eventOut struct {
190191 MergeRequest *mrOut `json:"merge_request"`
191192 }
192193
193-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"}
194+var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"}
194195
195196 // AC6: the CLI end to end against a real forge.
196197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -209,11 +210,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
209210 w := c.startWatch(t, secret)
210211
211212 // Create. Private by default; here public with a description.
212- res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)
213+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--topics", "Go,cli,go", "--visibility", "public", "--json").ok(t)
213214 assert.Equal(t, repoKeys, keys(t, res.stdout))
214215 created := decode[repoOut](t, res.stdout)
215216 assert.Equal(t, "proj", created.Name)
216217 assert.Equal(t, "first", created.Description)
218+ assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated")
217219 assert.Equal(t, "public", created.Visibility)
218220 assert.Equal(t, "main", created.DefaultBranch)
219221 require.NotNil(t, created.CreatedAt)
@@ -282,6 +284,16 @@ func TestAC6CLIEndToEnd(t *testing.T) {
282284 assert.Empty(t, decode[repoOut](t, res.stdout).Description)
283285 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
284286
287+ // Topics are replaced as a whole and cleared with an empty value.
288+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "forge,git", "--json").ok(t)
289+ assert.Equal(t, []string{"forge", "git"}, decode[repoOut](t, res.stdout).Topics)
290+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
291+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--topics", "Bad_Topic")
292+ requireOneLineError(t, stderr, code, "invalid request")
293+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "", "--json").ok(t)
294+ assert.Empty(t, decode[repoOut](t, res.stdout).Topics)
295+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
296+
285297 // list and show: same shape, human tables too.
286298 res = c.cli(t, secret, "repo", "list", "--json").ok(t)
287299 list := decode[[]json.RawMessage](t, res.stdout)
@@ -168,12 +168,13 @@ type tokenOut struct {
168 }168 }
169 169
170 type repoOut struct {170 type repoOut struct {
171- Name string `json:"name"`171+ Name string `json:"name"`
172- Description string `json:"description"`172+ Description string `json:"description"`
173- Visibility string `json:"visibility"`173+ Topics []string `json:"topics"`
174- DefaultBranch string `json:"default_branch"`174+ Visibility string `json:"visibility"`
175- CreatedAt *string `json:"created_at"`175+ DefaultBranch string `json:"default_branch"`
176- PushedAt *string `json:"pushed_at"`176+ CreatedAt *string `json:"created_at"`
177+ PushedAt *string `json:"pushed_at"`
177 }178 }
178 179
179 type eventOut struct {180 type eventOut struct {
@@ -190,7 +191,7 @@ type eventOut struct {
190 MergeRequest *mrOut `json:"merge_request"`191 MergeRequest *mrOut `json:"merge_request"`
191 }192 }
192 193
193-var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "visibility"}194+var repoKeys = []string{"created_at", "default_branch", "description", "failing_checks_block", "merge_modes", "name", "pushed_at", "topics", "visibility"}
194 195
195 // AC6: the CLI end to end against a real forge.196 // AC6: the CLI end to end against a real forge.
196 func TestAC6CLIEndToEnd(t *testing.T) {197 func TestAC6CLIEndToEnd(t *testing.T) {
@@ -209,11 +210,12 @@ func TestAC6CLIEndToEnd(t *testing.T) {
209 w := c.startWatch(t, secret)210 w := c.startWatch(t, secret)
210 211
211 // Create. Private by default; here public with a description.212 // Create. Private by default; here public with a description.
212- res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--visibility", "public", "--json").ok(t)213+ res := c.cli(t, secret, "repo", "create", "proj", "--description", "first", "--topics", "Go,cli,go", "--visibility", "public", "--json").ok(t)
213 assert.Equal(t, repoKeys, keys(t, res.stdout))214 assert.Equal(t, repoKeys, keys(t, res.stdout))
214 created := decode[repoOut](t, res.stdout)215 created := decode[repoOut](t, res.stdout)
215 assert.Equal(t, "proj", created.Name)216 assert.Equal(t, "proj", created.Name)
216 assert.Equal(t, "first", created.Description)217 assert.Equal(t, "first", created.Description)
218+ assert.Equal(t, []string{"cli", "go"}, created.Topics, "normalized: lowercase, sorted, deduplicated")
217 assert.Equal(t, "public", created.Visibility)219 assert.Equal(t, "public", created.Visibility)
218 assert.Equal(t, "main", created.DefaultBranch)220 assert.Equal(t, "main", created.DefaultBranch)
219 require.NotNil(t, created.CreatedAt)221 require.NotNil(t, created.CreatedAt)
@@ -282,6 +284,16 @@ func TestAC6CLIEndToEnd(t *testing.T) {
282 assert.Empty(t, decode[repoOut](t, res.stdout).Description)284 assert.Empty(t, decode[repoOut](t, res.stdout).Description)
283 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)285 assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
284 286
287+ // Topics are replaced as a whole and cleared with an empty value.
288+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "forge,git", "--json").ok(t)
289+ assert.Equal(t, []string{"forge", "git"}, decode[repoOut](t, res.stdout).Topics)
290+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
291+ _, stderr, code = c.bin(t, secret, "repo", "edit", "proj", "--topics", "Bad_Topic")
292+ requireOneLineError(t, stderr, code, "invalid request")
293+ res = c.cli(t, secret, "repo", "edit", "proj", "--topics", "", "--json").ok(t)
294+ assert.Empty(t, decode[repoOut](t, res.stdout).Topics)
295+ assert.Equal(t, "repo_updated", decode[eventOut](t, w.next(t, time.Second)).Type)
296+
285 // list and show: same shape, human tables too.297 // list and show: same shape, human tables too.
286 res = c.cli(t, secret, "repo", "list", "--json").ok(t)298 res = c.cli(t, secret, "repo", "list", "--json").ok(t)
287 list := decode[[]json.RawMessage](t, res.stdout)299 list := decode[[]json.RawMessage](t, res.stdout)
M internal/app/cli_repo.go +25 -15
@@ -26,12 +26,13 @@ const (
2626
2727 // jsonRepo is the stable JSON shape of a repository.
2828 type jsonRepo struct {
29- Name string `json:"name"`
30- Description string `json:"description"`
31- Visibility string `json:"visibility"`
32- DefaultBranch string `json:"default_branch"`
33- CreatedAt *string `json:"created_at"`
34- PushedAt *string `json:"pushed_at"`
29+ Name string `json:"name"`
30+ Description string `json:"description"`
31+ Topics []string `json:"topics"`
32+ Visibility string `json:"visibility"`
33+ DefaultBranch string `json:"default_branch"`
34+ CreatedAt *string `json:"created_at"`
35+ PushedAt *string `json:"pushed_at"`
3536 // MergeModes are the allowed merge modes ("fast-forward", "squash").
3637 MergeModes []string `json:"merge_modes"`
3738 FailingChecksBlock bool `json:"failing_checks_block"`
@@ -47,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
4748 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
4849 Name: r.GetName(),
4950 Description: r.GetDescription(),
51+ Topics: append([]string{}, r.GetTopics()...),
5052 Visibility: visibilityName(r.GetVisibility()),
5153 DefaultBranch: r.GetDefaultBranch(),
5254 CreatedAt: jsonTime(r.GetCreatedAt()),
@@ -188,6 +190,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
188190 {"visibility", visibilityName(r.GetVisibility())},
189191 {"default branch", r.GetDefaultBranch()},
190192 {"description", desc},
193+ {"topics", orDash(strings.Join(r.GetTopics(), ", "))},
191194 {"created", shortTime(r.GetCreatedAt(), "-")},
192195 {"last push", shortTime(r.GetPushedAt(), "never")},
193196 {"merge modes", orDash(strings.Join(modes, ", "))},
@@ -221,6 +224,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
221224 func newRepoCreateCmd(env *cliEnv) *cobra.Command {
222225 var (
223226 description string
227+ topics []string
224228 visibility string
225229 )
226230 cmd := &cobra.Command{
@@ -231,11 +235,12 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
231235 Args: cobra.ExactArgs(1),
232236 }
233237 cmd.Flags().StringVar(&description, "description", "", "one-line description")
238+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "topics, comma-separated (1-50 characters of a-z, 0-9 and -; at most 20)")
234239 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
235240 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
236241 asJSON := addJSONFlag(cmd)
237242 cmd.RunE = func(cmd *cobra.Command, args []string) error {
238- req, err := buildCreateRepo(args[0], description, visibility)
243+ req, err := buildCreateRepo(args[0], description, topics, visibility)
239244 if err != nil {
240245 return err
241246 }
@@ -257,23 +262,23 @@ func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.She
257262 }
258263
259264 // buildCreateRepo maps the flags of `repo create` to the request.
260-func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRequest, error) {
265+func buildCreateRepo(name, description string, topics []string, visibility string) (*ermite.CreateRepoRequest, error) {
261266 vis, err := parseVisibility(visibility)
262267 if err != nil {
263268 return nil, err
264269 }
265- return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil
270+ return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil
266271 }
267272
268273 func newRepoEditCmd(env *cliEnv) *cobra.Command {
269274 var description, visibility, defaultBranch string
270- var mergeModes []string
275+ var topics, mergeModes []string
271276 var failingChecksBlock bool
272277 cmd := &cobra.Command{
273278 Use: "edit <name>",
274- Short: "Change a repository's description, visibility, default branch or merge settings",
279+ Short: "Change a repository's description, topics, visibility, default branch or merge settings",
275280 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
276- "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
281+ "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
277282 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
278283 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
279284 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -281,6 +286,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
281286 ValidArgsFunction: completeRepoNames(env),
282287 }
283288 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
289+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
284290 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
285291 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
286292 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
@@ -291,7 +297,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291297 })
292298 asJSON := addJSONFlag(cmd)
293299 cmd.RunE = func(cmd *cobra.Command, args []string) error {
294- req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock)
300+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock)
295301 if err != nil {
296302 return err
297303 }
@@ -310,13 +316,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
310316
311317 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
312318 // the user passed go into the update mask.
313-func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
319+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
314320 req := &ermite.UpdateRepoRequest{Repo: name}
315321 var paths []string
316322 if cmd.Flags().Changed("description") {
317323 req.Description = description
318324 paths = append(paths, "description")
319325 }
326+ if cmd.Flags().Changed("topics") {
327+ req.Topics = topics
328+ paths = append(paths, "topics")
329+ }
320330 if cmd.Flags().Changed("visibility") {
321331 vis, err := parseVisibility(visibility)
322332 if err != nil {
@@ -356,7 +366,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB
356366 paths = append(paths, "settings.failing_checks_block")
357367 }
358368 if len(paths) == 0 {
359- return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block")
369+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")
360370 }
361371 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
362372 return req, nil
@@ -26,12 +26,13 @@ const (
26 26
27 // jsonRepo is the stable JSON shape of a repository.27 // jsonRepo is the stable JSON shape of a repository.
28 type jsonRepo struct {28 type jsonRepo struct {
29- Name string `json:"name"`29+ Name string `json:"name"`
30- Description string `json:"description"`30+ Description string `json:"description"`
31- Visibility string `json:"visibility"`31+ Topics []string `json:"topics"`
32- DefaultBranch string `json:"default_branch"`32+ Visibility string `json:"visibility"`
33- CreatedAt *string `json:"created_at"`33+ DefaultBranch string `json:"default_branch"`
34- PushedAt *string `json:"pushed_at"`34+ CreatedAt *string `json:"created_at"`
35+ PushedAt *string `json:"pushed_at"`
35 // MergeModes are the allowed merge modes ("fast-forward", "squash").36 // MergeModes are the allowed merge modes ("fast-forward", "squash").
36 MergeModes []string `json:"merge_modes"`37 MergeModes []string `json:"merge_modes"`
37 FailingChecksBlock bool `json:"failing_checks_block"`38 FailingChecksBlock bool `json:"failing_checks_block"`
@@ -47,6 +48,7 @@ func toJSONRepo(r *ermite.Repo) jsonRepo {
47 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),48 FailingChecksBlock: r.GetSettings().GetFailingChecksBlock(),
48 Name: r.GetName(),49 Name: r.GetName(),
49 Description: r.GetDescription(),50 Description: r.GetDescription(),
51+ Topics: append([]string{}, r.GetTopics()...),
50 Visibility: visibilityName(r.GetVisibility()),52 Visibility: visibilityName(r.GetVisibility()),
51 DefaultBranch: r.GetDefaultBranch(),53 DefaultBranch: r.GetDefaultBranch(),
52 CreatedAt: jsonTime(r.GetCreatedAt()),54 CreatedAt: jsonTime(r.GetCreatedAt()),
@@ -188,6 +190,7 @@ func printRepo(w io.Writer, r *ermite.Repo) error {
188 {"visibility", visibilityName(r.GetVisibility())},190 {"visibility", visibilityName(r.GetVisibility())},
189 {"default branch", r.GetDefaultBranch()},191 {"default branch", r.GetDefaultBranch()},
190 {"description", desc},192 {"description", desc},
193+ {"topics", orDash(strings.Join(r.GetTopics(), ", "))},
191 {"created", shortTime(r.GetCreatedAt(), "-")},194 {"created", shortTime(r.GetCreatedAt(), "-")},
192 {"last push", shortTime(r.GetPushedAt(), "never")},195 {"last push", shortTime(r.GetPushedAt(), "never")},
193 {"merge modes", orDash(strings.Join(modes, ", "))},196 {"merge modes", orDash(strings.Join(modes, ", "))},
@@ -221,6 +224,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
221 func newRepoCreateCmd(env *cliEnv) *cobra.Command {224 func newRepoCreateCmd(env *cliEnv) *cobra.Command {
222 var (225 var (
223 description string226 description string
227+ topics []string
224 visibility string228 visibility string
225 )229 )
226 cmd := &cobra.Command{230 cmd := &cobra.Command{
@@ -231,11 +235,12 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
231 Args: cobra.ExactArgs(1),235 Args: cobra.ExactArgs(1),
232 }236 }
233 cmd.Flags().StringVar(&description, "description", "", "one-line description")237 cmd.Flags().StringVar(&description, "description", "", "one-line description")
238+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "topics, comma-separated (1-50 characters of a-z, 0-9 and -; at most 20)")
234 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")239 cmd.Flags().StringVar(&visibility, "visibility", visibilityPrivate, "visibility: public or private")
235 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)240 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
236 asJSON := addJSONFlag(cmd)241 asJSON := addJSONFlag(cmd)
237 cmd.RunE = func(cmd *cobra.Command, args []string) error {242 cmd.RunE = func(cmd *cobra.Command, args []string) error {
238- req, err := buildCreateRepo(args[0], description, visibility)243+ req, err := buildCreateRepo(args[0], description, topics, visibility)
239 if err != nil {244 if err != nil {
240 return err245 return err
241 }246 }
@@ -257,23 +262,23 @@ func visibilityCompletion(*cobra.Command, []string, string) ([]string, cobra.She
257 }262 }
258 263
259 // buildCreateRepo maps the flags of `repo create` to the request.264 // buildCreateRepo maps the flags of `repo create` to the request.
260-func buildCreateRepo(name, description, visibility string) (*ermite.CreateRepoRequest, error) {265+func buildCreateRepo(name, description string, topics []string, visibility string) (*ermite.CreateRepoRequest, error) {
261 vis, err := parseVisibility(visibility)266 vis, err := parseVisibility(visibility)
262 if err != nil {267 if err != nil {
263 return nil, err268 return nil, err
264 }269 }
265- return &ermite.CreateRepoRequest{Name: name, Description: description, Visibility: vis}, nil270+ return &ermite.CreateRepoRequest{Name: name, Description: description, Topics: topics, Visibility: vis}, nil
266 }271 }
267 272
268 func newRepoEditCmd(env *cliEnv) *cobra.Command {273 func newRepoEditCmd(env *cliEnv) *cobra.Command {
269 var description, visibility, defaultBranch string274 var description, visibility, defaultBranch string
270- var mergeModes []string275+ var topics, mergeModes []string
271 var failingChecksBlock bool276 var failingChecksBlock bool
272 cmd := &cobra.Command{277 cmd := &cobra.Command{
273 Use: "edit <name>",278 Use: "edit <name>",
274- Short: "Change a repository's description, visibility, default branch or merge settings",279+ Short: "Change a repository's description, topics, visibility, default branch or merge settings",
275 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +280 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
276- "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" +281+ "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
277 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +282 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
278 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +283 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
279 "Needs the repo:admin scope." + jsonRepoHelp,284 "Needs the repo:admin scope." + jsonRepoHelp,
@@ -281,6 +286,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
281 ValidArgsFunction: completeRepoNames(env),286 ValidArgsFunction: completeRepoNames(env),
282 }287 }
283 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")288 cmd.Flags().StringVar(&description, "description", "", "new description (empty clears it)")
289+ cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
284 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")290 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
285 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")291 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
286 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")292 cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
@@ -291,7 +297,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
291 })297 })
292 asJSON := addJSONFlag(cmd)298 asJSON := addJSONFlag(cmd)
293 cmd.RunE = func(cmd *cobra.Command, args []string) error {299 cmd.RunE = func(cmd *cobra.Command, args []string) error {
294- req, err := buildUpdateRepo(cmd, args[0], description, visibility, defaultBranch, mergeModes, failingChecksBlock)300+ req, err := buildUpdateRepo(cmd, args[0], description, topics, visibility, defaultBranch, mergeModes, failingChecksBlock)
295 if err != nil {301 if err != nil {
296 return err302 return err
297 }303 }
@@ -310,13 +316,17 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
310 316
311 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags317 // buildUpdateRepo maps the flags of `repo edit` to the request: only the flags
312 // the user passed go into the update mask.318 // the user passed go into the update mask.
313-func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {319+func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []string, visibility, defaultBranch string, mergeModes []string, failingChecksBlock bool) (*ermite.UpdateRepoRequest, error) {
314 req := &ermite.UpdateRepoRequest{Repo: name}320 req := &ermite.UpdateRepoRequest{Repo: name}
315 var paths []string321 var paths []string
316 if cmd.Flags().Changed("description") {322 if cmd.Flags().Changed("description") {
317 req.Description = description323 req.Description = description
318 paths = append(paths, "description")324 paths = append(paths, "description")
319 }325 }
326+ if cmd.Flags().Changed("topics") {
327+ req.Topics = topics
328+ paths = append(paths, "topics")
329+ }
320 if cmd.Flags().Changed("visibility") {330 if cmd.Flags().Changed("visibility") {
321 vis, err := parseVisibility(visibility)331 vis, err := parseVisibility(visibility)
322 if err != nil {332 if err != nil {
@@ -356,7 +366,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description, visibility, defaultB
356 paths = append(paths, "settings.failing_checks_block")366 paths = append(paths, "settings.failing_checks_block")
357 }367 }
358 if len(paths) == 0 {368 if len(paths) == 0 {
359- return nil, errors.New("nothing to change: give at least one of --description, --visibility, --default-branch, --merge-modes, --failing-checks-block")369+ return nil, errors.New("nothing to change: give at least one of --description, --topics, --visibility, --default-branch, --merge-modes, --failing-checks-block")
360 }370 }
361 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}371 req.UpdateMask = &fieldmaskpb.FieldMask{Paths: paths}
362 return req, nil372 return req, nil
M internal/app/cli_repo_test.go +22 -12
@@ -16,7 +16,7 @@ import (
1616
1717 var (
1818 repoAlpha = &ermite.Repo{
19- Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
19+ Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
2020 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
2121 Settings: &ermite.RepoSettings{
2222 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
5454 {
5555 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
5656 wantStdout: `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
5959 ]`,
6060 },
6161 {
@@ -111,6 +111,7 @@ func TestRepoShow(t *testing.T) {
111111 "visibility public\n"+
112112 "default branch main\n"+
113113 "description The first one\n"+
114+ "topics cli, go\n"+
114115 "created 2026-01-02 03:04\n"+
115116 "last push 2026-03-04 05:06\n"+
116117 "merge modes fast-forward, squash\n"+
@@ -119,7 +120,7 @@ func TestRepoShow(t *testing.T) {
119120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
120121
121122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
122- assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123124 }
124125
125126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -129,14 +130,16 @@ func TestRepoShowNeedsOneArg(t *testing.T) {
129130
130131 func TestRepoCreateMapsFlags(t *testing.T) {
131132 tests := []struct {
132- name string
133- args []string
134- want ermite.Visibility
135- desc string
133+ name string
134+ args []string
135+ want ermite.Visibility
136+ desc string
137+ topics []string
136138 }{
137- {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},
138- {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},
139- {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},
139+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
140+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
141+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
142+ {"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
140143 }
141144 for _, tt := range tests {
142145 t.Run(tt.name, func(t *testing.T) {
@@ -150,6 +153,7 @@ func TestRepoCreateMapsFlags(t *testing.T) {
150153 assert.Equal(t, "x", got.GetName())
151154 assert.Equal(t, tt.want, got.GetVisibility())
152155 assert.Equal(t, tt.desc, got.GetDescription())
156+ assert.Equal(t, tt.topics, got.GetTopics())
153157 })
154158 }
155159 }
@@ -172,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
172176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
173177 }}
174178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
175- assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
176180 }
177181
178182 func TestRepoEditMask(t *testing.T) {
@@ -188,6 +192,12 @@ func TestRepoEditMask(t *testing.T) {
188192 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
189193 assert.Empty(t, r.GetDescription())
190194 }},
195+ {"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
196+ assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
197+ }},
198+ {"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
199+ assert.Empty(t, r.GetTopics())
200+ }},
191201 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
192202 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
193203 }},
@@ -16,7 +16,7 @@ import (
16 16
17 var (17 var (
18 repoAlpha = &ermite.Repo{18 repoAlpha = &ermite.Repo{
19- Name: "alpha", Description: "The first one", Visibility: ermite.Visibility_VISIBILITY_PUBLIC,19+ Name: "alpha", Description: "The first one", Topics: []string{"cli", "go"}, Visibility: ermite.Visibility_VISIBILITY_PUBLIC,
20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),20 DefaultBranch: "main", CreatedAt: ts("2026-01-02T03:04:05Z"), PushedAt: ts("2026-03-04T05:06:07Z"),
21 Settings: &ermite.RepoSettings{21 Settings: &ermite.RepoSettings{
22 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},22 AllowedMergeModes: []ermite.MergeMode{ermite.MergeMode_MERGE_MODE_FAST_FORWARD, ermite.MergeMode_MERGE_MODE_SQUASH},
@@ -54,8 +54,8 @@ func TestRepoList(t *testing.T) {
54 {54 {
55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,55 name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true,
56 wantStdout: `[56 wantStdout: `[
57- {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},57+ {"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true},
58- {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}58+ {"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}
59 ]`,59 ]`,
60 },60 },
61 {61 {
@@ -111,6 +111,7 @@ func TestRepoShow(t *testing.T) {
111 "visibility public\n"+111 "visibility public\n"+
112 "default branch main\n"+112 "default branch main\n"+
113 "description The first one\n"+113 "description The first one\n"+
114+ "topics cli, go\n"+
114 "created 2026-01-02 03:04\n"+115 "created 2026-01-02 03:04\n"+
115 "last push 2026-03-04 05:06\n"+116 "last push 2026-03-04 05:06\n"+
116 "merge modes fast-forward, squash\n"+117 "merge modes fast-forward, squash\n"+
@@ -119,7 +120,7 @@ func TestRepoShow(t *testing.T) {
119 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())120 assert.Equal(t, "alpha", reqs[0].(*ermite.GetRepoRequest).GetRepo())
120 121
121 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)122 res = runCLI(t, envFor(url), "", "repo", "show", "alpha", "--json").ok(t)
122- assert.JSONEq(t, `{"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)123+ assert.JSONEq(t, `{"name":"alpha","description":"The first one","topics":["cli","go"],"visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}`, res.stdout)
123 }124 }
124 125
125 func TestRepoShowNeedsOneArg(t *testing.T) {126 func TestRepoShowNeedsOneArg(t *testing.T) {
@@ -129,14 +130,16 @@ func TestRepoShowNeedsOneArg(t *testing.T) {
129 130
130 func TestRepoCreateMapsFlags(t *testing.T) {131 func TestRepoCreateMapsFlags(t *testing.T) {
131 tests := []struct {132 tests := []struct {
132- name string133+ name string
133- args []string134+ args []string
134- want ermite.Visibility135+ want ermite.Visibility
135- desc string136+ desc string
137+ topics []string
136 }{138 }{
137- {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, ""},139+ {"default is private", []string{"x"}, ermite.Visibility_VISIBILITY_PRIVATE, "", nil},
138- {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d"},140+ {"--visibility public", []string{"x", "--visibility", "public", "--description", "d d"}, ermite.Visibility_VISIBILITY_PUBLIC, "d d", nil},
139- {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, ""},141+ {"case insensitive", []string{"x", "--visibility", "Public"}, ermite.Visibility_VISIBILITY_PUBLIC, "", nil},
142+ {"--topics", []string{"x", "--topics", "go,cli"}, ermite.Visibility_VISIBILITY_PRIVATE, "", []string{"go", "cli"}},
140 }143 }
141 for _, tt := range tests {144 for _, tt := range tests {
142 t.Run(tt.name, func(t *testing.T) {145 t.Run(tt.name, func(t *testing.T) {
@@ -150,6 +153,7 @@ func TestRepoCreateMapsFlags(t *testing.T) {
150 assert.Equal(t, "x", got.GetName())153 assert.Equal(t, "x", got.GetName())
151 assert.Equal(t, tt.want, got.GetVisibility())154 assert.Equal(t, tt.want, got.GetVisibility())
152 assert.Equal(t, tt.desc, got.GetDescription())155 assert.Equal(t, tt.desc, got.GetDescription())
156+ assert.Equal(t, tt.topics, got.GetTopics())
153 })157 })
154 }158 }
155 }159 }
@@ -172,7 +176,7 @@ func TestRepoCreateJSON(t *testing.T) {
172 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil176 return &ermite.CreateRepoResponse{Repo: repoBeta}, nil
173 }}177 }}
174 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)178 res := runCLI(t, envFor(f.serve(t)), "", "repo", "create", "beta", "--json").ok(t)
175- assert.JSONEq(t, `{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)179+ assert.JSONEq(t, `{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}`, res.stdout)
176 }180 }
177 181
178 func TestRepoEditMask(t *testing.T) {182 func TestRepoEditMask(t *testing.T) {
@@ -188,6 +192,12 @@ func TestRepoEditMask(t *testing.T) {
188 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {192 {"clear description", []string{"--description", ""}, []string{"description"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
189 assert.Empty(t, r.GetDescription())193 assert.Empty(t, r.GetDescription())
190 }},194 }},
195+ {"topics", []string{"--topics", "Go,cli"}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
196+ assert.Equal(t, []string{"Go", "cli"}, r.GetTopics())
197+ }},
198+ {"clear topics", []string{"--topics", ""}, []string{"topics"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
199+ assert.Empty(t, r.GetTopics())
200+ }},
191 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {201 {"visibility", []string{"--visibility", "public"}, []string{"visibility"}, func(t *testing.T, r *ermite.UpdateRepoRequest) {
192 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())202 assert.Equal(t, ermite.Visibility_VISIBILITY_PUBLIC, r.GetVisibility())
193 }},203 }},
M internal/app/cli_watch_test.go +1 -1
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
9595 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
9696 }
9797 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
9999 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
@@ -95,7 +95,7 @@ func TestWatchNDJSON(t *testing.T) {
95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())95 assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96 }96 }
97 require.Len(t, lines, 5)97 require.Len(t, lines, 5)
98- assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])98+ assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","topics":[],"visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[99 assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},100 {"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},101 {"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
M internal/app/testdata/web/home.html +32 -2
@@ -72,6 +72,34 @@
7272 <a href="/demo">demo</a>
7373 </h2>
7474 <p class="repo-desc">A demo repository</p>
75+<figure class="repo-pulse">
76+<svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
77+<defs>
78+<linearGradient id="hb-demo" x1="0" y1="0" x2="0" y2="1">
79+<stop class="pulse-stop-top" offset="0">
80+</stop>
81+<stop class="pulse-stop-bottom" offset="1">
82+</stop>
83+</linearGradient>
84+</defs>
85+<path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke">
86+</path>
87+<path class="pulse-area" d="M0 58 C98.3 58 491.7 58 590 58 L590 64 L0 64 Z" fill="url(#hb-demo)">
88+</path>
89+<path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
90+</path>
91+<circle class="pulse-ring" cx="590" cy="58" r="4">
92+</circle>
93+<circle class="pulse-dot" cx="590" cy="58" r="3.5">
94+</circle>
95+</svg>
96+<figcaption class="pulse-cap" aria-hidden="true">
97+<span>90 days ago</span>
98+<span>today</span>
99+</figcaption>
100+</figure>
101+</div>
102+<div class="repo-head">
75103 <dl class="repo-stats" aria-label="Figures of demo">
76104 <div>
77105 <dt>commits</dt>
@@ -81,10 +109,10 @@
81109 <dt>last 30 days</dt>
82110 <dd>0</dd>
83111 </div>
84-<div>
112+<div class="rs-tag">
85113 <dt>latest tag</dt>
86114 <dd>
87-<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>
115+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
88116 </dd>
89117 </div>
90118 <div>
@@ -114,6 +142,8 @@
114142 <a href="/quiet">quiet</a>
115143 </h2>
116144 <p class="repo-desc mute">no description</p>
145+</div>
146+<div class="repo-head">
117147 <dl class="repo-stats" aria-label="Figures of quiet">
118148 <div>
119149 <dt>last activity</dt>
@@ -72,6 +72,34 @@
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+<figure class="repo-pulse">
76+<svg viewBox="0 0 600 64" role="img" aria-label="Activity: 0 commits in the last 90 days" focusable="false">
77+<defs>
78+<linearGradient id="hb-demo" x1="0" y1="0" x2="0" y2="1">
79+<stop class="pulse-stop-top" offset="0">
80+</stop>
81+<stop class="pulse-stop-bottom" offset="1">
82+</stop>
83+</linearGradient>
84+</defs>
85+<path class="pulse-grid" d="M0 21.5 H600 M0 42.5 H600 M0 63.5 H600" vector-effect="non-scaling-stroke">
86+</path>
87+<path class="pulse-area" d="M0 58 C98.3 58 491.7 58 590 58 L590 64 L0 64 Z" fill="url(#hb-demo)">
88+</path>
89+<path class="pulse-line" d="M0 58 C98.3 58 491.7 58 590 58" vector-effect="non-scaling-stroke">
90+</path>
91+<circle class="pulse-ring" cx="590" cy="58" r="4">
92+</circle>
93+<circle class="pulse-dot" cx="590" cy="58" r="3.5">
94+</circle>
95+</svg>
96+<figcaption class="pulse-cap" aria-hidden="true">
97+<span>90 days ago</span>
98+<span>today</span>
99+</figcaption>
100+</figure>
101+</div>
102+<div class="repo-head">
75 <dl class="repo-stats" aria-label="Figures of demo">103 <dl class="repo-stats" aria-label="Figures of demo">
76 <div>104 <div>
77 <dt>commits</dt>105 <dt>commits</dt>
@@ -81,10 +109,10 @@
81 <dt>last 30 days</dt>109 <dt>last 30 days</dt>
82 <dd>0</dd>110 <dd>0</dd>
83 </div>111 </div>
84-<div>112+<div class="rs-tag">
85 <dt>latest tag</dt>113 <dt>latest tag</dt>
86 <dd>114 <dd>
87-<a href="/demo/tree/SHA40" title="made AGO">v0.2.0</a>115+<a href="/demo/tree/SHA40" title="v0.2.0 · made AGO">v0.2.0</a>
88 </dd>116 </dd>
89 </div>117 </div>
90 <div>118 <div>
@@ -114,6 +142,8 @@
114 <a href="/quiet">quiet</a>142 <a href="/quiet">quiet</a>
115 </h2>143 </h2>
116 <p class="repo-desc mute">no description</p>144 <p class="repo-desc mute">no description</p>
145+</div>
146+<div class="repo-head">
117 <dl class="repo-stats" aria-label="Figures of quiet">147 <dl class="repo-stats" aria-label="Figures of quiet">
118 <div>148 <div>
119 <dt>last activity</dt>149 <dt>last activity</dt>
M internal/app/web_test.go +5 -2
@@ -184,8 +184,11 @@ var (
184184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
185185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
186186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
187- reImport = regexp.MustCompile(`(?i)@import|url\(`)
188- reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
187+ // reImport finds what loads a resource from a page: an @import, or a url()
188+ // that is not a reference to an element of the same document (an SVG
189+ // gradient, fill="url(#id)").
190+ reImport = regexp.MustCompile(`(?i)@import|url\(\s*[^#\s]`)
191+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
189192 )
190193
191194 // assertNoInline checks the page has no inline script, style element or
@@ -184,8 +184,11 @@ var (
184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)184 reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`)
185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)185 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)186 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
187- reImport = regexp.MustCompile(`(?i)@import|url\(`)187+ // reImport finds what loads a resource from a page: an @import, or a url()
188- reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)188+ // that is not a reference to an element of the same document (an SVG
189+ // gradient, fill="url(#id)").
190+ reImport = regexp.MustCompile(`(?i)@import|url\(\s*[^#\s]`)
191+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
189 )192 )
190 193
191 // assertNoInline checks the page has no inline script, style element or194 // assertNoInline checks the page has no inline script, style element or
M internal/domain/repo.go +52 -3
@@ -4,6 +4,8 @@ package domain
44
55 import (
66 "fmt"
7+ "slices"
8+ "strings"
79 "time"
810 )
911
@@ -35,10 +37,57 @@ func (v Visibility) String() string { return string(v) }
3537 type Repo struct {
3638 Name RepoName
3739 Description string
38- Visibility Visibility
39- CreatedAt time.Time
40- PushedAt time.Time // zero until the first push
40+ // Topics are the normalized topics (see NormalizeTopics); nil for none.
41+ Topics []string
42+ Visibility Visibility
43+ CreatedAt time.Time
44+ PushedAt time.Time // zero until the first push
4145 // Settings are the merge settings; the stores always fill them in, with
4246 // the defaults for a repository that never stored any.
4347 Settings RepoSettings
4448 }
49+
50+// Limits of the topics of a repository.
51+const (
52+ // MaxTopics is the largest number of topics of a repository.
53+ MaxTopics = 20
54+ // MaxTopicLen is the longest topic, in characters.
55+ MaxTopicLen = 50
56+)
57+
58+// NormalizeTopics lowercases, validates, deduplicates and sorts topics. A
59+// topic is 1 to MaxTopicLen characters of a-z, 0-9 and "-", starting with a
60+// letter or a digit; there are at most MaxTopics of them once deduplicated.
61+// No topic gives nil. A broken rule gives an error matching ErrInvalid.
62+func NormalizeTopics(topics []string) ([]string, error) {
63+ var out []string
64+ for _, t := range topics {
65+ t = strings.ToLower(t)
66+ if !validTopic(t) {
67+ return nil, fmt.Errorf("%w: topic %q must be 1 to %d characters of a-z, 0-9 and -, starting with a letter or a digit", ErrInvalid, t, MaxTopicLen)
68+ }
69+ out = append(out, t)
70+ }
71+ slices.Sort(out)
72+ out = slices.Compact(out)
73+ if len(out) > MaxTopics {
74+ return nil, fmt.Errorf("%w: %d topics, at most %d", ErrInvalid, len(out), MaxTopics)
75+ }
76+ return out, nil
77+}
78+
79+func validTopic(t string) bool {
80+ if t == "" || len(t) > MaxTopicLen {
81+ return false
82+ }
83+ for i := range len(t) {
84+ c := t[i]
85+ switch {
86+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
87+ case c == '-' && i > 0:
88+ default:
89+ return false
90+ }
91+ }
92+ return true
93+}
@@ -4,6 +4,8 @@ package domain
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "slices"
8+ "strings"
7 "time"9 "time"
8 )10 )
9 11
@@ -35,10 +37,57 @@ func (v Visibility) String() string { return string(v) }
35 type Repo struct {37 type Repo struct {
36 Name RepoName38 Name RepoName
37 Description string39 Description string
38- Visibility Visibility40+ // Topics are the normalized topics (see NormalizeTopics); nil for none.
39- CreatedAt time.Time41+ Topics []string
40- PushedAt time.Time // zero until the first push42+ Visibility Visibility
43+ CreatedAt time.Time
44+ PushedAt time.Time // zero until the first push
41 // Settings are the merge settings; the stores always fill them in, with45 // Settings are the merge settings; the stores always fill them in, with
42 // the defaults for a repository that never stored any.46 // the defaults for a repository that never stored any.
43 Settings RepoSettings47 Settings RepoSettings
44 }48 }
49+
50+// Limits of the topics of a repository.
51+const (
52+ // MaxTopics is the largest number of topics of a repository.
53+ MaxTopics = 20
54+ // MaxTopicLen is the longest topic, in characters.
55+ MaxTopicLen = 50
56+)
57+
58+// NormalizeTopics lowercases, validates, deduplicates and sorts topics. A
59+// topic is 1 to MaxTopicLen characters of a-z, 0-9 and "-", starting with a
60+// letter or a digit; there are at most MaxTopics of them once deduplicated.
61+// No topic gives nil. A broken rule gives an error matching ErrInvalid.
62+func NormalizeTopics(topics []string) ([]string, error) {
63+ var out []string
64+ for _, t := range topics {
65+ t = strings.ToLower(t)
66+ if !validTopic(t) {
67+ return nil, fmt.Errorf("%w: topic %q must be 1 to %d characters of a-z, 0-9 and -, starting with a letter or a digit", ErrInvalid, t, MaxTopicLen)
68+ }
69+ out = append(out, t)
70+ }
71+ slices.Sort(out)
72+ out = slices.Compact(out)
73+ if len(out) > MaxTopics {
74+ return nil, fmt.Errorf("%w: %d topics, at most %d", ErrInvalid, len(out), MaxTopics)
75+ }
76+ return out, nil
77+}
78+
79+func validTopic(t string) bool {
80+ if t == "" || len(t) > MaxTopicLen {
81+ return false
82+ }
83+ for i := range len(t) {
84+ c := t[i]
85+ switch {
86+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
87+ case c == '-' && i > 0:
88+ default:
89+ return false
90+ }
91+ }
92+ return true
93+}
A internal/domain/repo_test.go +62 -0
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNormalizeTopics(t *testing.T) {
16+ t.Parallel()
17+ many := func(n int) []string {
18+ var out []string
19+ for i := range n {
20+ out = append(out, "t"+strings.Repeat("x", i))
21+ }
22+ return out
23+ }
24+ tests := []struct {
25+ name string
26+ in []string
27+ want []string
28+ bad bool
29+ }{
30+ {name: "nil", in: nil, want: nil},
31+ {name: "empty", in: []string{}, want: nil},
32+ {name: "lowercased", in: []string{"Go"}, want: []string{"go"}},
33+ {name: "sorted", in: []string{"zig", "c", "go"}, want: []string{"c", "go", "zig"}},
34+ {name: "deduplicated after lowercasing", in: []string{"Go", "go", "GO"}, want: []string{"go"}},
35+ {name: "digits and hyphens", in: []string{"3d-printing", "0"}, want: []string{"0", "3d-printing"}},
36+ {name: "50 characters", in: []string{strings.Repeat("a", 50)}, want: []string{strings.Repeat("a", 50)}},
37+ {name: "exactly 20", in: many(20), want: many(20)},
38+ {name: "duplicates do not count twice", in: append(many(20), "t", "tx"), want: many(20)},
39+ {name: "21", in: many(21), bad: true},
40+ {name: "51 characters", in: []string{strings.Repeat("a", 51)}, bad: true},
41+ {name: "empty topic", in: []string{""}, bad: true},
42+ {name: "leading hyphen", in: []string{"-go"}, bad: true},
43+ {name: "underscore", in: []string{"a_b"}, bad: true},
44+ {name: "space", in: []string{"a b"}, bad: true},
45+ {name: "dot", in: []string{"a.b"}, bad: true},
46+ {name: "non-ASCII", in: []string{"café"}, bad: true},
47+ {name: "one bad among good", in: []string{"go", "no!"}, bad: true},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ t.Parallel()
52+ got, err := domain.NormalizeTopics(tt.in)
53+ if tt.bad {
54+ require.ErrorIs(t, err, domain.ErrInvalid)
55+ assert.Nil(t, got)
56+ return
57+ }
58+ require.NoError(t, err)
59+ assert.Equal(t, tt.want, got)
60+ })
61+ }
62+}
@@ -0,0 +1,62 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNormalizeTopics(t *testing.T) {
16+ t.Parallel()
17+ many := func(n int) []string {
18+ var out []string
19+ for i := range n {
20+ out = append(out, "t"+strings.Repeat("x", i))
21+ }
22+ return out
23+ }
24+ tests := []struct {
25+ name string
26+ in []string
27+ want []string
28+ bad bool
29+ }{
30+ {name: "nil", in: nil, want: nil},
31+ {name: "empty", in: []string{}, want: nil},
32+ {name: "lowercased", in: []string{"Go"}, want: []string{"go"}},
33+ {name: "sorted", in: []string{"zig", "c", "go"}, want: []string{"c", "go", "zig"}},
34+ {name: "deduplicated after lowercasing", in: []string{"Go", "go", "GO"}, want: []string{"go"}},
35+ {name: "digits and hyphens", in: []string{"3d-printing", "0"}, want: []string{"0", "3d-printing"}},
36+ {name: "50 characters", in: []string{strings.Repeat("a", 50)}, want: []string{strings.Repeat("a", 50)}},
37+ {name: "exactly 20", in: many(20), want: many(20)},
38+ {name: "duplicates do not count twice", in: append(many(20), "t", "tx"), want: many(20)},
39+ {name: "21", in: many(21), bad: true},
40+ {name: "51 characters", in: []string{strings.Repeat("a", 51)}, bad: true},
41+ {name: "empty topic", in: []string{""}, bad: true},
42+ {name: "leading hyphen", in: []string{"-go"}, bad: true},
43+ {name: "underscore", in: []string{"a_b"}, bad: true},
44+ {name: "space", in: []string{"a b"}, bad: true},
45+ {name: "dot", in: []string{"a.b"}, bad: true},
46+ {name: "non-ASCII", in: []string{"café"}, bad: true},
47+ {name: "one bad among good", in: []string{"go", "no!"}, bad: true},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ t.Parallel()
52+ got, err := domain.NormalizeTopics(tt.in)
53+ if tt.bad {
54+ require.ErrorIs(t, err, domain.ErrInvalid)
55+ assert.Nil(t, got)
56+ return
57+ }
58+ require.NoError(t, err)
59+ assert.Equal(t, tt.want, got)
60+ })
61+ }
62+}
M internal/ports/dto.go +14 -1
@@ -31,13 +31,17 @@ type RepoView struct {
3131 type CreateRepoParams struct {
3232 Name domain.RepoName
3333 Description string
34+ // Topics are normalized by Create (see domain.NormalizeTopics).
35+ Topics []string
3436 // Visibility defaults to private when empty.
3537 Visibility domain.Visibility
3638 }
3739
3840 // UpdateRepoParams are the changes of Update; a nil field is left alone.
3941 type UpdateRepoParams struct {
40- Description *string
42+ Description *string
43+ // Topics replaces the topics; an empty list clears them.
44+ Topics *[]string
4145 Visibility *domain.Visibility
4246 DefaultBranch *string
4347 // AllowedMergeModes replaces the allowed merge modes; it must hold at
@@ -121,11 +125,20 @@ type RepoStats struct {
121125 Commits, Recent int
122126 // LatestTag is the most recently created tag; nil without tags.
123127 LatestTag *domain.TagInfo
128+ // Activity is the number of commits of each of the last ActivityDays
129+ // days, oldest first; the last day ends now. A day is 24 hours.
130+ Activity []int
131+ // Partial tells that the recent commits were more than MaxCommitTimes:
132+ // Recent and Activity count only some of them.
133+ Partial bool
124134 }
125135
126136 // RecentDays is the window of RepoStats.Recent.
127137 const RecentDays = 30
128138
139+// ActivityDays is the span of RepoStats.Activity.
140+const ActivityDays = 90
141+
129142 // WatchParams select the events of a watch.
130143 type WatchParams struct {
131144 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
@@ -31,13 +31,17 @@ type RepoView struct {
31 type CreateRepoParams struct {31 type CreateRepoParams struct {
32 Name domain.RepoName32 Name domain.RepoName
33 Description string33 Description string
34+ // Topics are normalized by Create (see domain.NormalizeTopics).
35+ Topics []string
34 // Visibility defaults to private when empty.36 // Visibility defaults to private when empty.
35 Visibility domain.Visibility37 Visibility domain.Visibility
36 }38 }
37 39
38 // UpdateRepoParams are the changes of Update; a nil field is left alone.40 // UpdateRepoParams are the changes of Update; a nil field is left alone.
39 type UpdateRepoParams struct {41 type UpdateRepoParams struct {
40- Description *string42+ Description *string
43+ // Topics replaces the topics; an empty list clears them.
44+ Topics *[]string
41 Visibility *domain.Visibility45 Visibility *domain.Visibility
42 DefaultBranch *string46 DefaultBranch *string
43 // AllowedMergeModes replaces the allowed merge modes; it must hold at47 // AllowedMergeModes replaces the allowed merge modes; it must hold at
@@ -121,11 +125,20 @@ type RepoStats struct {
121 Commits, Recent int125 Commits, Recent int
122 // LatestTag is the most recently created tag; nil without tags.126 // LatestTag is the most recently created tag; nil without tags.
123 LatestTag *domain.TagInfo127 LatestTag *domain.TagInfo
128+ // Activity is the number of commits of each of the last ActivityDays
129+ // days, oldest first; the last day ends now. A day is 24 hours.
130+ Activity []int
131+ // Partial tells that the recent commits were more than MaxCommitTimes:
132+ // Recent and Activity count only some of them.
133+ Partial bool
124 }134 }
125 135
126 // RecentDays is the window of RepoStats.Recent.136 // RecentDays is the window of RepoStats.Recent.
127 const RecentDays = 30137 const RecentDays = 30
128 138
139+// ActivityDays is the span of RepoStats.Activity.
140+const ActivityDays = 90
141+
129 // WatchParams select the events of a watch.142 // WatchParams select the events of a watch.
130 type WatchParams struct {143 type WatchParams struct {
131 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.144 // Topics are TopicHome or "repo:<name>"; empty means TopicHome.
M internal/ports/mocks/RepoReader.go +98 -18
@@ -50,47 +50,55 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
5050 return &RepoReader_Expecter{mock: &_m.Mock}
5151 }
5252
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) {
53+// CommitTimes provides a mock function for the type RepoReader
54+func (_mock *RepoReader) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
5555 ret := _mock.Called(ctx, repo, head, since)
5656
5757 if len(ret) == 0 {
58- panic("no return value specified for CountCommits")
58+ panic("no return value specified for CommitTimes")
5959 }
6060
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 {
61+ var r0 []time.Time
62+ var r1 bool
63+ var r2 error
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) ([]time.Time, bool, error)); ok {
6465 return returnFunc(ctx, repo, head, since)
6566 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {
67+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) []time.Time); ok {
6768 r0 = returnFunc(ctx, repo, head, since)
6869 } else {
69- r0 = ret.Get(0).(int)
70+ if ret.Get(0) != nil {
71+ r0 = ret.Get(0).([]time.Time)
72+ }
7073 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
74+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) bool); ok {
7275 r1 = returnFunc(ctx, repo, head, since)
7376 } else {
74- r1 = ret.Error(1)
77+ r1 = ret.Get(1).(bool)
7578 }
76- return r0, r1
79+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
80+ r2 = returnFunc(ctx, repo, head, since)
81+ } else {
82+ r2 = ret.Error(2)
83+ }
84+ return r0, r1, r2
7785 }
7886
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 {
87+// RepoReader_CommitTimes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitTimes'
88+type RepoReader_CommitTimes_Call struct {
8189 *mock.Call
8290 }
8391
84-// CountCommits is a helper method to define mock.On call
92+// CommitTimes is a helper method to define mock.On call
8593 // - ctx context.Context
8694 // - repo domain.RepoName
8795 // - head domain.ObjectID
8896 // - 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)}
97+func (_e *RepoReader_Expecter) CommitTimes(ctx any, repo any, head any, since any) *RepoReader_CommitTimes_Call {
98+ return &RepoReader_CommitTimes_Call{Call: _e.mock.On("CommitTimes", ctx, repo, head, since)}
9199 }
92100
93-func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {
101+func (_c *RepoReader_CommitTimes_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CommitTimes_Call {
94102 _c.Call.Run(func(args mock.Arguments) {
95103 var arg0 context.Context
96104 if args[0] != nil {
@@ -118,12 +126,84 @@ func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo d
118126 return _c
119127 }
120128
129+func (_c *RepoReader_CommitTimes_Call) Return(times []time.Time, truncated bool, err error) *RepoReader_CommitTimes_Call {
130+ _c.Call.Return(times, truncated, err)
131+ return _c
132+}
133+
134+func (_c *RepoReader_CommitTimes_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error)) *RepoReader_CommitTimes_Call {
135+ _c.Call.Return(run)
136+ return _c
137+}
138+
139+// CountCommits provides a mock function for the type RepoReader
140+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
141+ ret := _mock.Called(ctx, repo, head)
142+
143+ if len(ret) == 0 {
144+ panic("no return value specified for CountCommits")
145+ }
146+
147+ var r0 int
148+ var r1 error
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) (int, error)); ok {
150+ return returnFunc(ctx, repo, head)
151+ }
152+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) int); ok {
153+ r0 = returnFunc(ctx, repo, head)
154+ } else {
155+ r0 = ret.Get(0).(int)
156+ }
157+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID) error); ok {
158+ r1 = returnFunc(ctx, repo, head)
159+ } else {
160+ r1 = ret.Error(1)
161+ }
162+ return r0, r1
163+}
164+
165+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
166+type RepoReader_CountCommits_Call struct {
167+ *mock.Call
168+}
169+
170+// CountCommits is a helper method to define mock.On call
171+// - ctx context.Context
172+// - repo domain.RepoName
173+// - head domain.ObjectID
174+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any) *RepoReader_CountCommits_Call {
175+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head)}
176+}
177+
178+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID)) *RepoReader_CountCommits_Call {
179+ _c.Call.Run(func(args mock.Arguments) {
180+ var arg0 context.Context
181+ if args[0] != nil {
182+ arg0 = args[0].(context.Context)
183+ }
184+ var arg1 domain.RepoName
185+ if args[1] != nil {
186+ arg1 = args[1].(domain.RepoName)
187+ }
188+ var arg2 domain.ObjectID
189+ if args[2] != nil {
190+ arg2 = args[2].(domain.ObjectID)
191+ }
192+ run(
193+ arg0,
194+ arg1,
195+ arg2,
196+ )
197+ })
198+ return _c
199+}
200+
121201 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122202 _c.Call.Return(n, err)
123203 return _c
124204 }
125205
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 {
206+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)) *RepoReader_CountCommits_Call {
127207 _c.Call.Return(run)
128208 return _c
129209 }
@@ -50,47 +50,55 @@ func (_m *RepoReader) EXPECT() *RepoReader_Expecter {
50 return &RepoReader_Expecter{mock: &_m.Mock}50 return &RepoReader_Expecter{mock: &_m.Mock}
51 }51 }
52 52
53-// CountCommits provides a mock function for the type RepoReader53+// CommitTimes 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) {54+func (_mock *RepoReader) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
55 ret := _mock.Called(ctx, repo, head, since)55 ret := _mock.Called(ctx, repo, head, since)
56 56
57 if len(ret) == 0 {57 if len(ret) == 0 {
58- panic("no return value specified for CountCommits")58+ panic("no return value specified for CommitTimes")
59 }59 }
60 60
61- var r0 int61+ var r0 []time.Time
62- var r1 error62+ var r1 bool
63- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error)); ok {63+ var r2 error
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) ([]time.Time, bool, error)); ok {
64 return returnFunc(ctx, repo, head, since)65 return returnFunc(ctx, repo, head, since)
65 }66 }
66- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) int); ok {67+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) []time.Time); ok {
67 r0 = returnFunc(ctx, repo, head, since)68 r0 = returnFunc(ctx, repo, head, since)
68 } else {69 } else {
69- r0 = ret.Get(0).(int)70+ if ret.Get(0) != nil {
71+ r0 = ret.Get(0).([]time.Time)
72+ }
70 }73 }
71- if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {74+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) bool); ok {
72 r1 = returnFunc(ctx, repo, head, since)75 r1 = returnFunc(ctx, repo, head, since)
73 } else {76 } else {
74- r1 = ret.Error(1)77+ r1 = ret.Get(1).(bool)
75 }78 }
76- return r0, r179+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, time.Time) error); ok {
80+ r2 = returnFunc(ctx, repo, head, since)
81+ } else {
82+ r2 = ret.Error(2)
83+ }
84+ return r0, r1, r2
77 }85 }
78 86
79-// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'87+// RepoReader_CommitTimes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitTimes'
80-type RepoReader_CountCommits_Call struct {88+type RepoReader_CommitTimes_Call struct {
81 *mock.Call89 *mock.Call
82 }90 }
83 91
84-// CountCommits is a helper method to define mock.On call92+// CommitTimes is a helper method to define mock.On call
85 // - ctx context.Context93 // - ctx context.Context
86 // - repo domain.RepoName94 // - repo domain.RepoName
87 // - head domain.ObjectID95 // - head domain.ObjectID
88 // - since time.Time96 // - since time.Time
89-func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any, since any) *RepoReader_CountCommits_Call {97+func (_e *RepoReader_Expecter) CommitTimes(ctx any, repo any, head any, since any) *RepoReader_CommitTimes_Call {
90- return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head, since)}98+ return &RepoReader_CommitTimes_Call{Call: _e.mock.On("CommitTimes", ctx, repo, head, since)}
91 }99 }
92 100
93-func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CountCommits_Call {101+func (_c *RepoReader_CommitTimes_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time)) *RepoReader_CommitTimes_Call {
94 _c.Call.Run(func(args mock.Arguments) {102 _c.Call.Run(func(args mock.Arguments) {
95 var arg0 context.Context103 var arg0 context.Context
96 if args[0] != nil {104 if args[0] != nil {
@@ -118,12 +126,84 @@ func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo d
118 return _c126 return _c
119 }127 }
120 128
129+func (_c *RepoReader_CommitTimes_Call) Return(times []time.Time, truncated bool, err error) *RepoReader_CommitTimes_Call {
130+ _c.Call.Return(times, truncated, err)
131+ return _c
132+}
133+
134+func (_c *RepoReader_CommitTimes_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error)) *RepoReader_CommitTimes_Call {
135+ _c.Call.Return(run)
136+ return _c
137+}
138+
139+// CountCommits provides a mock function for the type RepoReader
140+func (_mock *RepoReader) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
141+ ret := _mock.Called(ctx, repo, head)
142+
143+ if len(ret) == 0 {
144+ panic("no return value specified for CountCommits")
145+ }
146+
147+ var r0 int
148+ var r1 error
149+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) (int, error)); ok {
150+ return returnFunc(ctx, repo, head)
151+ }
152+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID) int); ok {
153+ r0 = returnFunc(ctx, repo, head)
154+ } else {
155+ r0 = ret.Get(0).(int)
156+ }
157+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID) error); ok {
158+ r1 = returnFunc(ctx, repo, head)
159+ } else {
160+ r1 = ret.Error(1)
161+ }
162+ return r0, r1
163+}
164+
165+// RepoReader_CountCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountCommits'
166+type RepoReader_CountCommits_Call struct {
167+ *mock.Call
168+}
169+
170+// CountCommits is a helper method to define mock.On call
171+// - ctx context.Context
172+// - repo domain.RepoName
173+// - head domain.ObjectID
174+func (_e *RepoReader_Expecter) CountCommits(ctx any, repo any, head any) *RepoReader_CountCommits_Call {
175+ return &RepoReader_CountCommits_Call{Call: _e.mock.On("CountCommits", ctx, repo, head)}
176+}
177+
178+func (_c *RepoReader_CountCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID)) *RepoReader_CountCommits_Call {
179+ _c.Call.Run(func(args mock.Arguments) {
180+ var arg0 context.Context
181+ if args[0] != nil {
182+ arg0 = args[0].(context.Context)
183+ }
184+ var arg1 domain.RepoName
185+ if args[1] != nil {
186+ arg1 = args[1].(domain.RepoName)
187+ }
188+ var arg2 domain.ObjectID
189+ if args[2] != nil {
190+ arg2 = args[2].(domain.ObjectID)
191+ }
192+ run(
193+ arg0,
194+ arg1,
195+ arg2,
196+ )
197+ })
198+ return _c
199+}
200+
121 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {201 func (_c *RepoReader_CountCommits_Call) Return(n int, err error) *RepoReader_CountCommits_Call {
122 _c.Call.Return(n, err)202 _c.Call.Return(n, err)
123 return _c203 return _c
124 }204 }
125 205
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 {206+func (_c *RepoReader_CountCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)) *RepoReader_CountCommits_Call {
127 _c.Call.Return(run)207 _c.Call.Return(run)
128 return _c208 return _c
129 }209 }
M internal/ports/ports.go +9 -3
@@ -118,6 +118,9 @@ type CommitQuery struct {
118118 Limit int
119119 }
120120
121+// MaxCommitTimes bounds RepoReader.CommitTimes.
122+const MaxCommitTimes = 100_000
123+
121124 // MaxCommitLimit is the most commits one CommitQuery may ask for.
122125 const MaxCommitLimit = 1000
123126
@@ -178,9 +181,12 @@ type RepoReader interface {
178181 // GetCommit returns a commit with its statistics and its diff against the
179182 // first parent, bounded by limits.
180183 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+ // CountCommits counts the commits reachable from head.
185+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
186+ // CommitTimes are the committer dates of the commits reachable from head
187+ // committed at or after since, in no particular order; at most
188+ // MaxCommitTimes, truncated tells there were more.
189+ CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
184190 // LatestTag is the most recently created tag (the commit date for a
185191 // lightweight tag); ok is false when the repository has no tag.
186192 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
@@ -118,6 +118,9 @@ type CommitQuery struct {
118 Limit int118 Limit int
119 }119 }
120 120
121+// MaxCommitTimes bounds RepoReader.CommitTimes.
122+const MaxCommitTimes = 100_000
123+
121 // MaxCommitLimit is the most commits one CommitQuery may ask for.124 // MaxCommitLimit is the most commits one CommitQuery may ask for.
122 const MaxCommitLimit = 1000125 const MaxCommitLimit = 1000
123 126
@@ -178,9 +181,12 @@ type RepoReader interface {
178 // GetCommit returns a commit with its statistics and its diff against the181 // GetCommit returns a commit with its statistics and its diff against the
179 // first parent, bounded by limits.182 // first parent, bounded by limits.
180 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)183 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-zero184+ // CountCommits counts the commits reachable from head.
182- // since, only those committed at or after it.185+ CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
183- CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (int, error)186+ // CommitTimes are the committer dates of the commits reachable from head
187+ // committed at or after since, in no particular order; at most
188+ // MaxCommitTimes, truncated tells there were more.
189+ CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
184 // LatestTag is the most recently created tag (the commit date for a190 // LatestTag is the most recently created tag (the commit date for a
185 // lightweight tag); ok is false when the repository has no tag.191 // 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)192 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
M internal/readertest/conformance.go +22 -8
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "slices"
1112 "strings"
1213 "testing"
1314 "time"
@@ -119,16 +120,29 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
119120 ids = append(ids, f.Commit("c"+itoa(i)))
120121 }
121122 head := MustID(t, ids[3])
122- n, err := g.CountCommits(ctx, f.Name, head, time.Time{})
123+ n, err := g.CountCommits(ctx, f.Name, head)
123124 require.NoError(t, err)
124125 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{})
126+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
129127 require.NoError(t, err)
130128 assert.Equal(t, 2, n, "from another commit")
131- _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})
129+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130+ assert.ErrorIs(t, err, domain.ErrNotFound)
131+
132+ unix := func(ts []time.Time) []int64 {
133+ var out []int64
134+ for _, x := range ts {
135+ out = append(out, x.Unix())
136+ }
137+ slices.Sort(out)
138+ return out
139+ }
140+ base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141+ times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142+ require.NoError(t, err)
143+ assert.False(t, more)
144+ assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145+ _, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
132146 assert.ErrorIs(t, err, domain.ErrNotFound)
133147
134148 // Dates out of order: a commit dated in the past on top of recent ones
@@ -137,9 +151,9 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
137151 f.When = recent.Add(-48 * time.Hour)
138152 old := f.Commit("backdated")
139153 f.When = recent
140- n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))
154+ times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
141155 require.NoError(t, err)
142- assert.Equal(t, 2, n, "the recent commits behind an old one count")
156+ assert.Len(t, times, 2, "the recent commits behind an old one count")
143157
144158 // The latest tag is the one made last: an annotated tag by its own date,
145159 // a lightweight one by the date of its commit.
@@ -8,6 +8,7 @@ import (
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10 "io"10 "io"
11+ "slices"
11 "strings"12 "strings"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -119,16 +120,29 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
119 ids = append(ids, f.Commit("c"+itoa(i)))120 ids = append(ids, f.Commit("c"+itoa(i)))
120 }121 }
121 head := MustID(t, ids[3])122 head := MustID(t, ids[3])
122- n, err := g.CountCommits(ctx, f.Name, head, time.Time{})123+ n, err := g.CountCommits(ctx, f.Name, head)
123 require.NoError(t, err)124 require.NoError(t, err)
124 assert.Equal(t, 4, n)125 assert.Equal(t, 4, n)
125- n, err = g.CountCommits(ctx, f.Name, head, f.When.Add(-90*time.Second))126+ n, err = g.CountCommits(ctx, f.Name, MustID(t, ids[1]))
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)127 require.NoError(t, err)
130 assert.Equal(t, 2, n, "from another commit")128 assert.Equal(t, 2, n, "from another commit")
131- _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), time.Time{})129+ _, err = g.CountCommits(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)))
130+ assert.ErrorIs(t, err, domain.ErrNotFound)
131+
132+ unix := func(ts []time.Time) []int64 {
133+ var out []int64
134+ for _, x := range ts {
135+ out = append(out, x.Unix())
136+ }
137+ slices.Sort(out)
138+ return out
139+ }
140+ base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
141+ times, more, err := g.CommitTimes(ctx, f.Name, head, base.Add(150*time.Second))
142+ require.NoError(t, err)
143+ assert.False(t, more)
144+ assert.Equal(t, []int64{base.Add(3 * time.Minute).Unix(), base.Add(4 * time.Minute).Unix()}, unix(times), "12:03 and 12:04")
145+ _, _, err = g.CommitTimes(ctx, f.Name, MustID(t, strings.Repeat("ab", 20)), base)
132 assert.ErrorIs(t, err, domain.ErrNotFound)146 assert.ErrorIs(t, err, domain.ErrNotFound)
133 147
134 // Dates out of order: a commit dated in the past on top of recent ones148 // Dates out of order: a commit dated in the past on top of recent ones
@@ -137,9 +151,9 @@ func testCountCommitsAndLatestTag(t *testing.T, mk Maker) {
137 f.When = recent.Add(-48 * time.Hour)151 f.When = recent.Add(-48 * time.Hour)
138 old := f.Commit("backdated")152 old := f.Commit("backdated")
139 f.When = recent153 f.When = recent
140- n, err = g.CountCommits(ctx, f.Name, MustID(t, old), recent.Add(-90*time.Second))154+ times, _, err = g.CommitTimes(ctx, f.Name, MustID(t, old), base.Add(150*time.Second))
141 require.NoError(t, err)155 require.NoError(t, err)
142- assert.Equal(t, 2, n, "the recent commits behind an old one count")156+ assert.Len(t, times, 2, "the recent commits behind an old one count")
143 157
144 // The latest tag is the one made last: an annotated tag by its own date,158 // 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.159 // a lightweight one by the date of its commit.
M internal/usecase/browse_stats.go +42 -6
@@ -4,6 +4,7 @@ package usecase
44
55 import (
66 "context"
7+ "slices"
78 "sync"
89 "time"
910
@@ -81,7 +82,37 @@ func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read
8182 }
8283 }
8384
85+// activity counts the commits of the last ports.RecentDays days, and those
86+// of each of the last ports.ActivityDays days, oldest first. A day is the 24
87+// hours that end at now minus a whole number of days; a commit dated after
88+// now counts in the last day.
89+func activity(times []time.Time, now time.Time) (recent int, days []int) {
90+ days = make([]int, ports.ActivityDays)
91+ recentSince := daysBefore(now, ports.RecentDays)
92+ for _, t := range times {
93+ if !t.Before(recentSince) {
94+ recent++
95+ }
96+ ago := int(now.Sub(t) / (24 * time.Hour))
97+ if ago < 0 {
98+ ago = 0
99+ }
100+ if ago < ports.ActivityDays {
101+ days[ports.ActivityDays-1-ago]++
102+ }
103+ }
104+ return recent, days
105+}
106+
107+// daysBefore is now minus n days of 24 hours: the days of activity are
108+// elapsed time, not calendar days, so that a change of daylight saving time
109+// neither drops nor doubles an hour.
110+func daysBefore(now time.Time, n int) time.Time {
111+ return now.Add(-time.Duration(n) * 24 * time.Hour)
112+}
113+
84114 func cloneStats(st ports.RepoStats) ports.RepoStats {
115+ st.Activity = slices.Clone(st.Activity)
85116 if st.LatestTag != nil {
86117 t := *st.LatestTag
87118 st.LatestTag = &t
@@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats {
89120 return st
90121 }
91122
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.
123+// RepoStats counts the commits reachable from head, all of them, those of
124+// the last ports.RecentDays days and those of each recent day, and names the
125+// latest tag. The answer is kept for statsTTL, or until the repository is
126+// pushed to: the home page and its live updates read it on every row.
95127 func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {
96128 r, err := b.authorize(ctx, p, repo)
97129 if err != nil {
@@ -103,13 +135,17 @@ func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.
103135 rctx := context.WithoutCancel(ctx)
104136 var st ports.RepoStats
105137 var err error
106- if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {
138+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
107139 return ports.RepoStats{}, err
108140 }
109- since := now.AddDate(0, 0, -ports.RecentDays)
110- if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {
141+ // One walk gives both the recent count and the activity.
142+ span := max(ports.RecentDays, ports.ActivityDays)
143+ times, truncated, err := b.reader.CommitTimes(rctx, repo, head, daysBefore(now, span))
144+ if err != nil {
111145 return ports.RepoStats{}, err
112146 }
147+ st.Recent, st.Activity = activity(times, now)
148+ st.Partial = truncated
113149 tag, ok, err := b.reader.LatestTag(rctx, repo)
114150 if err != nil {
115151 return ports.RepoStats{}, err
@@ -4,6 +4,7 @@ package usecase
4 4
5 import (5 import (
6 "context"6 "context"
7+ "slices"
7 "sync"8 "sync"
8 "time"9 "time"
9 10
@@ -81,7 +82,37 @@ func (c *statsCache) get(ctx context.Context, key statsKey, now time.Time, read
81 }82 }
82 }83 }
83 84
85+// activity counts the commits of the last ports.RecentDays days, and those
86+// of each of the last ports.ActivityDays days, oldest first. A day is the 24
87+// hours that end at now minus a whole number of days; a commit dated after
88+// now counts in the last day.
89+func activity(times []time.Time, now time.Time) (recent int, days []int) {
90+ days = make([]int, ports.ActivityDays)
91+ recentSince := daysBefore(now, ports.RecentDays)
92+ for _, t := range times {
93+ if !t.Before(recentSince) {
94+ recent++
95+ }
96+ ago := int(now.Sub(t) / (24 * time.Hour))
97+ if ago < 0 {
98+ ago = 0
99+ }
100+ if ago < ports.ActivityDays {
101+ days[ports.ActivityDays-1-ago]++
102+ }
103+ }
104+ return recent, days
105+}
106+
107+// daysBefore is now minus n days of 24 hours: the days of activity are
108+// elapsed time, not calendar days, so that a change of daylight saving time
109+// neither drops nor doubles an hour.
110+func daysBefore(now time.Time, n int) time.Time {
111+ return now.Add(-time.Duration(n) * 24 * time.Hour)
112+}
113+
84 func cloneStats(st ports.RepoStats) ports.RepoStats {114 func cloneStats(st ports.RepoStats) ports.RepoStats {
115+ st.Activity = slices.Clone(st.Activity)
85 if st.LatestTag != nil {116 if st.LatestTag != nil {
86 t := *st.LatestTag117 t := *st.LatestTag
87 st.LatestTag = &t118 st.LatestTag = &t
@@ -89,9 +120,10 @@ func cloneStats(st ports.RepoStats) ports.RepoStats {
89 return st120 return st
90 }121 }
91 122
92-// RepoStats counts the commits reachable from head, all of them and those of123+// RepoStats counts the commits reachable from head, all of them, those of
93-// the last ports.RecentDays days before now, and names the latest tag. The124+// the last ports.RecentDays days and those of each recent day, and names the
94-// answer is kept for statsTTL, or until the repository is pushed to.125+// latest tag. The answer is kept for statsTTL, or until the repository is
126+// pushed to: the home page and its live updates read it on every row.
95 func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.RepoName, head domain.ObjectID, now time.Time) (ports.RepoStats, error) {127 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)128 r, err := b.authorize(ctx, p, repo)
97 if err != nil {129 if err != nil {
@@ -103,13 +135,17 @@ func (b *Browse) RepoStats(ctx context.Context, p domain.Principal, repo domain.
103 rctx := context.WithoutCancel(ctx)135 rctx := context.WithoutCancel(ctx)
104 var st ports.RepoStats136 var st ports.RepoStats
105 var err error137 var err error
106- if st.Commits, err = b.reader.CountCommits(rctx, repo, head, time.Time{}); err != nil {138+ if st.Commits, err = b.reader.CountCommits(rctx, repo, head); err != nil {
107 return ports.RepoStats{}, err139 return ports.RepoStats{}, err
108 }140 }
109- since := now.AddDate(0, 0, -ports.RecentDays)141+ // One walk gives both the recent count and the activity.
110- if st.Recent, err = b.reader.CountCommits(rctx, repo, head, since); err != nil {142+ span := max(ports.RecentDays, ports.ActivityDays)
143+ times, truncated, err := b.reader.CommitTimes(rctx, repo, head, daysBefore(now, span))
144+ if err != nil {
111 return ports.RepoStats{}, err145 return ports.RepoStats{}, err
112 }146 }
147+ st.Recent, st.Activity = activity(times, now)
148+ st.Partial = truncated
113 tag, ok, err := b.reader.LatestTag(rctx, repo)149 tag, ok, err := b.reader.LatestTag(rctx, repo)
114 if err != nil {150 if err != nil {
115 return ports.RepoStats{}, err151 return ports.RepoStats{}, err
M internal/usecase/browse_test.go +78 -6
@@ -113,7 +113,8 @@ func browseOps() []browseOp {
113113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114114 return err
115115 }, func(r *mocks.RepoReader) {
116- r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
117118 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118119 }},
119120 }
@@ -136,13 +137,17 @@ func TestRepoStatsCache(t *testing.T) {
136137 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137138 var reads atomic.Int32
138139 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) {
140+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
141+ func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
141142 reads.Add(1)
142143 <-gate
143144 return 900, nil
144145 })
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+ recent := make([]time.Time, 12)
147+ for i := range recent {
148+ recent[i] = now.Add(-time.Duration(i) * time.Hour)
149+ }
150+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
146151 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147152 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148153
@@ -160,6 +165,8 @@ func TestRepoStatsCache(t *testing.T) {
160165 assert.Equal(t, 900, st.Commits)
161166 assert.Equal(t, 12, st.Recent)
162167 assert.Equal(t, tag, *st.LatestTag)
168+ require.Len(t, st.Activity, ports.ActivityDays)
169+ assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
163170 })
164171 }
165172 for reads.Load() == 0 {
@@ -182,6 +189,70 @@ func TestRepoStatsCache(t *testing.T) {
182189 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183190 }
184191
192+// The activity spreads the commits over the last days, oldest first, and
193+// the recent count covers 30 days.
194+func TestRepoStatsActivity(t *testing.T) {
195+ t.Parallel()
196+ ctx := context.Background()
197+ f := newBrowseFixture(t)
198+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200+ now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201+ day := 24 * time.Hour
202+ times := []time.Time{
203+ now.Add(time.Hour), // in the future: the last day
204+ now.Add(-time.Minute), // the last day
205+ now.Add(-day - 1), // the day before
206+ now.Add(-29 * day), // recent, day 29 ago
207+ now.Add(-31 * day), // not recent
208+ now.Add(-89*day - 1), // the first day of the span
209+ }
210+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214+ require.NoError(t, err)
215+ assert.Equal(t, 4, st.Recent)
216+ last := ports.ActivityDays - 1
217+ assert.Equal(t, 2, st.Activity[last])
218+ assert.Equal(t, 1, st.Activity[last-1])
219+ assert.Equal(t, 1, st.Activity[last-29])
220+ assert.Equal(t, 1, st.Activity[last-31])
221+ assert.Equal(t, 1, st.Activity[0])
222+ sum := 0
223+ for _, n := range st.Activity {
224+ sum += n
225+ }
226+ assert.Equal(t, 6, sum)
227+ assert.False(t, st.Partial)
228+}
229+
230+// The days are 24 hours whatever the zone of now: across a change of
231+// daylight saving time, the walk and the bins agree and no hour is lost.
232+func TestRepoStatsActivityAcrossDST(t *testing.T) {
233+ t.Parallel()
234+ paris, err := time.LoadLocation("Europe/Paris")
235+ require.NoError(t, err)
236+ ctx := context.Background()
237+ f := newBrowseFixture(t)
238+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240+ // 90 days of 24 hours before this noon cross the spring change (March 29).
241+ now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242+ day := 24 * time.Hour
243+ since := now.Add(-ports.ActivityDays * day)
244+ require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245+ times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250+ require.NoError(t, err)
251+ assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252+ assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253+ assert.True(t, st.Partial, "the walk was truncated")
254+}
255+
185256 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186257 t.Parallel()
187258 ctx := context.Background()
@@ -189,10 +260,11 @@ func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
189260 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190261 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191262 now := time.Now()
192- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()
263+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
193264 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194265 require.Error(t, err)
195- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()
266+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
196268 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()
197269 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198270 require.NoError(t, err)
@@ -113,7 +113,8 @@ func browseOps() []browseOp {
113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())113 _, err := b.RepoStats(ctx, p, repo, oid("a"), time.Now())
114 return err114 return err
115 }, func(r *mocks.RepoReader) {115 }, func(r *mocks.RepoReader) {
116- r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(1, nil)116+ r.EXPECT().CountCommits(mock.Anything, mock.Anything, mock.Anything).Return(1, nil)
117+ r.EXPECT().CommitTimes(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, false, nil)
117 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)118 r.EXPECT().LatestTag(mock.Anything, mock.Anything).Return(domain.TagInfo{}, false, nil)
118 }},119 }},
119 }120 }
@@ -136,13 +137,17 @@ func TestRepoStatsCache(t *testing.T) {
136 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)137 now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC)
137 var reads atomic.Int32138 var reads atomic.Int32
138 gate := make(chan struct{})139 gate := make(chan struct{})
139- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything, time.Time{}).RunAndReturn(140+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, mock.Anything).RunAndReturn(
140- func(context.Context, domain.RepoName, domain.ObjectID, time.Time) (int, error) {141+ func(context.Context, domain.RepoName, domain.ObjectID) (int, error) {
141 reads.Add(1)142 reads.Add(1)
142 <-gate143 <-gate
143 return 900, nil144 return 900, nil
144 })145 })
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+ recent := make([]time.Time, 12)
147+ for i := range recent {
148+ recent[i] = now.Add(-time.Duration(i) * time.Hour)
149+ }
150+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, mock.Anything, mock.Anything).Return(recent, false, nil)
146 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}151 tag := domain.TagInfo{Name: "v1", At: now, CommitID: oid("c")}
147 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)152 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(tag, true, nil)
148 153
@@ -160,6 +165,8 @@ func TestRepoStatsCache(t *testing.T) {
160 assert.Equal(t, 900, st.Commits)165 assert.Equal(t, 900, st.Commits)
161 assert.Equal(t, 12, st.Recent)166 assert.Equal(t, 12, st.Recent)
162 assert.Equal(t, tag, *st.LatestTag)167 assert.Equal(t, tag, *st.LatestTag)
168+ require.Len(t, st.Activity, ports.ActivityDays)
169+ assert.Equal(t, 12, st.Activity[ports.ActivityDays-1], "all in the last day")
163 })170 })
164 }171 }
165 for reads.Load() == 0 {172 for reads.Load() == 0 {
@@ -182,6 +189,70 @@ func TestRepoStatsCache(t *testing.T) {
182 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")189 assert.EqualValues(t, 4, reads.Load(), "a push forgets the figures")
183 }190 }
184 191
192+// The activity spreads the commits over the last days, oldest first, and
193+// the recent count covers 30 days.
194+func TestRepoStatsActivity(t *testing.T) {
195+ t.Parallel()
196+ ctx := context.Background()
197+ f := newBrowseFixture(t)
198+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
199+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
200+ now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
201+ day := 24 * time.Hour
202+ times := []time.Time{
203+ now.Add(time.Hour), // in the future: the last day
204+ now.Add(-time.Minute), // the last day
205+ now.Add(-day - 1), // the day before
206+ now.Add(-29 * day), // recent, day 29 ago
207+ now.Add(-31 * day), // not recent
208+ now.Add(-89*day - 1), // the first day of the span
209+ }
210+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(100, nil)
211+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), now.Add(-ports.ActivityDays*day)).Return(times, false, nil)
212+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
213+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
214+ require.NoError(t, err)
215+ assert.Equal(t, 4, st.Recent)
216+ last := ports.ActivityDays - 1
217+ assert.Equal(t, 2, st.Activity[last])
218+ assert.Equal(t, 1, st.Activity[last-1])
219+ assert.Equal(t, 1, st.Activity[last-29])
220+ assert.Equal(t, 1, st.Activity[last-31])
221+ assert.Equal(t, 1, st.Activity[0])
222+ sum := 0
223+ for _, n := range st.Activity {
224+ sum += n
225+ }
226+ assert.Equal(t, 6, sum)
227+ assert.False(t, st.Partial)
228+}
229+
230+// The days are 24 hours whatever the zone of now: across a change of
231+// daylight saving time, the walk and the bins agree and no hour is lost.
232+func TestRepoStatsActivityAcrossDST(t *testing.T) {
233+ t.Parallel()
234+ paris, err := time.LoadLocation("Europe/Paris")
235+ require.NoError(t, err)
236+ ctx := context.Background()
237+ f := newBrowseFixture(t)
238+ repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
239+ f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
240+ // 90 days of 24 hours before this noon cross the spring change (March 29).
241+ now := time.Date(2026, 4, 1, 12, 0, 0, 0, paris)
242+ day := 24 * time.Hour
243+ since := now.Add(-ports.ActivityDays * day)
244+ require.NotEqual(t, now.AddDate(0, 0, -ports.ActivityDays), since, "the calendar would differ by an hour")
245+ times := []time.Time{since.Add(time.Minute), now.Add(-30*day + time.Minute)}
246+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), since).Return(times, true, nil)
247+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(200_000, nil)
248+ f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil)
249+ st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
250+ require.NoError(t, err)
251+ assert.Equal(t, 1, st.Activity[0], "the start of the span is in the first day")
252+ assert.Equal(t, 1, st.Recent, "30 days of 24 hours")
253+ assert.True(t, st.Partial, "the walk was truncated")
254+}
255+
185 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {256 func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
186 t.Parallel()257 t.Parallel()
187 ctx := context.Background()258 ctx := context.Background()
@@ -189,10 +260,11 @@ func TestRepoStatsDoesNotKeepAFailure(t *testing.T) {
189 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}260 repo := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
190 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)261 f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil)
191 now := time.Now()262 now := time.Now()
192- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), time.Time{}).Return(0, errors.New("boom")).Once()263+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(0, errors.New("boom")).Once()
193 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)264 _, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
194 require.Error(t, err)265 require.Error(t, err)
195- f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a"), mock.Anything).Return(3, nil).Twice()266+ f.reader.EXPECT().CountCommits(mock.Anything, repo.Name, oid("a")).Return(3, nil).Once()
267+ f.reader.EXPECT().CommitTimes(mock.Anything, repo.Name, oid("a"), mock.Anything).Return([]time.Time{now}, false, nil).Once()
196 f.reader.EXPECT().LatestTag(mock.Anything, repo.Name).Return(domain.TagInfo{}, false, nil).Once()268 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)269 st, err := f.browse.RepoStats(ctx, domain.Anonymous(), repo.Name, oid("a"), now)
198 require.NoError(t, err)270 require.NoError(t, err)
M internal/usecase/repos.go +5 -0
@@ -61,6 +61,10 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
6161 if err := validateDescription(params.Description); err != nil {
6262 return domain.Repo{}, err
6363 }
64+ topics, err := domain.NormalizeTopics(params.Topics)
65+ if err != nil {
66+ return domain.Repo{}, err
67+ }
6468 vis := params.Visibility
6569 if vis == "" {
6670 vis = domain.VisibilityPrivate
@@ -71,6 +75,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
7175 repo := domain.Repo{
7276 Name: params.Name,
7377 Description: params.Description,
78+ Topics: topics,
7479 Visibility: vis,
7580 CreatedAt: r.clock.Now().UTC(),
7681 Settings: domain.DefaultRepoSettings(),
@@ -61,6 +61,10 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
61 if err := validateDescription(params.Description); err != nil {61 if err := validateDescription(params.Description); err != nil {
62 return domain.Repo{}, err62 return domain.Repo{}, err
63 }63 }
64+ topics, err := domain.NormalizeTopics(params.Topics)
65+ if err != nil {
66+ return domain.Repo{}, err
67+ }
64 vis := params.Visibility68 vis := params.Visibility
65 if vis == "" {69 if vis == "" {
66 vis = domain.VisibilityPrivate70 vis = domain.VisibilityPrivate
@@ -71,6 +75,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
71 repo := domain.Repo{75 repo := domain.Repo{
72 Name: params.Name,76 Name: params.Name,
73 Description: params.Description,77 Description: params.Description,
78+ Topics: topics,
74 Visibility: vis,79 Visibility: vis,
75 CreatedAt: r.clock.Now().UTC(),80 CreatedAt: r.clock.Now().UTC(),
76 Settings: domain.DefaultRepoSettings(),81 Settings: domain.DefaultRepoSettings(),
M internal/usecase/repos_test.go +22 -0
@@ -68,6 +68,28 @@ func TestCreate(t *testing.T) {
6868 assert.Equal(t, want, got)
6969 })
7070
71+ t.Run("topics are normalized and stored", func(t *testing.T) {
72+ t.Parallel()
73+ f := newFixture(t)
74+ n := name(t, "tools")
75+ want := domain.Repo{Name: n, Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
76+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
77+ f.meta.EXPECT().Create(ctx, want).Return(nil)
78+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
79+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
80+
81+ got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Topics: []string{"Go", "cli", "go"}})
82+ require.NoError(t, err)
83+ assert.Equal(t, want, got)
84+ })
85+
86+ t.Run("an invalid topic is refused before anything runs", func(t *testing.T) {
87+ t.Parallel()
88+ f := newFixture(t)
89+ _, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Topics: []string{"bad topic"}})
90+ assert.ErrorIs(t, err, domain.ErrInvalid)
91+ })
92+
7193 t.Run("anonymous is refused before anything runs", func(t *testing.T) {
7294 t.Parallel()
7395 f := newFixture(t)
@@ -68,6 +68,28 @@ func TestCreate(t *testing.T) {
68 assert.Equal(t, want, got)68 assert.Equal(t, want, got)
69 })69 })
70 70
71+ t.Run("topics are normalized and stored", func(t *testing.T) {
72+ t.Parallel()
73+ f := newFixture(t)
74+ n := name(t, "tools")
75+ want := domain.Repo{Name: n, Topics: []string{"cli", "go"}, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
76+ f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
77+ f.meta.EXPECT().Create(ctx, want).Return(nil)
78+ f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
79+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
80+
81+ got, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: n, Topics: []string{"Go", "cli", "go"}})
82+ require.NoError(t, err)
83+ assert.Equal(t, want, got)
84+ })
85+
86+ t.Run("an invalid topic is refused before anything runs", func(t *testing.T) {
87+ t.Parallel()
88+ f := newFixture(t)
89+ _, err := f.repos.Create(ctx, domain.Root(), ports.CreateRepoParams{Name: name(t, "tools"), Topics: []string{"bad topic"}})
90+ assert.ErrorIs(t, err, domain.ErrInvalid)
91+ })
92+
71 t.Run("anonymous is refused before anything runs", func(t *testing.T) {93 t.Run("anonymous is refused before anything runs", func(t *testing.T) {
72 t.Parallel()94 t.Parallel()
73 f := newFixture(t)95 f := newFixture(t)
M internal/usecase/repos_write.go +14 -3
@@ -44,7 +44,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
4444 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil
4545 }
4646
47-// Update changes the description, the visibility, the default branch and the
47+// Update changes the description, the topics, the visibility, the default branch and the
4848 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
4949 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
5050 // else domain.ErrInvalid. A default branch that does not exist gives
@@ -61,7 +61,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
6161 return domain.Repo{}, err
6262 }
6363 before := repo
64- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&
64+ if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
6565 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
6666 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
6767 }
@@ -77,6 +77,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
7777 return domain.Repo{}, err
7878 }
7979 }
80+ if params.Topics != nil {
81+ topics, err := domain.NormalizeTopics(*params.Topics)
82+ if err != nil {
83+ return domain.Repo{}, err
84+ }
85+ params.Topics = &topics
86+ }
8087 if params.Visibility != nil {
8188 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
8289 return domain.Repo{}, err
@@ -102,7 +109,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
102109 // branch was checked above, so the remaining causes are a vanished
103110 // repository or an I/O failure.
104111 metaDone := false
105- if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
112+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
106113 if repo, err = r.updateMeta(ctx, name, params); err != nil {
107114 return domain.Repo{}, err
108115 }
@@ -122,6 +129,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
122129 log.Info().
123130 Str("repo", name.String()).
124131 Bool("description", params.Description != nil).
132+ Bool("topics", params.Topics != nil).
125133 Bool("visibility", params.Visibility != nil).
126134 Bool("default_branch", params.DefaultBranch != nil).
127135 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
@@ -148,6 +156,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
148156 if params.Description != nil {
149157 repo.Description = *params.Description
150158 }
159+ if params.Topics != nil {
160+ repo.Topics = *params.Topics
161+ }
151162 if params.Visibility != nil {
152163 repo.Visibility = *params.Visibility
153164 }
@@ -44,7 +44,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
44 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil44 return ports.RepoView{Repo: repo, DefaultBranch: branch}, nil
45 }45 }
46 46
47-// Update changes the description, the visibility, the default branch and the47+// Update changes the description, the topics, the visibility, the default branch and the
48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,49 // (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
50 // else domain.ErrInvalid. A default branch that does not exist gives50 // else domain.ErrInvalid. A default branch that does not exist gives
@@ -61,7 +61,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
61 return domain.Repo{}, err61 return domain.Repo{}, err
62 }62 }
63 before := repo63 before := repo
64- if params.Description == nil && params.Visibility == nil && params.DefaultBranch == nil &&64+ if params.Description == nil && params.Topics == nil && params.Visibility == nil && params.DefaultBranch == nil &&
65 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {65 params.AllowedMergeModes == nil && params.FailingChecksBlock == nil {
66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)66 return domain.Repo{}, fmt.Errorf("%w: nothing to update", domain.ErrInvalid)
67 }67 }
@@ -77,6 +77,13 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
77 return domain.Repo{}, err77 return domain.Repo{}, err
78 }78 }
79 }79 }
80+ if params.Topics != nil {
81+ topics, err := domain.NormalizeTopics(*params.Topics)
82+ if err != nil {
83+ return domain.Repo{}, err
84+ }
85+ params.Topics = &topics
86+ }
80 if params.Visibility != nil {87 if params.Visibility != nil {
81 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {88 if _, err := domain.ParseVisibility(params.Visibility.String()); err != nil {
82 return domain.Repo{}, err89 return domain.Repo{}, err
@@ -102,7 +109,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
102 // branch was checked above, so the remaining causes are a vanished109 // branch was checked above, so the remaining causes are a vanished
103 // repository or an I/O failure.110 // repository or an I/O failure.
104 metaDone := false111 metaDone := false
105- if params.Description != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {112+ if params.Description != nil || params.Topics != nil || params.Visibility != nil || params.AllowedMergeModes != nil || params.FailingChecksBlock != nil {
106 if repo, err = r.updateMeta(ctx, name, params); err != nil {113 if repo, err = r.updateMeta(ctx, name, params); err != nil {
107 return domain.Repo{}, err114 return domain.Repo{}, err
108 }115 }
@@ -122,6 +129,7 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
122 log.Info().129 log.Info().
123 Str("repo", name.String()).130 Str("repo", name.String()).
124 Bool("description", params.Description != nil).131 Bool("description", params.Description != nil).
132+ Bool("topics", params.Topics != nil).
125 Bool("visibility", params.Visibility != nil).133 Bool("visibility", params.Visibility != nil).
126 Bool("default_branch", params.DefaultBranch != nil).134 Bool("default_branch", params.DefaultBranch != nil).
127 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).135 Bool("settings", params.AllowedMergeModes != nil || params.FailingChecksBlock != nil).
@@ -148,6 +156,9 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
148 if params.Description != nil {156 if params.Description != nil {
149 repo.Description = *params.Description157 repo.Description = *params.Description
150 }158 }
159+ if params.Topics != nil {
160+ repo.Topics = *params.Topics
161+ }
151 if params.Visibility != nil {162 if params.Visibility != nil {
152 repo.Visibility = *params.Visibility163 repo.Visibility = *params.Visibility
153 }164 }
M internal/usecase/repos_write_test.go +40 -0
@@ -44,6 +44,39 @@ func TestUpdate(t *testing.T) {
4444 assert.Equal(t, want, got)
4545 })
4646
47+ t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48+ t.Parallel()
49+ f := newFixture(t)
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
51+ want := priv
52+ want.Topics = []string{"cli", "go"}
53+ want.Settings = domain.DefaultRepoSettings()
54+ f.meta.EXPECT().Update(ctx, want).Return(nil)
55+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57+ }).Return().Once()
58+
59+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
60+ require.NoError(t, err)
61+ assert.Equal(t, want, got)
62+ })
63+
64+ t.Run("an empty list clears the topics", func(t *testing.T) {
65+ t.Parallel()
66+ f := newFixture(t)
67+ withTopics := priv
68+ withTopics.Topics = []string{"go"}
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
70+ want := priv
71+ want.Settings = domain.DefaultRepoSettings()
72+ f.meta.EXPECT().Update(ctx, want).Return(nil)
73+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74+
75+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics)
78+ })
79+
4780 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
4881 t.Parallel()
4982 f := newFixture(t)
@@ -124,12 +157,19 @@ func TestUpdate(t *testing.T) {
124157 })
125158
126159 t.Run("invalid input changes nothing", func(t *testing.T) {
160+ tooMany := make([]string, 0, domain.MaxTopics+1)
161+ for i := range domain.MaxTopics + 1 {
162+ tooMany = append(tooMany, fmt.Sprintf("t%d", i))
163+ }
127164 for name, params := range map[string]ports.UpdateRepoParams{
128165 "empty": {},
129166 "empty branch": {DefaultBranch: ptr("")},
130167 "multi-line": {Description: ptr("a\nb")},
131168 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132169 "bad utf-8": {Description: ptr("\xff")},
170+ "bad topic": {Topics: &[]string{"Bad_Topic"}},
171+ "empty topic": {Topics: &[]string{""}},
172+ "too many topics": {Topics: &tooMany},
133173 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134174 } {
135175 f := newFixture(t)
@@ -44,6 +44,39 @@ func TestUpdate(t *testing.T) {
44 assert.Equal(t, want, got)44 assert.Equal(t, want, got)
45 })45 })
46 46
47+ t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
48+ t.Parallel()
49+ f := newFixture(t)
50+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
51+ want := priv
52+ want.Topics = []string{"cli", "go"}
53+ want.Settings = domain.DefaultRepoSettings()
54+ f.meta.EXPECT().Update(ctx, want).Return(nil)
55+ f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
56+ Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
57+ }).Return().Once()
58+
59+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
60+ require.NoError(t, err)
61+ assert.Equal(t, want, got)
62+ })
63+
64+ t.Run("an empty list clears the topics", func(t *testing.T) {
65+ t.Parallel()
66+ f := newFixture(t)
67+ withTopics := priv
68+ withTopics.Topics = []string{"go"}
69+ f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Twice()
70+ want := priv
71+ want.Settings = domain.DefaultRepoSettings()
72+ f.meta.EXPECT().Update(ctx, want).Return(nil)
73+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
74+
75+ got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
76+ require.NoError(t, err)
77+ assert.Nil(t, got.Topics)
78+ })
79+
47 t.Run("a lost race on the metadata is retried", func(t *testing.T) {80 t.Run("a lost race on the metadata is retried", func(t *testing.T) {
48 t.Parallel()81 t.Parallel()
49 f := newFixture(t)82 f := newFixture(t)
@@ -124,12 +157,19 @@ func TestUpdate(t *testing.T) {
124 })157 })
125 158
126 t.Run("invalid input changes nothing", func(t *testing.T) {159 t.Run("invalid input changes nothing", func(t *testing.T) {
160+ tooMany := make([]string, 0, domain.MaxTopics+1)
161+ for i := range domain.MaxTopics + 1 {
162+ tooMany = append(tooMany, fmt.Sprintf("t%d", i))
163+ }
127 for name, params := range map[string]ports.UpdateRepoParams{164 for name, params := range map[string]ports.UpdateRepoParams{
128 "empty": {},165 "empty": {},
129 "empty branch": {DefaultBranch: ptr("")},166 "empty branch": {DefaultBranch: ptr("")},
130 "multi-line": {Description: ptr("a\nb")},167 "multi-line": {Description: ptr("a\nb")},
131 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},168 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
132 "bad utf-8": {Description: ptr("\xff")},169 "bad utf-8": {Description: ptr("\xff")},
170+ "bad topic": {Topics: &[]string{"Bad_Topic"}},
171+ "empty topic": {Topics: &[]string{""}},
172+ "too many topics": {Topics: &tooMany},
133 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},173 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
134 } {174 } {
135 f := newFixture(t)175 f := newFixture(t)
M proto/ermite/storage/repo.proto +2 -0
@@ -30,6 +30,8 @@ message Repo {
3030 // Unset on repositories stored before merge requests: they read as the
3131 // default settings.
3232 RepoSettings settings = 6;
33+ // Normalized and sorted; empty on repositories stored before topics.
34+ repeated string topics = 7;
3335 }
3436
3537 // SchemaVersion is stored under the key "meta/schema".
@@ -30,6 +30,8 @@ message Repo {
30 // Unset on repositories stored before merge requests: they read as the30 // Unset on repositories stored before merge requests: they read as the
31 // default settings.31 // default settings.
32 RepoSettings settings = 6;32 RepoSettings settings = 6;
33+ // Normalized and sorted; empty on repositories stored before topics.
34+ repeated string topics = 7;
33 }35 }
34 36
35 // SchemaVersion is stored under the key "meta/schema".37 // SchemaVersion is stored under the key "meta/schema".

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