Commit 4f3bd4893b
Verified · cmc
cmd/gitbay/main.go +2
| @@ -216,6 +216,8 @@ func repoCmd() *cobra.Command { | ||
| 216 | 216 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), |
| 217 | 217 | pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}), |
| 218 | 218 | pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}), |
| 219 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), | |
| 220 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), | |
| 219 | 221 | pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), |
| 220 | 222 | pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), |
| 221 | 223 | local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), |
docs/users.org +2
| @@ -96,6 +96,8 @@ gitbay repo settings protect you/project main # no force-push, no delete | ||
| 96 | 96 | gitbay repo settings require-signed you/project on # every commit must verify |
| 97 | 97 | gitbay repo settings git-daemon you/project on # expose over git:// |
| 98 | 98 | gitbay repo topics add you/project cli forge # free-form tags, shown on the web |
| 99 | gitbay repo search forge # find repos by name/description/topic | |
| 100 | gitbay repo grep you/project "some string" # literal git grep over the default branch | |
| 99 | 101 | gitbay repo archive you/project # read-only: pushes and issue/MR |
| 100 | 102 | gitbay repo unarchive you/project # writes refused, browsing intact |
| 101 | 103 | #+end_src |
e2e/search_test.go added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func TestSearch(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | bobKey := inst.newKey(t, "bob") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", | |
| 15 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 16 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 17 | ||
| 18 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/webapp", | |
| 19 | "--description", "'a small web application'"); code != 0 { | |
| 20 | t.Fatalf("repo create: %s", errOut) | |
| 21 | } | |
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "topics", "add", "alice/webapp", "golang"); code != 0 { | |
| 23 | t.Fatal("topics add failed") | |
| 24 | } | |
| 25 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private", | |
| 26 | "--description", "'hidden things'"); code != 0 { | |
| 27 | t.Fatal("private repo create failed") | |
| 28 | } | |
| 29 | ||
| 30 | work := t.TempDir() | |
| 31 | env := inst.gitEnv(aliceKey) | |
| 32 | mustGit(t, work, env, "clone", inst.sshURL("alice/webapp"), "w") | |
| 33 | dir := filepath.Join(work, "w") | |
| 34 | os.WriteFile(filepath.Join(dir, "main.go"), | |
| 35 | []byte("package main\n\nfunc Greet() string {\n\treturn \"hello, forge\"\n}\n"), 0o644) | |
| 36 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 37 | mustGit(t, dir, env, "add", ".") | |
| 38 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 39 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 40 | ||
| 41 | // repo search over SSH: by name, description, and topic; visibility | |
| 42 | // respected — bob never sees the private repo, alice does. | |
| 43 | out, _, code := inst.ssh(t, aliceKey, "", "repo", "search", "webapp") | |
| 44 | if code != 0 || !strings.Contains(out, "alice/webapp") { | |
| 45 | t.Fatalf("search by name: %s", out) | |
| 46 | } | |
| 47 | if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "application"); !strings.Contains(out, "alice/webapp") { | |
| 48 | t.Fatalf("search by description: %s", out) | |
| 49 | } | |
| 50 | if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "golang"); !strings.Contains(out, "alice/webapp") { | |
| 51 | t.Fatalf("search by topic: %s", out) | |
| 52 | } | |
| 53 | if out, _, _ = inst.ssh(t, bobKey, "", "repo", "search", "secret"); strings.Contains(out, "alice/secret") { | |
| 54 | t.Fatal("private repo leaked to bob's search") | |
| 55 | } | |
| 56 | if out, _, _ = inst.ssh(t, aliceKey, "", "repo", "search", "hidden"); !strings.Contains(out, "alice/secret") { | |
| 57 | t.Fatalf("owner's search missed private repo: %s", out) | |
| 58 | } | |
| 59 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "search", "x"); code != 2 || !strings.Contains(errOut, "2 to 200") { | |
| 60 | t.Fatalf("short query: exit %d, %s", code, errOut) | |
| 61 | } | |
| 62 | ||
| 63 | // repo grep over SSH: literal, case-insensitive, 404-parity on private. | |
| 64 | out, _, code = inst.ssh(t, aliceKey, "", "repo", "grep", "alice/webapp", "HELLO", "--json") | |
| 65 | if code != 0 || !strings.Contains(out, `"path":"main.go"`) || !strings.Contains(out, `"line":4`) { | |
| 66 | t.Fatalf("grep: %s", out) | |
| 67 | } | |
| 68 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "grep", "alice/secret", "anything"); code != 3 || !strings.Contains(errOut, "not found") { | |
| 69 | t.Fatalf("private grep parity: exit %d, %s", code, errOut) | |
| 70 | } | |
| 71 | ||
| 72 | // Web: index filter respects the query and visibility. | |
| 73 | status, body := inst.get(t, "/?q=webapp") | |
| 74 | if status != 200 || !strings.Contains(body, "alice/webapp") { | |
| 75 | t.Fatalf("index filter: %d", status) | |
| 76 | } | |
| 77 | _, body = inst.get(t, "/?q=nomatchhere") | |
| 78 | if strings.Contains(body, "alice/webapp") { | |
| 79 | t.Fatal("index filter did not filter") | |
| 80 | } | |
| 81 | _, body = inst.get(t, "/?q=hidden") | |
| 82 | if strings.Contains(body, "secret") { | |
| 83 | t.Fatal("private repo leaked on index search") | |
| 84 | } | |
| 85 | ||
| 86 | // Web: repo code search with mark and blob line anchor. | |
| 87 | status, body = inst.get(t, "/alice/webapp/search?q=hello") | |
| 88 | if status != 200 || !strings.Contains(body, `<mark>hello</mark>`) || | |
| 89 | !strings.Contains(body, `/alice/webapp/blob/main/main.go#L4">main.go:4</a>`) { | |
| 90 | t.Fatalf("repo search page: %d\n%s", status, body) | |
| 91 | } | |
| 92 | _, body = inst.get(t, "/alice/webapp/search?q=zzznothing") | |
| 93 | if !strings.Contains(body, "no matches") { | |
| 94 | t.Fatal("empty state missing") | |
| 95 | } | |
| 96 | _, body = inst.get(t, "/alice/webapp/search?q=x") | |
| 97 | if !strings.Contains(body, "2 to 200") { | |
| 98 | t.Fatal("short query error missing") | |
| 99 | } | |
| 100 | ||
| 101 | // Blob line numbers are linkable anchors for the search links. | |
| 102 | _, body = inst.get(t, "/alice/webapp/blob/main/main.go") | |
| 103 | if !strings.Contains(body, `id="L4"`) { | |
| 104 | t.Fatal("blob line anchors missing") | |
| 105 | } | |
| 106 | } | |
internal/control/repo.go +133
| @@ -60,6 +60,23 @@ func init() { | ||
| 60 | 60 | Summary: "add topics: repo topics add <owner/name> <topic>...", Run: runTopicsAdd}) |
| 61 | 61 | register(Command{Path: []string{"repo", "topics", "remove"}, |
| 62 | 62 | Summary: "remove topics: repo topics remove <owner/name> <topic>...", Run: runTopicsRemove}) |
| 63 | register(Command{Path: []string{"repo", "search"}, | |
| 64 | Summary: "find repositories by name, description, or topic: repo search <query>", ReadOnly: true, Run: runRepoSearch}) | |
| 65 | register(Command{Path: []string{"repo", "grep"}, | |
| 66 | Summary: "search file contents: repo grep <owner/name> <query> [--ref <ref>]", ReadOnly: true, Run: runRepoGrep}) | |
| 67 | } | |
| 68 | ||
| 69 | const ( | |
| 70 | minQueryLen = 2 | |
| 71 | maxQueryLen = 200 | |
| 72 | maxGrepMatches = 200 | |
| 73 | ) | |
| 74 | ||
| 75 | func validQuery(q string) error { | |
| 76 | if len(q) < minQueryLen || len(q) > maxQueryLen { | |
| 77 | return fmt.Errorf("query must be %d to %d characters", minQueryLen, maxQueryLen) | |
| 78 | } | |
| 79 | return nil | |
| 63 | 80 | } |
| 64 | 81 | |
| 65 | 82 | // refuseArchived blocks content writes (pushes are refused in the transport |
| @@ -557,6 +574,122 @@ func editTopics(c *Ctx, args []string, add bool) int { | ||
| 557 | 574 | }) |
| 558 | 575 | } |
| 559 | 576 | |
| 577 | // runRepoSearch matches the query against name, owner/name, description, | |
| 578 | // and topics of every repository the caller can see. | |
| 579 | func runRepoSearch(c *Ctx, args []string) int { | |
| 580 | if len(args) != 1 { | |
| 581 | return c.fail(protocol.ExitUsage, "usage: repo search <query>") | |
| 582 | } | |
| 583 | if err := validQuery(args[0]); err != nil { | |
| 584 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 585 | } | |
| 586 | q := strings.ToLower(args[0]) | |
| 587 | ||
| 588 | public, err := c.Store.ListPublicRepos() | |
| 589 | if err != nil { | |
| 590 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 591 | } | |
| 592 | own, err := c.Store.ListReposForUser(c.User.ID) | |
| 593 | if err != nil { | |
| 594 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 595 | } | |
| 596 | seen := map[int64]bool{} | |
| 597 | type out struct { | |
| 598 | Path string `json:"path"` | |
| 599 | Visibility string `json:"visibility"` | |
| 600 | Description string `json:"description,omitempty"` | |
| 601 | Topics []string `json:"topics,omitempty"` | |
| 602 | } | |
| 603 | var ds []out | |
| 604 | for _, r := range append(public, own...) { | |
| 605 | if seen[r.ID] { | |
| 606 | continue | |
| 607 | } | |
| 608 | seen[r.ID] = true | |
| 609 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) | |
| 610 | topics, _ := c.Store.ListTopics(r.ID) | |
| 611 | if !matchesRepo(q, r, desc, topics) { | |
| 612 | continue | |
| 613 | } | |
| 614 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) | |
| 615 | } | |
| 616 | return c.emit(ds, func(w io.Writer) { | |
| 617 | for _, d := range ds { | |
| 618 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Path, d.Visibility, d.Description) | |
| 619 | } | |
| 620 | }) | |
| 621 | } | |
| 622 | ||
| 623 | func matchesRepo(q string, r store.Repo, desc string, topics []string) bool { | |
| 624 | if strings.Contains(strings.ToLower(r.Path()), q) || | |
| 625 | strings.Contains(strings.ToLower(desc), q) { | |
| 626 | return true | |
| 627 | } | |
| 628 | for _, t := range topics { | |
| 629 | if strings.Contains(t, q) { | |
| 630 | return true | |
| 631 | } | |
| 632 | } | |
| 633 | return false | |
| 634 | } | |
| 635 | ||
| 636 | func runRepoGrep(c *Ctx, args []string) int { | |
| 637 | var path, query, ref string | |
| 638 | for i := 0; i < len(args); i++ { | |
| 639 | switch args[i] { | |
| 640 | case "--ref": | |
| 641 | if i+1 >= len(args) { | |
| 642 | return c.fail(protocol.ExitUsage, "--ref requires a value") | |
| 643 | } | |
| 644 | ref = args[i+1] | |
| 645 | i++ | |
| 646 | default: | |
| 647 | if path == "" { | |
| 648 | path = args[i] | |
| 649 | } else if query == "" { | |
| 650 | query = args[i] | |
| 651 | } else { | |
| 652 | return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]") | |
| 653 | } | |
| 654 | } | |
| 655 | } | |
| 656 | if path == "" || query == "" { | |
| 657 | return c.fail(protocol.ExitUsage, "usage: repo grep <owner/name> <query> [--ref <ref>]") | |
| 658 | } | |
| 659 | if err := validQuery(query); err != nil { | |
| 660 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 661 | } | |
| 662 | repo, code := resolveRepo(c, path, policy.CanRead) | |
| 663 | if code >= 0 { | |
| 664 | return code | |
| 665 | } | |
| 666 | if ref == "" { | |
| 667 | ref = repo.DefaultBranch | |
| 668 | } | |
| 669 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 670 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | |
| 671 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 672 | } | |
| 673 | matches, err := gitutil.Grep(dir, ref, query, maxGrepMatches) | |
| 674 | if err != nil { | |
| 675 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 676 | } | |
| 677 | type out struct { | |
| 678 | Path string `json:"path"` | |
| 679 | Line int `json:"line"` | |
| 680 | Text string `json:"text"` | |
| 681 | } | |
| 682 | var ds []out | |
| 683 | for _, m := range matches { | |
| 684 | ds = append(ds, out{m.Path, m.Line, m.Text}) | |
| 685 | } | |
| 686 | return c.emit(ds, func(w io.Writer) { | |
| 687 | for _, d := range ds { | |
| 688 | fmt.Fprintf(w, "%s:%d:%s\n", d.Path, d.Line, d.Text) | |
| 689 | } | |
| 690 | }) | |
| 691 | } | |
| 692 | ||
| 560 | 693 | func runProtect(c *Ctx, args []string) int { return setProtect(c, args, true) } |
| 561 | 694 | func runUnprotect(c *Ctx, args []string) int { return setProtect(c, args, false) } |
| 562 | 695 | |
internal/gitutil/grep.go added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "context" | |
| 5 | "fmt" | |
| 6 | "os/exec" | |
| 7 | "strconv" | |
| 8 | "strings" | |
| 9 | "time" | |
| 10 | ) | |
| 11 | ||
| 12 | type GrepMatch struct { | |
| 13 | Path string | |
| 14 | Line int | |
| 15 | Text string | |
| 16 | } | |
| 17 | ||
| 18 | // Grep runs a literal, case-insensitive git grep over the tree at ref, | |
| 19 | // skipping binary files. Matches are capped at max; "no matches" is an | |
| 20 | // empty result, not an error. | |
| 21 | func Grep(dir, ref, query string, max int) ([]GrepMatch, error) { | |
| 22 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 23 | defer cancel() | |
| 24 | // -z: NUL after the path and the line number, so paths containing | |
| 25 | // ':' parse unambiguously (format: "ref:path\0line\0text\n"). | |
| 26 | cmd := exec.CommandContext(ctx, "git", "-C", dir, "grep", "-nIiF", "-z", "-e", query, ref) | |
| 27 | out, err := cmd.Output() | |
| 28 | if err != nil { | |
| 29 | if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { | |
| 30 | return nil, nil | |
| 31 | } | |
| 32 | return nil, fmt.Errorf("git grep at %s: %w", ref, err) | |
| 33 | } | |
| 34 | var matches []GrepMatch | |
| 35 | for _, line := range strings.Split(string(out), "\n") { | |
| 36 | if line == "" { | |
| 37 | continue | |
| 38 | } | |
| 39 | parts := strings.SplitN(line, "\x00", 3) | |
| 40 | if len(parts) != 3 { | |
| 41 | continue | |
| 42 | } | |
| 43 | n, err := strconv.Atoi(parts[1]) | |
| 44 | if err != nil { | |
| 45 | continue | |
| 46 | } | |
| 47 | matches = append(matches, GrepMatch{ | |
| 48 | Path: strings.TrimPrefix(parts[0], ref+":"), | |
| 49 | Line: n, | |
| 50 | Text: parts[2], | |
| 51 | }) | |
| 52 | if len(matches) >= max { | |
| 53 | break | |
| 54 | } | |
| 55 | } | |
| 56 | return matches, nil | |
| 57 | } | |
internal/httpd/routes.go +1
| @@ -34,6 +34,7 @@ func (s *Server) Routes() []Route { | ||
| 34 | 34 | Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, |
| 35 | 35 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, |
| 36 | 36 | Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame}, |
| 37 | Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search}, | |
| 37 | 38 | Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw}, |
| 38 | 39 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log}, |
| 39 | 40 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log}, |
internal/httpd/web.go +91 −2
| @@ -103,12 +103,40 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 103 | 103 | } |
| 104 | 104 | } |
| 105 | 105 | } |
| 106 | q := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 106 | 107 | s.render(w, "index.html", struct { |
| 107 | 108 | Site string |
| 108 | 109 | Viewer string |
| 110 | Query string | |
| 109 | 111 | Repos []describedRepo |
| 110 | 112 | Mine []describedRepo |
| 111 | }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)}) | |
| 113 | }{s.siteName(), viewer.Username, q, | |
| 114 | s.filterRepos(q, s.describeAll(repos)), s.filterRepos(q, s.describeAll(mine))}) | |
| 115 | } | |
| 116 | ||
| 117 | // filterRepos keeps repos whose path, description, or topics contain the | |
| 118 | // query, case-insensitively. An empty query keeps everything. | |
| 119 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { | |
| 120 | if q == "" { | |
| 121 | return repos | |
| 122 | } | |
| 123 | q = strings.ToLower(q) | |
| 124 | var out []describedRepo | |
| 125 | for _, d := range repos { | |
| 126 | if strings.Contains(strings.ToLower(d.Path()), q) || | |
| 127 | strings.Contains(strings.ToLower(d.Desc), q) { | |
| 128 | out = append(out, d) | |
| 129 | continue | |
| 130 | } | |
| 131 | topics, _ := s.st.ListTopics(d.ID) | |
| 132 | for _, t := range topics { | |
| 133 | if strings.Contains(t, q) { | |
| 134 | out = append(out, d) | |
| 135 | break | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
| 139 | return out | |
| 112 | 140 | } |
| 113 | 141 | |
| 114 | 142 | // repoPage is the shared context for repo-scoped pages. |
| @@ -329,6 +357,66 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 329 | 357 | }{p, cs, base, filePath, binary, len(data), codeHTML}) |
| 330 | 358 | } |
| 331 | 359 | |
| 360 | // search runs a bounded literal git grep over the repo's default branch. | |
| 361 | func (s *Server) search(w http.ResponseWriter, r *http.Request) { | |
| 362 | p, ok := s.repoFor(w, r, "") | |
| 363 | if !ok { | |
| 364 | return | |
| 365 | } | |
| 366 | p.Tab = "search" | |
| 367 | q := strings.TrimSpace(r.URL.Query().Get("q")) | |
| 368 | type matchView struct { | |
| 369 | Path string | |
| 370 | Line int | |
| 371 | TextHTML template.HTML | |
| 372 | } | |
| 373 | var matches []matchView | |
| 374 | var queryErr string | |
| 375 | if q != "" { | |
| 376 | if len(q) < 2 || len(q) > 200 { | |
| 377 | queryErr = "query must be 2 to 200 characters" | |
| 378 | } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil { | |
| 379 | raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200) | |
| 380 | if err != nil { | |
| 381 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 382 | return | |
| 383 | } | |
| 384 | for _, m := range raw { | |
| 385 | matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)}) | |
| 386 | } | |
| 387 | } | |
| 388 | } | |
| 389 | s.render(w, "search.html", struct { | |
| 390 | repoPage | |
| 391 | Query string | |
| 392 | QueryErr string | |
| 393 | Matches []matchView | |
| 394 | Capped bool | |
| 395 | }{p, q, queryErr, matches, len(matches) == 200}) | |
| 396 | } | |
| 397 | ||
| 398 | // markMatch escapes a matched line and wraps case-insensitive occurrences | |
| 399 | // of the query in <mark>. | |
| 400 | func markMatch(text, q string) template.HTML { | |
| 401 | lower, lq := strings.ToLower(text), strings.ToLower(q) | |
| 402 | var b strings.Builder | |
| 403 | pos := 0 | |
| 404 | for { | |
| 405 | i := strings.Index(lower[pos:], lq) | |
| 406 | if i < 0 { | |
| 407 | break | |
| 408 | } | |
| 409 | i += pos | |
| 410 | b.WriteString(template.HTMLEscapeString(text[pos:i])) | |
| 411 | b.WriteString("<mark>") | |
| 412 | b.WriteString(template.HTMLEscapeString(text[i : i+len(q)])) | |
| 413 | b.WriteString("</mark>") | |
| 414 | pos = i + len(q) | |
| 415 | } | |
| 416 | b.WriteString(template.HTMLEscapeString(text[pos:])) | |
| 417 | return template.HTML(b.String()) | |
| 418 | } | |
| 419 | ||
| 332 | 420 | // blamePageSize caps how many lines one blame page renders; blame is a |
| 333 | 421 | // per-line subprocess cost, so large files paginate. |
| 334 | 422 | const blamePageSize = 1000 |
| @@ -417,7 +505,8 @@ func highlight(filePath string, data []byte) template.HTML { | ||
| 417 | 505 | lexer = lexers.Fallback |
| 418 | 506 | } |
| 419 | 507 | style := styles.Get("friendly") |
| 420 | formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false)) | |
| 508 | formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false), | |
| 509 | html.WithLinkableLineNumbers(true, "L")) | |
| 421 | 510 | iterator, err := lexer.Tokenise(nil, string(data)) |
| 422 | 511 | if err != nil { |
| 423 | 512 | return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") |
internal/web/static/style.css +30
| @@ -578,6 +578,36 @@ footer p { margin: 0; } | ||
| 578 | 578 | footer a { color: var(--muted); text-decoration: underline; } |
| 579 | 579 | footer a:hover { color: var(--accent); } |
| 580 | 580 | |
| 581 | /* search */ | |
| 582 | form.searchform { | |
| 583 | display: flex; | |
| 584 | gap: var(--sp-2); | |
| 585 | margin: var(--sp-3) 0 var(--sp-4); | |
| 586 | } | |
| 587 | form.searchform input[type="search"] { flex: 1; max-width: 32rem; } | |
| 588 | form.searchform.compact { margin: 0; } | |
| 589 | form.searchform.compact input[type="search"] { | |
| 590 | padding: var(--sp-1) var(--sp-3); | |
| 591 | font-size: var(--fs-2); | |
| 592 | min-width: 18rem; | |
| 593 | } | |
| 594 | ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; } | |
| 595 | ul.matchlist li { margin-bottom: var(--sp-3); } | |
| 596 | ul.matchlist .matchpath { font-family: var(--mono); font-size: var(--fs-1); } | |
| 597 | pre.matchline { | |
| 598 | background: var(--code-bg); | |
| 599 | border: 1px solid var(--faint); | |
| 600 | border-radius: var(--r-md); | |
| 601 | padding: var(--sp-1) var(--sp-3); | |
| 602 | margin: var(--sp-1) 0 0; | |
| 603 | overflow-x: auto; | |
| 604 | } | |
| 605 | pre.matchline mark { | |
| 606 | background: color-mix(in srgb, var(--warn) 25%, transparent); | |
| 607 | color: inherit; | |
| 608 | border-radius: 2px; | |
| 609 | } | |
| 610 | ||
| 581 | 611 | /* blame */ |
| 582 | 612 | .blame { |
| 583 | 613 | border: 1px solid var(--line); |
internal/web/templates/index.html +3
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "content"}} |
| 3 | 3 | <div class="headrow"> |
| 4 | 4 | <h1>repositories</h1> |
| 5 | <form method="get" action="/" class="searchform compact"> | |
| 6 | <input type="search" name="q" value="{{.Query}}" placeholder="filter by name, description, topic"> | |
| 7 | </form> | |
| 5 | 8 | <span class="spacer"></span> |
| 6 | 9 | {{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> · |
| 7 | 10 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}} |
internal/web/templates/layout.html +1
| @@ -31,6 +31,7 @@ | ||
| 31 | 31 | <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> |
| 32 | 32 | <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> |
| 33 | 33 | <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> |
| 34 | <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a> | |
| 34 | 35 | <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> |
| 35 | 36 | </nav> |
| 36 | 37 | </div> |
internal/web/templates/search.html added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | {{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 2 | {{define "content"}} | |
| 3 | {{template "repoheader" .}} | |
| 4 | <form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> | |
| 5 | <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> | |
| 6 | <button type="submit">search</button> | |
| 7 | </form> | |
| 8 | {{if .QueryErr}}<p class="error">{{.QueryErr}}</p> | |
| 9 | {{else if .Query}} | |
| 10 | {{if .Matches}} | |
| 11 | <p class="meta">{{len .Matches}} match{{if ne (len .Matches) 1}}es{{end}}{{if .Capped}} (capped — refine the query){{end}} on <code>{{.Ref}}</code></p> | |
| 12 | <ul class="matchlist"> | |
| 13 | {{range .Matches}}<li> | |
| 14 | <a class="matchpath" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}#L{{.Line}}">{{.Path}}:{{.Line}}</a> | |
| 15 | <pre class="matchline">{{.TextHTML}}</pre> | |
| 16 | </li> | |
| 17 | {{end}} | |
| 18 | </ul> | |
| 19 | {{else}}<p class="empty-note">no matches for “{{.Query}}” on <code>{{.Ref}}</code></p>{{end}} | |
| 20 | {{end}} | |
| 21 | {{end}} | |