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

merged merged by cmc on 2026-10-04 02:26 UTC · krz/gitbay:explore-activity into main

11 files changed, +185 −66

Layout: unified · split

e2e/profile_test.go +2 −2
@@ -214,10 +214,10 @@ func TestOwnerProfiles(t *testing.T) {
214 if !strings.Contains(body, `class="activity"`) { 214 if !strings.Contains(body, `class="activity"`) {
215 t.Error("the about tab has no activity graph") 215 t.Error("the about tab has no activity graph")
216 } 216 }
217 if strings.Contains(body, `<ul class="repolist"`) { 217 if strings.Contains(body, `<ul class="repolist`) {
218 t.Error("the repository list still sits under the about text") 218 t.Error("the repository list still sits under the about text")
219 } 219 }
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">`) {
221 t.Error("the repositories tab has no repository list") 221 t.Error("the repositories tab has no repository list")
222 } 222 }
223 223
internal/httpd/explorepage_test.go +45 −8
@@ -1,6 +1,7 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "fmt"
4 "strings" 5 "strings"
5 "testing" 6 "testing"
6 7
@@ -40,17 +41,53 @@ func TestExplorePageSummarisesTheFilter(t *testing.T) {
40} 41}
41 42
42func renderExplore(t *testing.T, q string, repos []describedRepo) string { 43func 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 {
43 t.Helper() 51 t.Helper()
44 var sb strings.Builder 52 var sb strings.Builder
45 err := web.Render(&sb, "explore.html", struct { 53 if err := web.Render(&sb, "explore.html", p); err != nil {
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 t.Fatalf("render: %v", err) 54 t.Fatalf("render: %v", err)
54 } 55 }
55 return sb.String() 56 return sb.String()
56} 57}
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 (
14// signed-out phone had no route to the listing from any page (#248). 14// signed-out phone had no route to the listing from any page (#248).
15func TestSignedOutRailDropsNothing(t *testing.T) { 15func TestSignedOutRailDropsNothing(t *testing.T) {
16 var sb strings.Builder 16 var sb strings.Builder
17 err := web.Render(&sb, "explore.html", struct { 17 err := web.Render(&sb, "explore.html", explorePage{basePage: basePage{Site: "gitbay"}, Tab: "explore", Pages: 1})
18 basePage
19 Tab string
20 Query string
21 Facets []facetGroup
22 Repos []describedRepo
23 }{basePage{Site: "gitbay"}, "explore", "", nil, nil})
24 if err != nil { 18 if err != nil {
25 t.Fatalf("render: %v", err) 19 t.Fatalf("render: %v", err)
26 } 20 }
internal/httpd/web.go +53 −8
@@ -223,8 +223,26 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)}) 223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
224} 224}
225 225
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
226func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 244func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
227 repos, err := s.st.ListPublicRepos() 245 repos, err := s.st.ListPublicReposByActivity()
228 if err != nil { 246 if err != nil {
229 http.Error(w, "internal error", http.StatusInternalServerError) 247 http.Error(w, "internal error", http.StatusInternalServerError)
230 return 248 return
@@ -235,13 +253,40 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
235 } 253 }
236 q := strings.TrimSpace(r.URL.Query().Get("q")) 254 q := strings.TrimSpace(r.URL.Query().Get("q"))
237 described := s.describeAll(repos) 255 described := s.describeAll(repos)
238 s.render(w, "explore.html", struct { 256 p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q,
239 basePage 257 Facets: []facetGroup{topicFacets(described, q)}}
240 Tab string 258 p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page"))
241 Query string 259 if p.Page > 1 {
242 Facets []facetGroup 260 p.Prev = explorePageURL(q, p.Page-1)
243 Repos []describedRepo 261 }
244 }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)}) 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()
245} 290}
246 291
247// privacy renders the privacy page: what the gitbay software does with 292// 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) {
460 return out, rows.Err() 460 return out, rows.Err()
461} 461}
462 462
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
463// ListForks returns the repositories forked from one repo. The caller 486// ListForks returns the repositories forked from one repo. The caller
464// filters by what the viewer may see. 487// filters by what the viewer may see.
465func (s *Store) ListForks(repoID int64) ([]Repo, error) { 488func (s *Store) ListForks(repoID int64) ([]Repo, error) {
internal/web/templates/explore.html +2 −1
@@ -12,11 +12,12 @@
12</form> 12</form>
13<span class="spacer"></span> 13<span class="spacer"></span>
14</div> 14</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>
16<ul class="repolist rows"> 16<ul class="repolist rows">
17{{range .Repos}}{{template "reporow" .}} 17{{range .Repos}}{{template "reporow" .}}
18{{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}} 18{{else}}<li class="empty">{{if .Query}}nothing matches that filter{{else}}no public repositories yet{{end}}</li>{{end}}
19</ul> 19</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}}
20</div> 21</div>
21</div> 22</div>
22{{end}} 23{{end}}
internal/web/templates/layout.html +1 −1
@@ -88,7 +88,7 @@
88</main> 88</main>
89 89
90<footer> 90<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>
92</footer> 92</footer>
93</div> 93</div>
94</div> 94</div>
internal/web/templates/owner.html +1 −1
@@ -46,7 +46,7 @@
46{{end}} 46{{end}}
47 47
48{{if eq .Tab "repos"}} 48{{if eq .Tab "repos"}}
49<ul class="repolist"> 49<ul class="repolist rows">
50{{range .Repos}}{{template "reporow" .}} 50{{range .Repos}}{{template "reporow" .}}
51{{else}}<li class="empty">no visible repositories</li>{{end}} 51{{else}}<li class="empty">no visible repositories</li>{{end}}
52</ul> 52</ul>
internal/web/web.go +2 −36
@@ -9,11 +9,9 @@ import (
9 "io" 9 "io"
10 "io/fs" 10 "io/fs"
11 "reflect" 11 "reflect"
12 "runtime/debug"
13 "sort" 12 "sort"
14 "strconv" 13 "strconv"
15 "strings" 14 "strings"
16 "sync"
17 "time" 15 "time"
18) 16)
19 17
@@ -32,36 +30,6 @@ var FontFS embed.FS
32//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4 30//go:embed static/img/*.gif static/img/*.webm static/img/*.mp4
33var ImageFS embed.FS 31var ImageFS embed.FS
34 32
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
65// StyleVersion identifies the bytes /static/style.css serves. The httpd 33// StyleVersion identifies the bytes /static/style.css serves. The httpd
66// package sets it; the layout stamps it on the stylesheet URL so a deploy 34// package sets it; the layout stamps it on the stylesheet URL so a deploy
67// changes that URL and a browser holding a cached copy cannot miss it. 35// changes that URL and a browser holding a cached copy cannot miss it.
@@ -127,10 +95,8 @@ func railOptItems(v any) []railItem {
127} 95}
128 96
129var funcs = template.FuncMap{ 97var funcs = template.FuncMap{
130 "gitbayVersion": func() string { return version() }, 98 "styleVersion": func() string { return StyleVersion },
131 "gitbayCommit": func() string { return fullVersion() }, 99 "join": strings.Join,
132 "styleVersion": func() string { return StyleVersion },
133 "join": strings.Join,
134 // paragraphs splits plain text on blank lines for safe rich display. 100 // paragraphs splits plain text on blank lines for safe rich display.
135 "paragraphs": func(s string) []string { 101 "paragraphs": func(s string) []string {
136 var out []string 102 var out []string
internal/web/widths_test.go +4 −2
@@ -31,8 +31,10 @@ func TestListPagesAreWide(t *testing.T) {
31 t.Errorf("%s does not use one-line rows", name) 31 t.Errorf("%s does not use one-line rows", name)
32 } 32 }
33 } 33 }
34 if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) { 34 for _, name := range []string{"explore.html", "owner.html"} {
35 t.Error("explore.html does not use one-line rows") 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 }
36 } 38 }
37 // Settings pages carry a section column: every section id has a link 39 // Settings pages carry a section column: every section id has a link
38 // in the column, and the page is wide with the narrow grid. 40 // in the column, and the page is wide with the narrow grid.