feat(basin): search file contents with git grep
6 files changed, +121 −3
- README.md modified +2 −2
- git.go modified +30 −0
- static/style.css modified +6 −1
- templates/search.html added +18 −0
- templates/tree.html modified +1 −0
- web.go modified +64 −0
README.md modified
| @@ -5,7 +5,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 5 | 5 | ## Features |
| 6 | 6 | |
| 7 | 7 | - Repos sit under `~user/name` and can be public or unlisted or private. Each one takes up to three tags, and there is a `/projects` page that searches across all of them sorted by whoever pushed most recently |
| 8 | -- Tree and blob and log and commit and refs views, with chroma doing the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser | |
| 8 | +- Tree and blob and log and commit and refs views, plus a case insensitive search across file contents at any ref backed by `git grep`. Chroma does the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser | |
| 9 | 9 | - The last commit shown against every tree entry comes out of one `git log --name-only` walk per directory instead of a separate process per file |
| 10 | 10 | - Profiles have a display name and a Markdown bio along with location and workplace and website, plus an avatar that you crop in the browser and the server then resamples down to a 256px PNG |
| 11 | 11 | - Forks and pull requests from either a branch or a fork. Comments are Markdown, merges take an editable message, and a request can be closed or reopened or brought up to date from its source at any point |
| @@ -71,7 +71,7 @@ To send changes to something you do not own, fork it from the button beside the | ||
| 71 | 71 | |
| 72 | 72 | ## Limits |
| 73 | 73 | |
| 74 | -There is no search inside files and no inline review comments, no squash or rebase merges, no collaborators beyond the single owner of a repo, and no disk quota of any kind. Blobs over 1 MiB are not rendered and anything over 64 MiB is not served raw either, while highlighting gives up past 256 KiB. Signature checking is SSH only for now, so commits signed with GPG show up as unverified. | |
| 74 | +There are no inline review comments, no squash or rebase merges, no collaborators beyond the single owner of a repo, and no disk quota of any kind. Blobs over 1 MiB are not rendered and anything over 64 MiB is not served raw either, while highlighting gives up past 256 KiB. Signature checking is SSH only for now, so commits signed with GPG show up as unverified. | |
| 75 | 75 | |
| 76 | 76 | ## Licence |
| 77 | 77 | |
git.go modified
| @@ -280,6 +280,36 @@ func (g Git) Tree(commit, dir string) ([]Entry, error) { | ||
| 280 | 280 | return entries, nil |
| 281 | 281 | } |
| 282 | 282 | |
| 283 | +type Match struct { | |
| 284 | + Path string | |
| 285 | + Line int | |
| 286 | + Text string | |
| 287 | +} | |
| 288 | + | |
| 289 | +func (g Git) Grep(commit, query string, limit int) ([]Match, bool, error) { | |
| 290 | + out, err := g.run(nil, "grep", "-n", "-i", "-I", "-F", "-z", "--no-color", "-e", query, commit) | |
| 291 | + if err != nil { | |
| 292 | + if strings.TrimSpace(err.Error()) == "" || strings.Contains(err.Error(), "exit status 1") { | |
| 293 | + return nil, false, nil | |
| 294 | + } | |
| 295 | + return nil, false, err | |
| 296 | + } | |
| 297 | + var matches []Match | |
| 298 | + prefix := commit + ":" | |
| 299 | + for _, line := range strings.Split(string(out), "\n") { | |
| 300 | + f := strings.Split(line, "\x00") | |
| 301 | + if len(f) != 3 || !strings.HasPrefix(f[0], prefix) { | |
| 302 | + continue | |
| 303 | + } | |
| 304 | + n, _ := strconv.Atoi(f[1]) | |
| 305 | + matches = append(matches, Match{Path: strings.TrimPrefix(f[0], prefix), Line: n, Text: strings.TrimLeft(f[2], " \t")}) | |
| 306 | + if len(matches) > limit { | |
| 307 | + return matches[:limit], true, nil | |
| 308 | + } | |
| 309 | + } | |
| 310 | + return matches, false, nil | |
| 311 | +} | |
| 312 | + | |
| 283 | 313 | func (g Git) Entry(commit, filePath string) *Entry { |
| 284 | 314 | if filePath == "" { |
| 285 | 315 | return nil |
static/style.css modified
| @@ -195,7 +195,7 @@ input[type=file]{border:0;padding:0} | ||
| 195 | 195 | .twocol label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem} |
| 196 | 196 | h3.plain{border:0;font-size:1.25rem} |
| 197 | 197 | .columns.browse{grid-template-columns:minmax(0,2fr) 1fr} |
| 198 | -.search input[name=search]{width:100%} | |
| 198 | +.search input[name=search],.search input[name=q]{width:100%} | |
| 199 | 199 | .search .sort{display:flex;justify-content:flex-end;align-items:center;gap:1rem;margin:.5rem 0 1rem} |
| 200 | 200 | .search .sort label{display:flex;align-items:center;gap:.3rem} |
| 201 | 201 | .event .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.9rem} |
| @@ -233,3 +233,8 @@ select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#ff | ||
| 233 | 233 | form.stack.centred{margin:0 auto 1.2rem;max-width:34rem} |
| 234 | 234 | form.stack.centred input{width:100%} |
| 235 | 235 | .privacy{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.6rem .8rem} |
| 236 | +.filesearch input{padding:.15rem .4rem;font-size:.9rem;width:12rem} | |
| 237 | +.hits{margin-bottom:1rem} | |
| 238 | +.hits h4{margin:0 0 .2rem} | |
| 239 | +.hits table.blob{border:1px solid var(--line)} | |
| 240 | +mark{background:#fff3b0;padding:0} | |
templates/search.html added
| @@ -0,0 +1,18 @@ | ||
| 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}} | |
| 17 | +{{end}} | |
| 18 | +{{end}} | |
templates/tree.html modified
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | {{with .Entry}}<span class="mode">{{perm .Mode}}</span>{{if eq .Type "blob"}}<span class="muted">{{size .Size}}</span>{{end}}{{end}} |
| 21 | 21 | {{if .Entry}}{{if eq .Entry.Type "blob"}}<a class="active" href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Path}}">View</a><a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/raw/{{$p.Ref}}/{{.Path}}">View raw</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}">Permalink</a>{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}?path={{.Path}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/{{.Path}}/">Permalink</a>{{end}}{{else}}<a href="/{{$p.Repo.Path}}/log/{{$p.Ref}}">Log</a><a href="/{{$p.Repo.Path}}/tree/{{$p.Commit}}/">Permalink</a>{{end}} |
| 22 | 22 | <span class="spacer"></span> |
| 23 | +<form method="get" action="/{{$p.Repo.Path}}/search" class="filesearch"><input type="hidden" name="ref" value="{{$p.Ref}}"><input name="q" placeholder="Search files" value=""></form> | |
| 23 | 24 | {{with .LastCommit}}<span class="lastcommit"><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Subject}} <span class="muted">{{ago .Time}}</span></span>{{end}} |
| 24 | 25 | </div> |
| 25 | 26 | {{end}} |
web.go modified
| @@ -78,6 +78,7 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 78 | 78 | "inc": func(i int) int { return i + 1 }, |
| 79 | 79 | "perm": perm, |
| 80 | 80 | "signer": s.signer, |
| 81 | + "mark": mark, | |
| 81 | 82 | "join": func(parts []string, upto int) string { return strings.Join(parts[:upto], "/") }, |
| 82 | 83 | "string": func(b []byte) string { return string(b) }, |
| 83 | 84 | } |
| @@ -646,6 +647,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 646 | 647 | s.log(w, r, repo, "") |
| 647 | 648 | case strings.HasPrefix(rest, "commit/") && r.Method == "GET": |
| 648 | 649 | s.commit(w, r, repo, strings.TrimPrefix(rest, "commit/")) |
| 650 | + case rest == "search" && r.Method == "GET": | |
| 651 | + s.search(w, r, repo) | |
| 649 | 652 | case rest == "pulls" && r.Method == "GET": |
| 650 | 653 | s.pulls(w, r, repo) |
| 651 | 654 | case rest == "pulls/new" && r.Method == "GET": |
| @@ -927,6 +930,44 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request, repo Repo, hash | ||
| 927 | 930 | s.render(w, r, "commit", page{Title: c.Subject, Data: map[string]any{"Page": rp, "Commit": c, "Files": files, "Added": added, "Removed": removed}}) |
| 928 | 931 | } |
| 929 | 932 | |
| 933 | +type matchGroup struct { | |
| 934 | + Path string | |
| 935 | + Matches []Match | |
| 936 | +} | |
| 937 | + | |
| 938 | +func (s *Server) search(w http.ResponseWriter, r *http.Request, repo Repo) { | |
| 939 | + ref := strings.TrimSpace(r.URL.Query().Get("ref")) | |
| 940 | + rp, g, err := s.repoPage(r, repo, ref) | |
| 941 | + if err != nil { | |
| 942 | + s.fail(w, r, 404, "No such ref") | |
| 943 | + return | |
| 944 | + } | |
| 945 | + rp.Tab = "tree" | |
| 946 | + query := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 947 | + data := map[string]any{"Page": rp, "Query": query} | |
| 948 | + if query != "" && !rp.Empty { | |
| 949 | + if len(query) > 200 { | |
| 950 | + query = query[:200] | |
| 951 | + } | |
| 952 | + matches, truncated, err := g.Grep(rp.Commit, query, 500) | |
| 953 | + if err != nil { | |
| 954 | + s.fail(w, r, 500, err.Error()) | |
| 955 | + return | |
| 956 | + } | |
| 957 | + var groups []matchGroup | |
| 958 | + for _, m := range matches { | |
| 959 | + if len(groups) == 0 || groups[len(groups)-1].Path != m.Path { | |
| 960 | + groups = append(groups, matchGroup{Path: m.Path}) | |
| 961 | + } | |
| 962 | + groups[len(groups)-1].Matches = append(groups[len(groups)-1].Matches, m) | |
| 963 | + } | |
| 964 | + data["Groups"] = groups | |
| 965 | + data["Count"] = len(matches) | |
| 966 | + data["Truncated"] = truncated | |
| 967 | + } | |
| 968 | + s.render(w, r, "search", page{Title: repo.Path() + " search", Data: data}) | |
| 969 | +} | |
| 970 | + | |
| 930 | 971 | func (s *Server) refs(w http.ResponseWriter, r *http.Request, repo Repo) { |
| 931 | 972 | rp, g, err := s.repoPage(r, repo, "") |
| 932 | 973 | if err != nil { |
| @@ -1004,6 +1045,29 @@ func ago(t time.Time) string { | ||
| 1004 | 1045 | } |
| 1005 | 1046 | } |
| 1006 | 1047 | |
| 1048 | +func mark(text, query string) template.HTML { | |
| 1049 | + if query == "" { | |
| 1050 | + return template.HTML(template.HTMLEscapeString(text)) | |
| 1051 | + } | |
| 1052 | + var b strings.Builder | |
| 1053 | + lower := strings.ToLower(text) | |
| 1054 | + q := strings.ToLower(query) | |
| 1055 | + for { | |
| 1056 | + i := strings.Index(lower, q) | |
| 1057 | + if i < 0 { | |
| 1058 | + b.WriteString(template.HTMLEscapeString(text)) | |
| 1059 | + break | |
| 1060 | + } | |
| 1061 | + b.WriteString(template.HTMLEscapeString(text[:i])) | |
| 1062 | + b.WriteString("<mark>") | |
| 1063 | + b.WriteString(template.HTMLEscapeString(text[i : i+len(q)])) | |
| 1064 | + b.WriteString("</mark>") | |
| 1065 | + text = text[i+len(q):] | |
| 1066 | + lower = lower[i+len(q):] | |
| 1067 | + } | |
| 1068 | + return template.HTML(b.String()) | |
| 1069 | +} | |
| 1070 | + | |
| 1007 | 1071 | func perm(mode string) string { |
| 1008 | 1072 | bits := func(o string) string { |
| 1009 | 1073 | n, _ := strconv.ParseInt(o, 8, 16) |