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 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 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. |
| 11 | func 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 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import "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). |
| 8 | func 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) { |
| 210 | 210 | viewer = s.viewer(r) |
| 211 | 211 | } |
| 212 | 212 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 213 | described := s.describeAll(repos) |
| 213 | 214 | s.render(w, "explore.html", struct { |
| 214 | 215 | 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)}) |
| 219 | 221 | } |
| 220 | 222 | |
| 221 | 223 | // privacy renders the privacy page: what the gitbay software does with |
internal/web/templates/explore.html
+5
| @@ -1,6 +1,9 @@ |
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}explore · {{.Site}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> |
| 5 | {{template "sidecol" .Facets}} |
| 6 | <div class="colmain"> |
| 4 | 7 | <div class="headrow"> |
| 5 | 8 | <h1>Explore</h1> |
| 6 | 9 | <form method="get" action="/explore" class="searchform compact"> |
| @@ -13,4 +16,6 @@ |
| 13 | 16 | {{range .Repos}}{{template "reporow" .}} |
| 14 | 17 | {{else}}<li class="empty">no public repositories yet</li>{{end}} |
| 15 | 18 | </ul> |
| 19 | </div> |
| 20 | </div> |
| 16 | 21 | {{end}} |
internal/web/templates/globalsearch.html
+13 −6
| @@ -1,14 +1,19 @@ |
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}search · {{.Site}}{{end}} |
| 3 | 3 | {{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}}&kind=repo">repositories</a></li> |
| 10 | <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=issue">issues</a></li> |
| 11 | <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=mr">merge requests</a></li> |
| 12 | </ul></div> |
| 13 | </nav> |
| 14 | <div class="colmain"> |
| 4 | 15 | <div class="listhead"> |
| 5 | 16 | <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}}&kind=repo">repositories</a> |
| 9 | | <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=issue">issues</a> |
| 10 | | <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=mr">merge requests</a> |
| 11 | | </nav> |
| 12 | 17 | {{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}} |
| 13 | 18 | </div> |
| 14 | 19 | <form method="get" action="/search" class="searchform"> |
| @@ -33,4 +38,6 @@ |
| 33 | 38 | {{else}} |
| 34 | 39 | <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> |
| 35 | 40 | {{end}} |
| 41 | </div> |
| 42 | </div> |
| 36 | 43 | {{end}} |