~gitpocket/gitpocket

feat(web): site search over repositories and users with language facets and breakdowns

commit
d819768dc704b922aab16b17207fced6a6759537 browse tree
parent
e112287a33d6f50da2a095fc6abcec6c6d8c7ce6
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 10:28 UTC

8 files changed, +351 −16

README.md modified

@@ -14,6 +14,7 @@ Small and lightweight git server for self hosting. Repositories are served over
1414 - A per user disk quota set with `gitpocket quota 2G` and enforced on push over both transports
1515 - Signature verification for both SSH and PGP. Register the key in settings, sign commits with it, and they show `Verified ~you`. PGP keys are imported into a server keyring and matched by primary fingerprint, SSH keys through an allowed signers file matched by fingerprint. Keys are public at `~you.keys` and `~you.pgp`
1616 - Issues per repository with Markdown bodies and comments, open and closed states, and an open count in the tab strip
17+- A site wide search at `/search` over repositories and people, ranked so an exact name beats a prefix beats a substring. Each repository's languages come from the byte size of its files at HEAD by extension, skipping minified and vendored paths, and are cached against the HEAD hash so the language filter and the breakdown bar on the summary page cost one `git ls-tree` per push
1718 - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title
1819 - A year of activity on each profile, drawn server side as a grid with no script, with a tab for the past twelve months and one for each calendar year that has commits. Hovering or focusing a day shows its count in a CSS tooltip. It counts commits on every branch of every public repository whose author email matches the account's verified address, so work on other people's projects counts and nothing is attributed by name alone
1920 - Blame, a compare page for any two refs, and tar.gz and zip downloads of any ref. The tar.gz is `git archive --format=tar` passed through a gzip writer with a zeroed header and a fixed level, so the same commit always produces the same bytes and `archive/<ref>.tar.gz.sha256` publishes the checksum that a package recipe can pin

search.go added

