feat(search): search repositories and users
8 files changed, +351 −16
- README.md modified +1 −0
- search.go added +264 −0
- static/style.css modified +34 −0
- store.go modified +18 −0
- templates/layout.html modified +1 −0
- templates/repo.html modified +4 −1
- templates/search.html modified +25 −15
- web.go modified +4 −0
README.md modified
| @@ -14,6 +14,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 14 | 14 | - A per user disk quota set with `gitpocket quota 2G` and enforced on push over both transports |
| 15 | 15 | - 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` |
| 16 | 16 | - 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 | 18 | - Stars, so a profile shows what someone found worth keeping, and a star count next to every repository title |
| 18 | 19 | - 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 |
| 19 | 20 | - 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} | ||
| 359 | 359 | .facts svg{flex:none;width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round} |
| 360 | 360 | .ownactions{border-top:1px solid var(--line);padding-top:.6rem} |
| 361 | 361 | .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 { | ||
| 576 | 576 | return err |
| 577 | 577 | } |
| 578 | 578 | |
| 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 | + | |
| 579 | 597 | func (s *Store) FeaturedRepos() ([]Repo, error) { |
| 580 | 598 | rows, err := s.db.Query(`select ` + repoColumns + ` where r.private = 0 and r.featured > 0 order by r.featured`) |
| 581 | 599 | if err != nil { |
templates/layout.html modified
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | <nav class="site"> |
| 20 | 20 | <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> |
| 21 | 21 | <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}} | |
| 22 | 23 | <span class="spacer"></span> |
| 23 | 24 | {{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}} |
| 24 | 25 | </nav> |
templates/repo.html modified
| @@ -15,7 +15,10 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 15 | 15 | {{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}} |
| 16 | 16 | </div> |
| 17 | 17 | <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}} | |
| 19 | 22 | <strong>read-only</strong> |
| 20 | 23 | <div><a href="https://{{.Host}}/{{$p.Repo.Path}}">https://{{.Host}}/{{$p.Repo.Path}}</a></div> |
| 21 | 24 | {{if $p.Owner}}<strong>read/write</strong> |
templates/search.html modified
| @@ -1,18 +1,28 @@ | ||
| 1 | 1 | {{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}} | |
| 17 | 24 | {{end}} |
| 25 | +</div> | |
| 26 | +</div> | |
| 27 | +</div> | |
| 18 | 28 | {{end}} |
web.go modified
| @@ -193,6 +193,7 @@ func (s *Server) handler() http.Handler { | ||
| 193 | 193 | }) |
| 194 | 194 | mux.HandleFunc("GET /{$}", s.index) |
| 195 | 195 | mux.HandleFunc("GET /projects", s.projects) |
| 196 | + mux.HandleFunc("GET /search", s.find) | |
| 196 | 197 | mux.HandleFunc("GET /login", s.loginForm) |
| 197 | 198 | mux.HandleFunc("POST /login", s.login) |
| 198 | 199 | mux.HandleFunc("POST /logout", s.logout) |
| @@ -960,6 +961,7 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) { | ||
| 960 | 961 | branches, _ := g.Refs("refs/heads") |
| 961 | 962 | tags, _ := g.Refs("refs/tags") |
| 962 | 963 | data["Branches"] = branches |
| 964 | + data["Languages"] = s.repoLanguages(repo) | |
| 963 | 965 | data["Tags"] = tags |
| 964 | 966 | name, content := g.Readme(rp.Commit) |
| 965 | 967 | if name != "" { |
| @@ -1351,6 +1353,8 @@ func ago(t time.Time) string { | ||
| 1351 | 1353 | switch { |
| 1352 | 1354 | case d < time.Minute: |
| 1353 | 1355 | return "just now" |
| 1356 | + case d < 2*time.Minute: | |
| 1357 | + return "a minute ago" | |
| 1354 | 1358 | case d < time.Hour: |
| 1355 | 1359 | return fmt.Sprintf("%d minutes ago", int(d.Minutes())) |
| 1356 | 1360 | case d < 2*time.Hour: |