Commit 2eb4578570

2eb45785702eea86342ca4166fa5a2f6296ae2df

parent: 1aafa85cd1

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 21:53 UTC

web: topic and kind columns on explore and search

Ref #226
internal/httpd/topics.go added +40
@@ -0,0 +1,40 @@
1package httpd
2
3import (
4 "net/url"
5 "sort"
6)
7
8// topicFacets counts the topics across repos for explore's column. The
9// links are the ones topic chips already use, ?q=<topic>; the active
10// topic links to explore with no query.
11func topicFacets(repos []describedRepo, q string) facetGroup {
12 counts := map[string]int64{}
13 for _, r := range repos {
14 for _, t := range r.Topics {
15 counts[t]++
16 }
17 }
18 names := make([]string, 0, len(counts))
19 for t := range counts {
20 names = append(names, t)
21 }
22 sort.Slice(names, func(i, j int) bool {
23 if counts[names[i]] != counts[names[j]] {
24 return counts[names[i]] > counts[names[j]]
25 }
26 return names[i] < names[j]
27 })
28 if len(names) > 20 {
29 names = names[:20]
30 }
31 g := facetGroup{Title: "Topics"}
32 for _, t := range names {
33 item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q}
34 if item.Active {
35 item.Href = "/explore"
36 }
37 g.Items = append(g.Items, item)
38 }
39 return g
40}
internal/httpd/topics_test.go added +34
@@ -0,0 +1,34 @@
1package httpd
2
3import "testing"
4
5// Explore's column counts topics across the visible repositories, most
6// used first then by name, capped at twenty, each linking to ?q=<topic>
7// and the active one clearing the query (desktop layout spec).
8func TestTopicFacets(t *testing.T) {
9 repos := []describedRepo{
10 {Topics: []string{"cli", "git"}},
11 {Topics: []string{"git", "swift"}},
12 {Topics: []string{"git"}},
13 }
14 g := topicFacets(repos, "cli")
15 if g.Title != "Topics" || len(g.Items) != 3 {
16 t.Fatalf("group: %+v", g)
17 }
18 if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" {
19 t.Errorf("git: %+v", g.Items[0])
20 }
21 if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" {
22 t.Errorf("cli: %+v", g.Items[1])
23 }
24 if g.Items[2].Label != "swift" || g.Items[2].Count != 1 {
25 t.Errorf("swift: %+v", g.Items[2])
26 }
27 var many []describedRepo
28 for i := 0; i < 30; i++ {
29 many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}})
30 }
31 if n := len(topicFacets(many, "").Items); n != 20 {
32 t.Errorf("cap: %d", n)
33 }
34}
internal/httpd/web.go +6 −4
@@ -210,12 +210,14 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
210210 viewer = s.viewer(r)
211211 }
212212 q := strings.TrimSpace(r.URL.Query().Get("q"))
213 described := s.describeAll(repos)
213214 s.render(w, "explore.html", struct {
214215 basePage
215 Tab string
216 Query string
217 Repos []describedRepo
218 }{s.baseFor(viewer), "explore", q, s.filterRepos(q, s.describeAll(repos))})
216 Tab string
217 Query string
218 Facets []facetGroup
219 Repos []describedRepo
220 }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)})
219221}
220222
221223// privacy renders the privacy page: what the gitbay software does with
internal/web/templates/explore.html +5
@@ -1,6 +1,9 @@
11{{define "width"}}wide{{end}}
22{{define "title"}}explore · {{.Site}}{{end}}
33{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
47<div class="headrow">
58<h1>Explore</h1>
69<form method="get" action="/explore" class="searchform compact">
@@ -13,4 +16,6 @@
1316{{range .Repos}}{{template "reporow" .}}
1417{{else}}<li class="empty">no public repositories yet</li>{{end}}
1518</ul>
19</div>
20</div>
1621{{end}}
internal/web/templates/globalsearch.html +13 −6
@@ -1,14 +1,19 @@
11{{define "width"}}wide{{end}}
22{{define "title"}}search · {{.Site}}{{end}}
33{{define "content"}}
4<div class="withcol">
5<nav class="sidecol" aria-label="Filters">
6 <div class="grp"><h2 class="colhead">Kind</h2>
7 <ul>
8 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
9 <li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=repo">repositories</a></li>
10 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
11 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
12 </ul></div>
13</nav>
14<div class="colmain">
415<div class="listhead">
516 <h1>Search</h1>
6 <nav class="filters">
7 <a {{if eq .Kind ""}}class="active" aria-current="page" {{end}}href="?q={{.Query}}">everything</a>
8 <a {{if eq .Kind "repo"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=repo">repositories</a>
9 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a>
10 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=mr">merge requests</a>
11 </nav>
1217 {{if and .Query (not .QueryErr)}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}}
1318</div>
1419<form method="get" action="/search" class="searchform">
@@ -33,4 +38,6 @@
3338{{else}}
3439<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
3540{{end}}
41</div>
42</div>
3643{{end}}