@@ -0,0 +1,264 @@
1+package main
2+
3+import (
4+ "net/http"
5+ "path"
6+ "slices"
7+ "strconv"
8+ "strings"
9+ "sync"
10+)
11+
12+type language struct {
13+ Name string
14+ Colour string
15+ Bytes int64
16+ Percent float64
17+}
18+
19+var languages = map[string][2]string{
20+ ".go": {"Go", "#00add8"}, ".ml": {"OCaml", "#ef7a08"}, ".mli": {"OCaml", "#ef7a08"}, ".mly": {"OCaml", "#ef7a08"}, ".mll": {"OCaml", "#ef7a08"},
21+ ".thy": {"Isabelle", "#fefe00"}, ".ML": {"Standard ML", "#dc566d"}, ".sml": {"Standard ML", "#dc566d"},
22+ ".v": {"Rocq", "#d0b68c"}, ".lean": {"Lean", "#4a4a8c"}, ".agda": {"Agda", "#315665"}, ".hs": {"Haskell", "#5e5086"}, ".idr": {"Idris", "#b30000"},
23+ ".c": {"C", "#555555"}, ".h": {"C", "#555555"}, ".cc": {"C++", "#f34b7d"}, ".cpp": {"C++", "#f34b7d"}, ".hpp": {"C++", "#f34b7d"},
24+ ".rs": {"Rust", "#dea584"}, ".zig": {"Zig", "#ec915c"}, ".S": {"Assembly", "#6e4c13"}, ".s": {"Assembly", "#6e4c13"}, ".asm": {"Assembly", "#6e4c13"},
25+ ".HC": {"HolyC", "#ffefaf"}, ".smt2": {"SMT", "#7b8d9a"}, ".py": {"Python", "#3572a5"}, ".rb": {"Ruby", "#701516"},
26+ ".js": {"JavaScript", "#f1e05a"}, ".mjs": {"JavaScript", "#f1e05a"}, ".ts": {"TypeScript", "#3178c6"}, ".tsx": {"TypeScript", "#3178c6"},
27+ ".java": {"Java", "#b07219"}, ".kt": {"Kotlin", "#a97bff"}, ".swift": {"Swift", "#f05138"}, ".scala": {"Scala", "#c22d40"},
28+ ".ex": {"Elixir", "#6e4a7e"}, ".exs": {"Elixir", "#6e4a7e"}, ".erl": {"Erlang", "#b83998"}, ".gleam": {"Gleam", "#ffaff3"},
29+ ".lua": {"Lua", "#000080"}, ".nix": {"Nix", "#7e7eff"}, ".sh": {"Shell", "#89e051"}, ".tex": {"TeX", "#3d6117"},
30+ ".html": {"HTML", "#e34c26"}, ".css": {"CSS", "#663399"}, ".scm": {"Scheme", "#1e4aec"}, ".rkt": {"Racket", "#3c5caa"},
31+ ".lisp": {"Common Lisp", "#3fb68b"}, ".clj": {"Clojure", "#db5855"}, ".fs": {"F#", "#b845fc"}, ".nim": {"Nim", "#ffc200"},
32+}
33+
34+var vendorDirs = []string{"vendor/", "node_modules/", "third_party/", "thirdparty/", "external/", "deps/", "dist/", "katex/"}
35+
36+func vendored(name string) bool {
37+ base := path.Base(name)
38+ if strings.Contains(base, ".min.") || strings.HasSuffix(base, ".pb.go") || strings.HasSuffix(base, "_generated.go") {
39+ return true
40+ }
41+ for _, d := range vendorDirs {
42+ if strings.HasPrefix(name, d) || strings.Contains(name, "/"+d) {
43+ return true
44+ }
45+ }
46+ return false
47+}
48+
49+type langEntry struct {
50+ head string
51+ langs []language
52+}
53+
54+var langCache = struct {
55+ sync.Mutex
56+ m map[int64]langEntry
57+}{m: map[int64]langEntry{}}
58+
59+func (s *Server) repoLanguages(repo Repo) []language {
60+ g := Git{Dir: s.repoDir(repo)}
61+ head, err := g.Resolve("HEAD")
62+ if err != nil {
63+ return nil
64+ }
65+ langCache.Lock()
66+ e, ok := langCache.m[repo.ID]
67+ langCache.Unlock()
68+ if ok && e.head == head {
69+ return e.langs
70+ }
71+ out, err := g.run(nil, "ls-tree", "-r", "-l", "-z", head)
72+ if err != nil {
73+ return nil
74+ }
75+ bytes := map[string]int64{}
76+ var total int64
77+ for _, rec := range strings.Split(string(out), "\x00") {
78+ meta, name, ok := strings.Cut(rec, "\t")
79+ f := strings.Fields(meta)
80+ if !ok || len(f) != 4 || f[1] != "blob" || vendored(name) {
81+ continue
82+ }
83+ l, known := languages[path.Ext(name)]
84+ if !known {
85+ l, known = languages[strings.ToLower(path.Ext(name))]
86+ }
87+ if !known {
88+ continue
89+ }
90+ size, _ := strconv.ParseInt(f[3], 10, 64)
91+ bytes[l[0]] += size
92+ total += size
93+ }
94+ var langs []language
95+ for _, l := range languages {
96+ if n, ok := bytes[l[0]]; ok {
97+ langs = append(langs, language{Name: l[0], Colour: l[1], Bytes: n, Percent: float64(n) * 100 / float64(total)})
98+ delete(bytes, l[0])
99+ }
100+ }
101+ slices.SortFunc(langs, func(a, b language) int {
102+ if a.Bytes != b.Bytes {
103+ return int(b.Bytes - a.Bytes)
104+ }
105+ return strings.Compare(a.Name, b.Name)
106+ })
107+ langCache.Lock()
108+ langCache.m[repo.ID] = langEntry{head, langs}
109+ langCache.Unlock()
110+ return langs
111+}
112+
113+type repoHit struct {
114+ Repo
115+ Language *language
116+ score int
117+}
118+
119+type userHit struct {
120+ User
121+ Profile Profile
122+ Repos int
123+ score int
124+}
125+
126+type langCount struct {
127+ Name string
128+ Colour string
129+ Count int
130+}
131+
132+func matchScore(q string, fields ...string) int {
133+ if q == "" {
134+ return 1
135+ }
136+ best := 0
137+ for i, f := range fields {
138+ f = strings.ToLower(f)
139+ weight := len(fields) - i
140+ switch {
141+ case f == q:
142+ best = max(best, 100*weight)
143+ case strings.HasPrefix(f, q):
144+ best = max(best, 50*weight)
145+ case strings.Contains(f, q):
146+ best = max(best, 10*weight)
147+ }
148+ }
149+ return best
150+}
151+
152+func (s *Server) find(w http.ResponseWriter, r *http.Request) {
153+ query := strings.TrimSpace(r.URL.Query().Get("q"))
154+ kind := r.URL.Query().Get("type")
155+ lang := r.URL.Query().Get("lang")
156+ sort := r.URL.Query().Get("sort")
157+ q := strings.ToLower(query)
158+ if kind != "users" {
159+ kind = "repos"
160+ }
161+
162+ all, _ := s.store.PublicRepos("", "")
163+ var repos []repoHit
164+ counts := map[string]*langCount{}
165+ for _, repo := range all {
166+ langs := s.repoLanguages(repo)
167+ var names []string
168+ for _, l := range langs {
169+ names = append(names, l.Name)
170+ }
171+ score := matchScore(q, repo.Name, repo.Owner.Name, strings.Join(repo.Tags, " "), strings.Join(names, " "), repo.Description)
172+ if score == 0 {
173+ continue
174+ }
175+ hit := repoHit{Repo: repo, score: score}
176+ if len(langs) > 0 {
177+ hit.Language = &langs[0]
178+ c, ok := counts[langs[0].Name]
179+ if !ok {
180+ c = &langCount{Name: langs[0].Name, Colour: langs[0].Colour}
181+ counts[langs[0].Name] = c
182+ }
183+ c.Count++
184+ }
185+ if lang != "" && (hit.Language == nil || hit.Language.Name != lang) {
186+ continue
187+ }
188+ hit.Updated = repoUpdated(s.repoDir(repo))
189+ repos = append(repos, hit)
190+ }
191+ switch sort {
192+ case "updated":
193+ slices.SortStableFunc(repos, func(a, b repoHit) int { return b.Updated.Compare(a.Updated) })
194+ case "stars":
195+ slices.SortStableFunc(repos, func(a, b repoHit) int { return b.Stars - a.Stars })
196+ default:
197+ sort = ""
198+ slices.SortStableFunc(repos, func(a, b repoHit) int {
199+ if a.score != b.score {
200+ return b.score - a.score
201+ }
202+ return b.Updated.Compare(a.Updated)
203+ })
204+ }
205+ var facets []langCount
206+ for _, c := range counts {
207+ facets = append(facets, *c)
208+ }
209+ slices.SortFunc(facets, func(a, b langCount) int {
210+ if a.Count != b.Count {
211+ return b.Count - a.Count
212+ }
213+ return strings.Compare(a.Name, b.Name)
214+ })
215+
216+ var users []userHit
217+ for _, u := range s.store.SearchableUsers() {
218+ p := s.store.Profile(u.ID)
219+ score := matchScore(q, u.Name, p.Display, p.Location, p.Workplace, p.Bio)
220+ if score == 0 {
221+ continue
222+ }
223+ owned, _ := s.store.ReposByOwner(u.ID, false)
224+ hit := userHit{User: u, Profile: p, score: score}
225+ for _, repo := range owned {
226+ if repo.Unlisted {
227+ continue
228+ }
229+ hit.Repos++
230+ }
231+ if lang != "" {
232+ found := false
233+ for _, repo := range repos {
234+ if repo.Owner.ID == u.ID {
235+ found = true
236+ break
237+ }
238+ }
239+ if !found {
240+ continue
241+ }
242+ }
243+ users = append(users, hit)
244+ }
245+ slices.SortStableFunc(users, func(a, b userHit) int {
246+ if a.score != b.score {
247+ return b.score - a.score
248+ }
249+ return b.Repos - a.Repos
250+ })
251+ if len(repos) > 100 {
252+ repos = repos[:100]
253+ }
254+ if len(users) > 100 {
255+ users = users[:100]
256+ }
257+ title := "Search"
258+ if query != "" {
259+ title = query + " · search"
260+ }
261+ s.render(w, r, "search", page{Title: title, User: s.current(r), Data: map[string]any{
262+ "Query": query, "Type": kind, "Lang": lang, "Sort": sort, "Repos": repos, "Users": users, "Languages": facets, "Facets": true,
263+ }})
264+}

