~gitpocket/gitpocket

1

fix(search): qualifiers and case insensitive languages

commit
4e14a871147eceddb1c392a283afa0bd276c7ff5 browse tree
parent
bf900b4d4ad795d41322a22a1718c6e81de7cce0
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-10-13 19:41 UTC

5 files changed, +125 −21

README.md modified

@@ -14,7 +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
17+- A site wide search at `/search` over repositories and people, ranked so an exact name beats a prefix beats a substring. Qualifiers narrow it the way they do elsewhere, `language:ocaml` (case insensitive, quoted when the name has a space), `user:name` and `tag:sel4`, and the language facets write the qualifier into the query box so the filter survives the next search. 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
1818 - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title
1919 - 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
2020 - 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 modified

@@ -127,6 +127,14 @@ type langCount struct {
127127 Name string
128128 Colour string
129129 Count int
130+ Query string
131+}
132+
133+func langQualifier(name string) string {
134+ if strings.ContainsRune(name, ' ') {
135+ return `language:"` + name + `"`
136+ }
137+ return "language:" + name
130138 }
131139
132140 func matchScore(q string, fields ...string) int {
@@ -152,9 +160,15 @@ func matchScore(q string, fields ...string) int {
152160 func (s *Server) find(w http.ResponseWriter, r *http.Request) {
153161 query := strings.TrimSpace(r.URL.Query().Get("q"))
154162 kind := r.URL.Query().Get("type")
163+ text, quals := parseQuery(query)
155164 lang := r.URL.Query().Get("lang")
165+ if l, ok := quals["language"]; ok {
166+ lang = l
167+ }
168+ lang = canonicalLanguage(lang)
169+ owner, tag := quals["user"], strings.ToLower(quals["tag"])
156170 sort := r.URL.Query().Get("sort")
157- q := strings.ToLower(query)
171+ q := strings.ToLower(text)
158172 if kind != "users" {
159173 kind = "repos"
160174 }
@@ -169,7 +183,7 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) {
169183 names = append(names, l.Name)
170184 }
171185 score := matchScore(q, repo.Name, repo.Owner.Name, strings.Join(repo.Tags, " "), strings.Join(names, " "), repo.Description)
172- if score == 0 {
186+ if score == 0 || (owner != "" && !strings.EqualFold(owner, repo.Owner.Name)) || (tag != "" && !slices.Contains(repo.Tags, tag)) {
173187 continue
174188 }
175189 hit := repoHit{Repo: repo, score: score}
@@ -203,9 +217,15 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) {
203217 })
204218 }
205219 var facets []langCount
220+ base := strings.TrimSpace(stripLanguage(query))
206221 for _, c := range counts {
222+ c.Query = strings.TrimSpace(base + " " + langQualifier(c.Name))
207223 facets = append(facets, *c)
208224 }
225+ box := base
226+ if lang != "" {
227+ box = strings.TrimSpace(base + " " + langQualifier(lang))
228+ }
209229 slices.SortFunc(facets, func(a, b langCount) int {
210230 if a.Count != b.Count {
211231 return b.Count - a.Count
@@ -217,7 +237,7 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) {
217237 for _, u := range s.store.SearchableUsers() {
218238 p := s.store.Profile(u.ID)
219239 score := matchScore(q, u.Name, p.Display, p.Location, p.Workplace, p.Bio)
220- if score == 0 {
240+ if score == 0 || (owner != "" && !strings.EqualFold(owner, u.Name)) {
221241 continue
222242 }
223243 owned, _ := s.store.ReposByOwner(u.ID, false)
@@ -259,6 +279,73 @@ func (s *Server) find(w http.ResponseWriter, r *http.Request) {
259279 title = query + " · search"
260280 }
261281 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,
282+ "Query": box, "Base": base, "Type": kind, "Lang": lang, "Sort": sort, "Repos": repos, "Users": users, "Languages": facets, "Facets": true,
263283 }})
264284 }
285+
286+var qualifiers = map[string]string{"language": "language", "lang": "language", "user": "user", "owner": "user", "tag": "tag", "topic": "tag"}
287+
288+func queryTokens(query string) []string {
289+ var tokens []string
290+ var cur strings.Builder
291+ quoted := false
292+ for _, r := range query {
293+ switch {
294+ case r == '"':
295+ quoted = !quoted
296+ case r == ' ' && !quoted:
297+ if cur.Len() > 0 {
298+ tokens = append(tokens, cur.String())
299+ cur.Reset()
300+ }
301+ default:
302+ cur.WriteRune(r)
303+ }
304+ }
305+ if cur.Len() > 0 {
306+ tokens = append(tokens, cur.String())
307+ }
308+ return tokens
309+}
310+
311+func parseQuery(query string) (string, map[string]string) {
312+ quals := map[string]string{}
313+ var words []string
314+ for _, tok := range queryTokens(query) {
315+ key, value, ok := strings.Cut(tok, ":")
316+ if name, known := qualifiers[strings.ToLower(key)]; ok && known && value != "" {
317+ quals[name] = strings.TrimPrefix(value, "~")
318+ continue
319+ }
320+ words = append(words, tok)
321+ }
322+ return strings.Join(words, " "), quals
323+}
324+
325+func stripLanguage(query string) string {
326+ var keep []string
327+ for _, tok := range queryTokens(query) {
328+ key, _, ok := strings.Cut(tok, ":")
329+ if ok && qualifiers[strings.ToLower(key)] == "language" {
330+ continue
331+ }
332+ if strings.ContainsRune(tok, ' ') {
333+ k, v, _ := strings.Cut(tok, ":")
334+ tok = k + `:"` + v + `"`
335+ }
336+ keep = append(keep, tok)
337+ }
338+ return strings.Join(keep, " ")
339+}
340+
341+func canonicalLanguage(name string) string {
342+ if name == "" {
343+ return ""
344+ }
345+ for _, l := range languages {
346+ if strings.EqualFold(l[0], name) {
347+ return l[0]
348+ }
349+ }
350+ return name
351+}

static/style.css modified

@@ -362,13 +362,6 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
362362 .navsearch{flex:0 1 20rem;margin-left:.5rem}
363363 .navsearch input{width:100%;padding:.2rem .55rem;font-size:.9rem}
364364 .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}
372365 .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)}
373366 .bigsearch{display:flex;gap:.5rem;margin:0 0 1.2rem 17rem}
374367 .bigsearch input{flex:1;font-size:1rem;padding:.45rem .7rem}
@@ -392,4 +385,20 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
392385 .langbar span{display:block;height:100%}
393386 .langlist{list-style:none;margin:0 0 1.2rem;padding:0;font-size:.85rem;display:flex;flex-wrap:wrap;gap:.2rem 1rem}
394387 .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)}}
388+@media(max-width:800px){.bigsearch{margin-left:0}.searchpage{grid-template-columns:1fr;gap:.6rem}.navsearch{order:5;flex-basis:100%;margin:0}.facets{display:flex;flex-direction:column;gap:.5rem}.facetbox{padding:.4rem}.facetbox h4,.facetbox.tips{display:none}.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist a{border:1px solid var(--line);padding:.2rem .55rem}.facetlist a.active{border-color:var(--repo);box-shadow:none}}
389+.facetbox{border:1px solid var(--line);border-radius:6px;padding:.5rem;margin-bottom:1rem}
390+.facetbox h4{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.15rem .4rem .45rem;font-weight:600}
391+.facetlist{display:flex;flex-direction:column;gap:1px}
392+.facetlist a{display:flex;align-items:center;gap:.55rem;padding:.35rem .45rem;color:var(--fg);border-radius:4px;font-size:.93rem}
393+.facetlist a:hover{background:var(--soft);text-decoration:none}
394+.facetlist a.active{background:#e8eef8;color:var(--repo);font-weight:600}
395+.facetlist svg{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;color:var(--muted)}
396+.facetlist a.active svg{color:var(--repo)}
397+.facetlist .dot{margin:0 .1rem 0 .15rem;width:.65rem;height:.65rem}
398+.facetlist .fname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
399+.facetlist .count{background:none;padding:0;color:var(--muted);font-size:.8rem;font-variant-numeric:tabular-nums;font-weight:400}
400+.clearfacet{display:block;font-size:.8rem;color:var(--muted);margin:.4rem .45rem .1rem}
401+.facetbox.tips p{margin:0 .4rem .2rem;display:flex;flex-wrap:wrap;gap:.3rem}
402+.facetbox.tips code{font-size:.75rem;background:var(--soft);padding:.05rem .3rem;border-radius:3px}
403+@media(max-width:800px){.facetbox{border:0;padding:0;margin:0}.facetlist a{border-radius:14px}.facets{gap:.35rem}}
404+@media(max-width:800px){.facetlist{flex-direction:row;flex-wrap:wrap;gap:.35rem}.facetlist .fname{flex:none}.facetlist a{gap:.4rem}.facets{flex-direction:row;flex-wrap:wrap}}

templates/repo.html modified

@@ -17,7 +17,7 @@ git push -u origin {{$p.Ref}}</pre>
1717 <div class="clone">
1818 {{with .Data.Languages}}<h3>languages</h3>
1919 <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>
20+<ul class="langlist">{{range .}}<li><i class="dot" style="background:{{.Colour}}"></i>{{.Name}}<span class="pct">{{printf "%.1f" .Percent}}%</span></li>{{end}}</ul>
2121 <h3 class="rule">clone</h3>{{else}}<h3>clone</h3>{{end}}
2222 <strong>read-only</strong>
2323 <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div>

templates/search.html modified

@@ -1,21 +1,29 @@
11 {{define "content"}}
22 {{$d := .Data}}
33 <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>
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}}"><button class="btn primary">Search</button></form>
55 <div class="searchpage">
66 <aside class="facets">
7+<section class="facetbox">
78 <h4>Filter by</h4>
89 <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>
10+<a href="?q={{$d.Query}}&type=repos"{{if eq $d.Type "repos"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 2.5h8.5a1 1 0 0 1 1 1v9.5H4.5A1.5 1.5 0 0 1 3 11.5z"/><path d="M3 11.5A1.5 1.5 0 0 1 4.5 10h8"/></svg><span class="fname">Repositories</span><span class="count">{{len $d.Repos}}</span></a>
11+<a href="?q={{$d.Query}}&type=users"{{if eq $d.Type "users"}} class="active"{{end}}><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="2.7"/><path d="M2.8 13.5c.6-2.6 2.7-4 5.2-4s4.6 1.4 5.2 4"/></svg><span class="fname">Users</span><span class="count">{{len $d.Users}}</span></a>
1112 </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}}
13+</section>
14+{{if $d.Languages}}<section class="facetbox">
15+<h4>Languages</h4>
16+<nav class="facetlist">{{range $d.Languages}}<a href="?q={{.Query}}&type={{$d.Type}}"{{if eq .Name $d.Lang}} class="active"{{end}}><i class="dot" style="background:{{.Colour}}"></i><span class="fname">{{.Name}}</span><span class="count">{{.Count}}</span></a>{{end}}</nav>
17+{{with $d.Lang}}<a class="clearfacet" href="?q={{$d.Base}}&type={{$d.Type}}">× Clear {{.}}</a>{{end}}
18+</section>{{end}}
19+<section class="facetbox tips">
20+<h4>Qualifiers</h4>
21+<p><code>language:ocaml</code> <code>user:james</code> <code>tag:sel4</code></p>
22+</section>
1523 </aside>
1624 <div class="results">
1725 {{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>
26+<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}}</h3>
1927 <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>
2028 {{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}}
2129 {{else}}