Commit 43e45ac3a9

43e45ac3a9fcad53b68393a9c632604f883f2a66

parent: ba9c084ef9

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-10-04 01:17 UTC

web: explore by latest activity, paged; slim profile repo rows

/explore orders public repositories by their most recent event, builds
excluded, and lists 20 per page. The profile's repositories tab uses
the one-line rows explore uses. The footer drops the commit hash.

Closes #326

Layout: unified · split

e2e/profile_test.go +2 −2
@@ -214,10 +214,10 @@ func TestOwnerProfiles(t *testing.T) {
214214 if !strings.Contains(body, `class="activity"`) {
215215 t.Error("the about tab has no activity graph")
216216 }
217 if strings.Contains(body, `<ul class="repolist"`) {
217 if strings.Contains(body, `<ul class="repolist`) {
218218 t.Error("the repository list still sits under the about text")
219219 }
220 if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist"`) {
220 if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist rows">`) {
221221 t.Error("the repositories tab has no repository list")
222222 }
223223
internal/httpd/explorepage_test.go +45 −8
@@ -1,6 +1,7 @@
11package httpd
22
33import (
4 "fmt"
45 "strings"
56 "testing"
67
@@ -40,17 +41,53 @@ func TestExplorePageSummarisesTheFilter(t *testing.T) {
4041}
4142
4243func renderExplore(t *testing.T, q string, repos []describedRepo) string {
44 t.Helper()
45 p := explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Query: q}
46 p.Repos, p.Total, p.Page, p.Pages = pageOf(repos, "")
47 return renderExplorePage(t, p)
48}
49
50func renderExplorePage(t *testing.T, p explorePage) string {
4351 t.Helper()
4452 var sb strings.Builder
45 err := web.Render(&sb, "explore.html", struct {
46 basePage
47 Tab string
48 Query string
49 Facets []facetGroup
50 Repos []describedRepo
51 }{basePage{Site: "gitbay"}, "explore", q, nil, repos})
52 if err != nil {
53 if err := web.Render(&sb, "explore.html", p); err != nil {
5354 t.Fatalf("render: %v", err)
5455 }
5556 return sb.String()
5657}
58
59func TestExplorePages(t *testing.T) {
60 var repos []describedRepo
61 for i := range 45 {
62 repos = append(repos, describedRepo{Repo: store.Repo{OwnerName: "krz", Name: fmt.Sprintf("r%02d", i)}})
63 }
64 for _, tc := range []struct {
65 in string
66 page, len int
67 first string
68 }{
69 {"", 1, 20, "r00"}, {"2", 2, 20, "r20"}, {"3", 3, 5, "r40"},
70 {"9", 3, 5, "r40"}, {"0", 1, 20, "r00"}, {"x", 1, 20, "r00"},
71 } {
72 got, total, page, pages := pageOf(repos, tc.in)
73 if total != 45 || pages != 3 || page != tc.page || len(got) != tc.len || got[0].Name != tc.first {
74 t.Errorf("page %q: total %d, page %d of %d, %d rows from %s", tc.in, total, page, pages, len(got), got[0].Name)
75 }
76 }
77 if got, _, page, pages := pageOf(nil, "2"); len(got) != 0 || page != 1 || pages != 1 {
78 t.Errorf("empty listing: %d rows, page %d of %d", len(got), page, pages)
79 }
80
81 p := explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Query: "go"}
82 p.Repos, p.Total, p.Page, p.Pages = pageOf(repos, "2")
83 p.Prev, p.Next = explorePageURL("go", 1), explorePageURL("go", 3)
84 out := renderExplorePage(t, p)
85 for _, want := range []string{"45 repositories", "page 2 of 3", `href="/explore?q=go"`, `href="/explore?page=3&amp;q=go"`} {
86 if !strings.Contains(out, want) {
87 t.Errorf("explore.html missing %q:\n%s", want, out)
88 }
89 }
90 if out := renderExplore(t, "", repos[:3]); strings.Contains(out, `class="pager"`) {
91 t.Errorf("a single page has a pager:\n%s", out)
92 }
93}
internal/httpd/railanon_test.go +1 −7
@@ -14,13 +14,7 @@ import (
1414// signed-out phone had no route to the listing from any page (#248).
1515func TestSignedOutRailDropsNothing(t *testing.T) {
1616 var sb strings.Builder
17 err := web.Render(&sb, "explore.html", struct {
18 basePage
19 Tab string
20 Query string
21 Facets []facetGroup
22 Repos []describedRepo
23 }{basePage{Site: "gitbay"}, "explore", "", nil, nil})
17 err := web.Render(&sb, "explore.html", explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Pages: 1})
2418 if err != nil {
2519 t.Fatalf("render: %v", err)
2620 }
internal/httpd/web.go +53 −8
@@ -223,8 +223,26 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
223223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
224224}
225225
226// explorePageSize is how many repositories one page of /explore lists.
227const explorePageSize = 20
228
229// explorePage is /explore: one page of the filtered listing, most recent
230// activity first. Total counts the whole filtered listing.
231type explorePage struct {
232 basePage
233 Tab string
234 Query string
235 Facets []facetGroup
236 Repos []describedRepo
237 Total int
238 Page int
239 Pages int
240 Prev string
241 Next string
242}
243
226244func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
227 repos, err := s.st.ListPublicRepos()
245 repos, err := s.st.ListPublicReposByActivity()
228246 if err != nil {
229247 http.Error(w, "internal error", http.StatusInternalServerError)
230248 return
@@ -235,13 +253,40 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
235253 }
236254 q := strings.TrimSpace(r.URL.Query().Get("q"))
237255 described := s.describeAll(repos)
238 s.render(w, "explore.html", struct {
239 basePage
240 Tab string
241 Query string
242 Facets []facetGroup
243 Repos []describedRepo
244 }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)})
256 p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q,
257 Facets: []facetGroup{topicFacets(described, q)}}
258 p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page"))
259 if p.Page > 1 {
260 p.Prev = explorePageURL(q, p.Page-1)
261 }
262 if p.Page < p.Pages {
263 p.Next = explorePageURL(q, p.Page+1)
264 }
265 s.render(w, "explore.html", p)
266}
267
268// pageOf returns page n (1-based, clamped) of repos at explorePageSize.
269func pageOf(repos []describedRepo, n string) (page []describedRepo, total, num, pages int) {
270 total = len(repos)
271 pages = max(1, (total+explorePageSize-1)/explorePageSize)
272 num, _ = strconv.Atoi(n)
273 num = min(max(num, 1), pages)
274 lo := (num - 1) * explorePageSize
275 return repos[lo:min(lo+explorePageSize, total)], total, num, pages
276}
277
278func explorePageURL(q string, page int) string {
279 v := url.Values{}
280 if q != "" {
281 v.Set("q", q)
282 }
283 if page > 1 {
284 v.Set("page", strconv.Itoa(page))
285 }
286 if len(v) == 0 {
287 return "/explore"
288 }
289 return "/explore?" + v.Encode()
245290}
246291
247292// privacy renders the privacy page: what the gitbay software does with
internal/store/explore_test.go added +51
@@ -0,0 +1,51 @@
1package store
2
3import "testing"
4
5func TestListPublicReposByActivity(t *testing.T) {
6 s := open(t)
7 if err := s.MigrateUp(); err != nil {
8 t.Fatal(err)
9 }
10 uid, err := s.CreateUser("cmc", true)
11 if err != nil {
12 t.Fatal(err)
13 }
14 ids := map[string]int64{}
15 for _, name := range []string{"aa", "bb", "cc", "dd", "secret"} {
16 vis := "public"
17 if name == "secret" {
18 vis = "private"
19 }
20 if ids[name], err = s.CreateRepo("user", uid, name, vis); err != nil {
21 t.Fatal(err)
22 }
23 }
24 for _, ev := range []struct{ repo, kind string }{
25 {"aa", "push"}, {"cc", "issue.created"}, {"bb", "push"}, {"aa", "build.finished"}, {"secret", "push"},
26 } {
27 if err := s.RecordEvent(ids[ev.repo], uid, ev.kind, "{}"); err != nil {
28 t.Fatal(err)
29 }
30 }
31
32 repos, err := s.ListPublicReposByActivity()
33 if err != nil {
34 t.Fatal(err)
35 }
36 var got []string
37 for _, r := range repos {
38 got = append(got, r.Name)
39 }
40 // bb was touched last; a build on aa is not activity; dd has none
41 // and comes after every repo that does.
42 want := []string{"bb", "cc", "aa", "dd"}
43 if len(got) != len(want) {
44 t.Fatalf("got %v, want %v", got, want)
45 }
46 for i := range want {
47 if got[i] != want[i] {
48 t.Fatalf("got %v, want %v", got, want)
49 }
50 }
51}
internal/store/repos.go +23
@@ -460,6 +460,29 @@ func (s *Store) ListPublicRepos() ([]Repo, error) {
460460 return out, rows.Err()
461461}
462462
463// ListPublicReposByActivity returns all public repositories, the most
464// recent event first. Builds are not activity: a nightly job would keep
465// its repository on top. A repository with no events sorts last, newest
466// first.
467func (s *Store) ListPublicReposByActivity() ([]Repo, error) {
468 rows, err := s.DB.Query(repoSelect + ` WHERE r.visibility = 'public'
469 ORDER BY COALESCE((SELECT MAX(e.id) FROM events e
470 WHERE e.repo_id = r.id AND e.kind NOT LIKE 'build.%'), 0) DESC, r.id DESC`)
471 if err != nil {
472 return nil, err
473 }
474 defer rows.Close()
475 var out []Repo
476 for rows.Next() {
477 r, err := scanRepo(rows)
478 if err != nil {
479 return nil, err
480 }
481 out = append(out, r)
482 }
483 return out, rows.Err()
484}
485
463486// ListForks returns the repositories forked from one repo. The caller
464487// filters by what the viewer may see.
465488func (s *Store) ListForks(repoID int64) ([]Repo, error) {
internal/web/templates/explore.html +2 −1
@@ -12,11 +12,12 @@
1212</form>
1313<span class="spacer"></span>
1414</div>
15<p class="meta">{{len .Repos}} repositor{{if eq (len .Repos) 1}}y{{else}}ies{{end}}{{if .Query}} matching <strong>{{.Query}}</strong>, <a href="/explore">clear filter</a>{{end}}</p>
15<p class="meta">{{.Total}} repositor{{if eq .Total 1}}y{{else}}ies{{end}}{{if .Query}} matching <strong>{{.Query}}</strong>, <a href="/explore">clear filter</a>{{end}}</p>
1616<ul class="repolist rows">
1717{{range .Repos}}{{template "reporow" .}}
1818{{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}}
1919</ul>
20{{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{with .Prev}} · <a href="{{.}}">← newer</a>{{end}}{{with .Next}} · <a href="{{.}}">older →</a>{{end}}</p>{{end}}
2021</div>
2122</div>
2223{{end}}
internal/web/templates/layout.html +1 −1
@@ -88,7 +88,7 @@
8888</main>
8989
9090<footer>
91 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="https://apps.apple.com/us/app/gitbay/id6806399095">iOS app</a> · <a href="/privacy">Privacy</a>{{with field . "TermsURL"}} · <a href="{{.}}">Terms</a>{{end}}{{with field . "AbuseURL"}} · <a href="{{.}}">Report abuse</a>{{end}}</p>
91 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a> · <a href="https://apps.apple.com/us/app/gitbay/id6806399095">iOS app</a> · <a href="/privacy">Privacy</a>{{with field . "TermsURL"}} · <a href="{{.}}">Terms</a>{{end}}{{with field . "AbuseURL"}} · <a href="{{.}}">Report abuse</a>{{end}}</p>
9292</footer>
9393</div>
9494</div>
internal/web/templates/owner.html +1 −1
@@ -46,7 +46,7 @@
4646{{end}}
4747
4848{{if eq .Tab "repos"}}
49<ul class="repolist">
49<ul class="repolist rows">
5050{{range .Repos}}{{template "reporow" .}}
5151{{else}}<li class="empty">no visible repositories</li>{{end}}
5252</ul>
internal/web/web.go +2 −36
@@ -9,11 +9,9 @@ import (
99 "io"
1010 "io/fs"
1111 "reflect"
12 "runtime/debug"
1312 "sort"
1413 "strconv"
1514 "strings"
16 "sync"
1715 "time"
1816)
1917
@@ -32,36 +30,6 @@ var FontFS embed.FS
3230//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4
3331var ImageFS embed.FS
3432
35// version returns the short VCS revision baked into the binary, or "" when
36// built outside a checkout. Used by the layout footer.
37var version = sync.OnceValue(func() string {
38 info, ok := debug.ReadBuildInfo()
39 if !ok {
40 return ""
41 }
42 for _, s := range info.Settings {
43 if s.Key == "vcs.revision" && len(s.Value) >= 10 {
44 return s.Value[:10]
45 }
46 }
47 return ""
48})
49
50// fullVersion is the complete VCS revision, for linking the footer hash
51// to the upstream commit page.
52var fullVersion = sync.OnceValue(func() string {
53 info, ok := debug.ReadBuildInfo()
54 if !ok {
55 return ""
56 }
57 for _, s := range info.Settings {
58 if s.Key == "vcs.revision" {
59 return s.Value
60 }
61 }
62 return ""
63})
64
6533// StyleVersion identifies the bytes /static/style.css serves. The httpd
6634// package sets it; the layout stamps it on the stylesheet URL so a deploy
6735// changes that URL and a browser holding a cached copy cannot miss it.
@@ -127,10 +95,8 @@ func railOptItems(v any) []railItem {
12795}
12896
12997var funcs = template.FuncMap{
130 "gitbayVersion": func() string { return version() },
131 "gitbayCommit": func() string { return fullVersion() },
132 "styleVersion": func() string { return StyleVersion },
133 "join": strings.Join,
98 "styleVersion": func() string { return StyleVersion },
99 "join": strings.Join,
134100 // paragraphs splits plain text on blank lines for safe rich display.
135101 "paragraphs": func(s string) []string {
136102 var out []string
internal/web/widths_test.go +4 −2
@@ -31,8 +31,10 @@ func TestListPagesAreWide(t *testing.T) {
3131 t.Errorf("%s does not use one-line rows", name)
3232 }
3333 }
34 if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) {
35 t.Error("explore.html does not use one-line rows")
34 for _, name := range []string{"explore.html", "owner.html"} {
35 if src, _ := templateFS.ReadFile("templates/" + name); !strings.Contains(string(src), `<ul class="repolist rows">`) {
36 t.Errorf("%s does not use one-line repository rows", name)
37 }
3638 }
3739 // Settings pages carry a section column: every section id has a link
3840 // in the column, and the page is wide with the narrow grid.