static/style.css modified

@@ -359,3 +359,37 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
359359 .facts svg{flex:none;width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
360360 .ownactions{border-top:1px solid var(--line);padding-top:.6rem}
361361 .profile .bio.readme,.profile .bio.readme p{font-family:inherit;font-size:.98rem;line-height:1.5}
362+.navsearch{flex:0 1 20rem;margin-left:.5rem}
363+.navsearch input{width:100%;padding:.2rem .55rem;font-size:.9rem}
364+.searchpage{display:grid;grid-template-columns:15rem 1fr;gap:2rem;align-items:start}
365+.facets h4{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 .4rem}
366+.facets h4+nav{margin-bottom:1.2rem}
367+.facetlist{display:flex;flex-direction:column}
368+.facetlist a{display:flex;justify-content:space-between;align-items:center;padding:.3rem .55rem;color:var(--fg);border-radius:4px}
369+.facetlist a:hover{background:var(--soft);text-decoration:none}
370+.facetlist a.active{background:var(--soft);font-weight:600;box-shadow:inset 3px 0 0 var(--repo)}
371+.facetlist .count{background:#e9ecef;color:var(--muted);font-size:.75rem;padding:0 .45rem;border-radius:9px;font-weight:400}
372+.dot{display:inline-block;width:.7rem;height:.7rem;border-radius:50%;margin-right:.4rem;vertical-align:-.05rem;border:1px solid rgba(0,0,0,.12)}
373+.bigsearch{display:flex;gap:.5rem;margin:0 0 1.2rem 17rem}
374+.bigsearch input{flex:1;font-size:1rem;padding:.45rem .7rem}
375+.resulthead{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:.4rem;margin-bottom:.2rem}
376+.resulthead h3{margin:0;border:0;padding:0;font-size:1.05rem}
377+.sorts{display:flex;gap:.8rem;font-size:.85rem}
378+.sorts a{color:var(--muted)}
379+.sorts a.active{color:var(--fg);font-weight:600}
380+.hit{padding:.9rem 0;border-bottom:1px solid var(--line)}
381+.hit h4{margin:0 0 .25rem;font-size:1.05rem;font-weight:600}
382+.hit h4 a{color:var(--repo)}
383+.hit h4 a.owner{color:var(--muted)}
384+.hit p{margin:.15rem 0}
385+.hit .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.85rem}
386+.hit .meta{display:flex;gap:1.1rem;flex-wrap:wrap;color:var(--muted);font-size:.85rem;margin-top:.35rem}
387+.hit.user{display:grid;grid-template-columns:3rem 1fr;gap:.9rem;align-items:start}
388+.hitavatar img,.hitavatar span{display:flex;width:3rem;height:3rem;border-radius:6px;object-fit:cover;border:1px solid var(--line)}
389+.hitavatar span{align-items:center;justify-content:center;background:var(--soft);color:var(--muted);font-size:1.3rem;text-transform:uppercase}
390+.hit .bio{color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
391+.langbar{display:flex;height:.5rem;border-radius:4px;overflow:hidden;margin:.2rem 0 .5rem;background:var(--line)}
392+.langbar span{display:block;height:100%}
393+.langlist{list-style:none;margin:0 0 1.2rem;padding:0;font-size:.85rem;display:flex;flex-wrap:wrap;gap:.2rem 1rem}
394+.langlist .pct{color:var(--muted);margin-left:.25rem}
395+@media(max-width:800px){.bigsearch{margin-left:0}.searchpage{grid-template-columns:1fr;gap:.6rem}.navsearch{order:5;flex-basis:100%;margin:0}.facets h4{display:none}.facets h4+nav{margin-bottom:.4rem}.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist a{border:1px solid var(--line);padding:.15rem .5rem;gap:.4rem}.facetlist a.active{box-shadow:none;border-color:var(--repo)}}

store.go modified

@@ -576,6 +576,24 @@ func (s *Store) Backup(path string) error {
576576 return err
577577 }
578578
579+func (s *Store) SearchableUsers() []User {
580+ rows, err := s.db.Query(`select id, name, created from users where banned = 0 order by name limit 2000`)
581+ if err != nil {
582+ return nil
583+ }
584+ defer rows.Close()
585+ var users []User
586+ for rows.Next() {
587+ var u User
588+ var created int64
589+ if rows.Scan(&u.ID, &u.Name, &created) == nil {
590+ u.Created = time.Unix(created, 0)
591+ users = append(users, u)
592+ }
593+ }
594+ return users
595+}
596+
579597 func (s *Store) FeaturedRepos() ([]Repo, error) {
580598 rows, err := s.db.Query(`select ` + repoColumns + ` where r.private = 0 and r.featured > 0 order by r.featured`)
581599 if err != nil {

templates/layout.html modified

@@ -19,6 +19,7 @@
1919 <nav class="site">
2020 <a class="brand" href="/"><svg class="logo" viewBox="0 0 24 24" aria-hidden="true"><path class="pocket" d="M3.5 3.5h17v9.5c0 4.4-3.8 8-8.5 8s-8.5-3.6-8.5-8z"/><circle cx="9" cy="9" r="1.6"/><circle cx="9" cy="16" r="1.6"/><circle cx="15" cy="11.5" r="1.6"/><path class="branch" d="M9 10.6v3.8M15 13.1c0 1.6-2.2 2.2-4.4 2.6"/></svg>{{.Name}}</a>
2121 <a href="/projects">projects</a>
22+{{if not (and .Data (index .Data "Facets"))}}<form class="navsearch" method="get" action="/search" role="search"><input name="q" placeholder="Search repositories, people, languages" aria-label="Search"></form>{{end}}
2223 <span class="spacer"></span>
2324 {{if .User}}<span>Logged in as <a href="/~{{.User.Name}}">~{{.User.Name}}</a> — <form method="post" action="/logout"><button>Log out</button></form></span>{{else}}<span class="auth"><a href="/login">Log in</a> — <a href="/register">Register</a></span>{{end}}
2425 </nav>

templates/repo.html modified

@@ -15,7 +15,10 @@ git push -u origin {{$p.Ref}}</pre>
1515 {{range $i, $t := .Data.Tags}}{{if lt $i 3}}<div class="ref"><strong>{{$t.Name}}</strong><div><a href="/{{$p.Repo.Path}}/tree/{{$t.Name}}/">browse ▸</a> <a href="/{{$p.Repo.Path}}/log/{{$t.Name}}">log ▸</a></div></div>{{end}}{{end}}
1616 </div>
1717 <div class="clone">
18-<h3>clone</h3>
18+{{with .Data.Languages}}<h3>languages</h3>
19+<div class="langbar">{{range .}}<span style="width:{{printf "%.2f" .Percent}}%;background:{{.Colour}}" title="{{.Name}} {{printf "%.1f" .Percent}}%"></span>{{end}}</div>
20+<ul class="langlist">{{range .}}<li><a href="/search?lang={{.Name}}"><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</a><span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul>
21+<h3 class="rule">clone</h3>{{else}}<h3>clone</h3>{{end}}
1922 <strong>read-only</strong>
2023 <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div>
2124 {{if $p.Owner}}<strong>read/write</strong>

templates/search.html modified

@@ -1,18 +1,28 @@
11 {{define "content"}}
2-{{$p := .Data.Page}}
3-<form method="get" action="/{{$p.Repo.Path}}/search" class="search">
4-<input type="hidden" name="ref" value="{{$p.Ref}}">
5-<input name="q" value="{{.Data.Query}}" placeholder="Search file contents" autofocus>
6-<div class="sort"><span class="muted">Case insensitive, fixed string, at <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/">{{$p.Ref}}</a></span><button class="btn">Search ▸</button></div>
7-</form>
8-{{if .Data.Query}}
9-<p class="muted">{{.Data.Count}} matches{{if .Data.Truncated}}, showing the first 500{{end}}</p>
10-{{range .Data.Groups}}
11-<div class="hits">
12-<h4><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">{{.Path}}</a></h4>
13-<table class="blob">{{range .Matches}}<tr><td class="n"><a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}#L{{.Line}}">{{.Line}}</a></td><td class="l">{{mark .Text $.Data.Query}}</td></tr>{{end}}</table>
14-</div>
15-{{end}}
16-{{if not .Data.Groups}}<p class="info">No matches.</p>{{end}}
2+{{$d := .Data}}
3+<div class="searchwrap">
4+<form class="bigsearch" method="get" action="/search"><input name="q" value="{{$d.Query}}" placeholder="Search repositories, people, languages" autofocus><input type="hidden" name="type" value="{{$d.Type}}">{{with $d.Lang}}<input type="hidden" name="lang" value="{{.}}">{{end}}<button class="btn primary">Search</button></form>
5+<div class="searchpage">
6+<aside class="facets">
7+<h4>Filter by</h4>
8+<nav class="facetlist">
9+<a href="?q={{$d.Query}}&type=repos{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Type "repos"}} class="active"{{end}}><span>Repositories</span><span class="count">{{len $d.Repos}}</span></a>
10+<a href="?q={{$d.Query}}&type=users{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Type "users"}} class="active"{{end}}><span>Users</span><span class="count">{{len $d.Users}}</span></a>
11+</nav>
12+{{if $d.Languages}}<h4>Languages</h4>
13+<nav class="facetlist">{{range $d.Languages}}<a href="?q={{$d.Query}}&type={{$d.Type}}&lang={{.Name}}"{{if eq .Name $d.Lang}} class="active"{{end}}><span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span><span class="count">{{.Count}}</span></a>{{end}}</nav>
14+{{with $d.Lang}}<p class="small"><a href="?q={{$d.Query}}&type={{$d.Type}}">Clear language</a></p>{{end}}{{end}}
15+</aside>
16+<div class="results">
17+{{if eq $d.Type "repos"}}
18+<div class="resulthead"><h3 class="plain">{{len $d.Repos}} repositor{{if eq (len $d.Repos) 1}}y{{else}}ies{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} in {{.}}{{end}}</h3>
19+<nav class="sorts"><a href="?q={{$d.Query}}&type=repos{{with $d.Lang}}&lang={{.}}{{end}}"{{if not $d.Sort}} class="active"{{end}}>Best match</a><a href="?q={{$d.Query}}&type=repos&sort=updated{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "updated"}} class="active"{{end}}>Recently updated</a><a href="?q={{$d.Query}}&type=repos&sort=stars{{with $d.Lang}}&lang={{.}}{{end}}"{{if eq $d.Sort "stars"}} class="active"{{end}}>Most stars</a></nav></div>
20+{{range $d.Repos}}<div class="hit"><h4><a class="owner" href="/~{{.Owner.Name}}">~{{.Owner.Name}}</a>/<a href="/{{.Path}}">{{.Name}}</a></h4>{{with .Description}}<p>{{.}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/projects?tag={{.}}">#{{.}}</a>{{end}}</p>{{end}}<p class="meta">{{with .Language}}<span><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}</span>{{end}}{{if .Stars}}<span>☆ {{.Stars}}</span>{{end}}<span>Updated {{ago .Updated}}</span></p></div>{{else}}<p class="info">No repositories match. Try a shorter query or clear the language filter.</p>{{end}}
21+{{else}}
22+<div class="resulthead"><h3 class="plain">{{len $d.Users}} user{{if ne (len $d.Users) 1}}s{{end}}{{with $d.Query}} matching <q>{{.}}</q>{{end}}{{with $d.Lang}} writing {{.}}{{end}}</h3></div>
23+{{range $d.Users}}<div class="hit user"><a href="/~{{.Name}}" class="hitavatar">{{if .Profile.Avatar}}<img src="/~{{.Name}}/avatar?v={{.Profile.Avatar}}" alt="">{{else}}<span>{{slice .Name 0 1}}</span>{{end}}</a><div><h4><a href="/~{{.Name}}">{{with .Profile.Display}}{{.}}{{else}}~{{.Name}}{{end}}</a> <span class="muted">~{{.Name}}</span></h4>{{with .Profile.Bio}}<p class="bio">{{.}}</p>{{end}}<p class="meta">{{with .Profile.Location}}<span>{{.}}</span>{{end}}<span>{{.Repos}} public repositor{{if eq .Repos 1}}y{{else}}ies{{end}}</span><span>Joined {{.Created.Format "January 2006"}}</span></p></div></div>{{else}}<p class="info">No users match.</p>{{end}}
1724 {{end}}
25+</div>
26+</div>
27+</div>
1828 {{end}}

web.go modified

@@ -193,6 +193,7 @@ func (s *Server) handler() http.Handler {
193193 })
194194 mux.HandleFunc("GET /{$}", s.index)
195195 mux.HandleFunc("GET /projects", s.projects)
196+ mux.HandleFunc("GET /search", s.find)
196197 mux.HandleFunc("GET /login", s.loginForm)
197198 mux.HandleFunc("POST /login", s.login)
198199 mux.HandleFunc("POST /logout", s.logout)
@@ -960,6 +961,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
960961 branches, _ := g.Refs("refs/heads")
961962 tags, _ := g.Refs("refs/tags")
962963 data["Branches"] = branches
964+ data["Languages"] = s.repoLanguages(repo)
963965 data["Tags"] = tags
964966 name, content := g.Readme(rp.Commit)
965967 if name != "" {
@@ -1351,6 +1353,8 @@ func ago(t time.Time) string {
13511353 switch {
13521354 case d < time.Minute:
13531355 return "just now"
1356+ case d < 2*time.Minute:
1357+ return "a minute ago"
13541358 case d < time.Hour:
13551359 return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
13561360 case d < 2*time.Hour: