Commit 251a71053c
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -59,6 +59,8 @@ func newRoot() *cobra.Command { | |||
| 59 | passOpts{server: []string{"feed"}}), | 59 | passOpts{server: []string{"feed"}}), |
| 60 | pass("explore", "public repositories on this instance [--limit n] [--cursor c]", | 60 | pass("explore", "public repositories on this instance [--limit n] [--cursor c]", |
| 61 | passOpts{server: []string{"explore"}}), | 61 | passOpts{server: []string{"explore"}}), |
| 62 | pass("search", "find repositories, issues and merge requests: <query> [--kind repo|issue|mr]", | ||
| 63 | passOpts{server: []string{"search"}}), | ||
| 62 | group("notifications", "your notification inbox", | 64 | group("notifications", "your notification inbox", |
| 63 | pass("list", "unread notifications, or [--all] [--limit n] [--cursor c]", | 65 | pass("list", "unread notifications, or [--all] [--limit n] [--cursor c]", |
| 64 | passOpts{server: []string{"notifications", "list"}}), | 66 | passOpts{server: []string{"notifications", "list"}}), |
e2e/readonly_test.go +1
| @@ -143,6 +143,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 143 | "release show": {"alice/app", "v1"}, | 143 | "release show": {"alice/app", "v1"}, |
| 144 | "release asset get": {"alice/app", "v1", "a.txt"}, | 144 | "release asset get": {"alice/app", "v1", "a.txt"}, |
| 145 | "notifications list": nil, | 145 | "notifications list": nil, |
| 146 | "search": {"app"}, | ||
| 146 | "webhook list": {"alice/app"}, | 147 | "webhook list": {"alice/app"}, |
| 147 | "webhook deliveries": {"alice/app"}, | 148 | "webhook deliveries": {"alice/app"}, |
| 148 | "wiki list": {"alice/app"}, | 149 | "wiki list": {"alice/app"}, |
e2e/searchweb_test.go added +111
| @@ -0,0 +1,111 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | // TestGlobalSearchAndNotificationsWeb covers the two web surfaces #118 | ||
| 10 | // still lacked: /search across the instance, and the notification inbox. | ||
| 11 | func TestGlobalSearchAndNotificationsWeb(t *testing.T) { | ||
| 12 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 13 | aliceKey := inst.newKey(t, "alice") | ||
| 14 | bobKey := inst.newKey(t, "bob") | ||
| 15 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 16 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 17 | |||
| 18 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/widget"); code != 0 { | ||
| 19 | t.Fatalf("repo create: %s", errOut) | ||
| 20 | } | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { | ||
| 22 | t.Fatalf("private repo create: %s", errOut) | ||
| 23 | } | ||
| 24 | if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/widget", | ||
| 25 | "--title", "'widget leaks memory'", "--body", "'it climbs'"); code != 0 { | ||
| 26 | t.Fatalf("issue create: %s", errOut) | ||
| 27 | } | ||
| 28 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", | ||
| 29 | "--title", "'widget in the private repo'"); code != 0 { | ||
| 30 | t.Fatalf("private issue create: %s", errOut) | ||
| 31 | } | ||
| 32 | |||
| 33 | // Anonymous search reaches the public repository and its issue, and | ||
| 34 | // neither the private repository nor its issue. | ||
| 35 | status, body := inst.get(t, "/search?q=widget") | ||
| 36 | if status != 200 { | ||
| 37 | t.Fatalf("anonymous search: %d", status) | ||
| 38 | } | ||
| 39 | if !strings.Contains(body, `href="/alice/widget"`) || | ||
| 40 | !strings.Contains(body, "widget leaks memory") || | ||
| 41 | !strings.Contains(body, `href="/alice/widget/issues/1"`) { | ||
| 42 | t.Fatalf("anonymous search missing public hits:\n%s", body) | ||
| 43 | } | ||
| 44 | if strings.Contains(body, "alice/secret") || strings.Contains(body, "private repo") { | ||
| 45 | t.Fatal("anonymous search leaked a private repository") | ||
| 46 | } | ||
| 47 | |||
| 48 | // Filtering by kind keeps the repository row and drops the issue. | ||
| 49 | _, body = inst.get(t, "/search?q=widget&kind=repo") | ||
| 50 | if !strings.Contains(body, `href="/alice/widget"`) || strings.Contains(body, "widget leaks memory") { | ||
| 51 | t.Fatalf("kind=repo filter:\n%s", body) | ||
| 52 | } | ||
| 53 | _, body = inst.get(t, "/search?q=x") | ||
| 54 | if !strings.Contains(body, "2 to 200 characters") { | ||
| 55 | t.Fatal("short query not refused") | ||
| 56 | } | ||
| 57 | |||
| 58 | // Alice sees her private repository and its issue in the same page. | ||
| 59 | browser := inst.login(t, aliceKey) | ||
| 60 | status, body = browserGet(t, browser, inst.base()+"/search?q=widget") | ||
| 61 | if status != 200 || !strings.Contains(body, "alice/secret") || | ||
| 62 | !strings.Contains(body, "widget in the private repo") { | ||
| 63 | t.Fatalf("owner search misses private rows:\n%s", body) | ||
| 64 | } | ||
| 65 | |||
| 66 | // The rail carries the unread badge and the notifications page lists | ||
| 67 | // the issue bob opened. | ||
| 68 | status, body = browserGet(t, browser, inst.base()+"/notifications") | ||
| 69 | if status != 200 || !strings.Contains(body, "bob opened issue #1") || | ||
| 70 | !strings.Contains(body, `href="/alice/widget/issues/1"`) { | ||
| 71 | t.Fatalf("notifications page:\n%s", body) | ||
| 72 | } | ||
| 73 | if !strings.Contains(body, `href="/notifications"`) { | ||
| 74 | t.Fatal("rail has no notifications link") | ||
| 75 | } | ||
| 76 | |||
| 77 | // Marking all read empties the unread list but not the full one. | ||
| 78 | if status, _ := browserPost(t, browser, inst.base()+"/notifications", url.Values{}); status != 200 { | ||
| 79 | t.Fatalf("mark all read: %d", status) | ||
| 80 | } | ||
| 81 | _, body = browserGet(t, browser, inst.base()+"/notifications") | ||
| 82 | if !strings.Contains(body, "nothing unread") { | ||
| 83 | t.Fatalf("unread list after sweep:\n%s", body) | ||
| 84 | } | ||
| 85 | _, body = browserGet(t, browser, inst.base()+"/notifications?all=1") | ||
| 86 | if !strings.Contains(body, "bob opened issue #1") { | ||
| 87 | t.Fatalf("all list after sweep:\n%s", body) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Watching from the repository header is the same state the CLI sets. | ||
| 91 | if status, _ := browserPost(t, browser, inst.base()+"/alice/widget/watch", url.Values{}); status != 200 { | ||
| 92 | t.Fatalf("watch toggle: %d", status) | ||
| 93 | } | ||
| 94 | out, errOut, code := inst.ssh(t, aliceKey, "", "notifications", "list", "--all", "--json") | ||
| 95 | if code != 0 { | ||
| 96 | t.Fatalf("notifications list: %s", errOut) | ||
| 97 | } | ||
| 98 | if !strings.Contains(out, "alice/widget") { | ||
| 99 | t.Fatalf("inbox over ssh: %s", out) | ||
| 100 | } | ||
| 101 | _, body = browserGet(t, browser, inst.base()+"/alice/widget") | ||
| 102 | if !strings.Contains(body, ">Watching</button>") { | ||
| 103 | t.Fatalf("repo header does not show the watch state:\n%s", body) | ||
| 104 | } | ||
| 105 | |||
| 106 | // Anonymous visitors get the search page; the inbox sends them to log | ||
| 107 | // in. http.Get follows the redirect, so the login page is the proof. | ||
| 108 | if status, body := inst.get(t, "/notifications"); status != 200 || !strings.Contains(body, "Sign in") { | ||
| 109 | t.Fatalf("anonymous /notifications: %d\n%s", status, body) | ||
| 110 | } | ||
| 111 | } | ||
internal/control/repo.go +9 −4
| @@ -783,7 +783,7 @@ func runRepoSearch(c *Ctx, args []string) int { | |||
| 783 | seen[r.ID] = true | 783 | seen[r.ID] = true |
| 784 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) | 784 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) |
| 785 | topics, _ := c.Store.ListTopics(r.ID) | 785 | topics, _ := c.Store.ListTopics(r.ID) |
| 786 | if !matchesRepo(q, r, desc, topics) { | 786 | if !MatchesRepo(q, r.Path(), desc, topics) { |
| 787 | continue | 787 | continue |
| 788 | } | 788 | } |
| 789 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) | 789 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) |
| @@ -795,13 +795,18 @@ func runRepoSearch(c *Ctx, args []string) int { | |||
| 795 | }) | 795 | }) |
| 796 | } | 796 | } |
| 797 | 797 | ||
| 798 | func matchesRepo(q string, r store.Repo, desc string, topics []string) bool { | 798 | // MatchesRepo is the one rule for matching a repository against a text |
| 799 | if strings.Contains(strings.ToLower(r.Path()), q) || | 799 | // query: its path, its description, or any of its topics. The web's |
| 800 | // /explore filter and /search page call it too, so the three surfaces | ||
| 801 | // cannot answer the same query differently. | ||
| 802 | func MatchesRepo(q, path, desc string, topics []string) bool { | ||
| 803 | q = strings.ToLower(q) | ||
| 804 | if strings.Contains(strings.ToLower(path), q) || | ||
| 800 | strings.Contains(strings.ToLower(desc), q) { | 805 | strings.Contains(strings.ToLower(desc), q) { |
| 801 | return true | 806 | return true |
| 802 | } | 807 | } |
| 803 | for _, t := range topics { | 808 | for _, t := range topics { |
| 804 | if strings.Contains(t, q) { | 809 | if strings.Contains(strings.ToLower(t), q) { |
| 805 | return true | 810 | return true |
| 806 | } | 811 | } |
| 807 | } | 812 | } |
internal/control/search.go added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | "slices" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/gitutil" | ||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | register(Command{Path: []string{"search"}, | ||
| 15 | Summary: "find repositories, issues and merge requests across the instance", | ||
| 16 | Usage: "search <query> [--kind repo|issue|mr]", | ||
| 17 | ReadOnly: true, Run: runSearch}) | ||
| 18 | } | ||
| 19 | |||
| 20 | // searchLimit caps each kind of result. A query that hits more than this | ||
| 21 | // wants narrowing, not a longer page. | ||
| 22 | const searchLimit = 50 | ||
| 23 | |||
| 24 | // SearchResult is one match, in the shape all three kinds share. | ||
| 25 | type SearchResult struct { | ||
| 26 | Kind string `json:"kind"` // repo, issue, or mr | ||
| 27 | Repo string `json:"repo"` | ||
| 28 | Number int64 `json:"number,omitempty"` | ||
| 29 | Title string `json:"title"` | ||
| 30 | Author string `json:"author,omitempty"` | ||
| 31 | State string `json:"state,omitempty"` | ||
| 32 | UpdatedAt string `json:"updated_at,omitempty"` | ||
| 33 | } | ||
| 34 | |||
| 35 | // Search runs a query for a user, or for an anonymous visitor when userID | ||
| 36 | // is 0, in which case only public repositories are reached. kinds names | ||
| 37 | // which of repo, issue and mr to look at; empty means all three. It is | ||
| 38 | // exported because the web's /search page renders it for readers who have | ||
| 39 | // no session to dispatch a command as. | ||
| 40 | func Search(st *store.Store, root string, userID int64, q string, kinds []string) ([]SearchResult, error) { | ||
| 41 | want := func(kind string) bool { return len(kinds) == 0 || slices.Contains(kinds, kind) } | ||
| 42 | out := []SearchResult{} | ||
| 43 | if want("repo") { | ||
| 44 | repos, err := st.VisibleRepos(userID) | ||
| 45 | if err != nil { | ||
| 46 | return nil, err | ||
| 47 | } | ||
| 48 | for _, r := range repos { | ||
| 49 | desc := gitutil.ReadDescription(RepoDir(root, r.OwnerName, r.Name)) | ||
| 50 | topics, _ := st.ListTopics(r.ID) | ||
| 51 | if !MatchesRepo(q, r.Path(), desc, topics) { | ||
| 52 | continue | ||
| 53 | } | ||
| 54 | out = append(out, SearchResult{Kind: "repo", Repo: r.Path(), Title: desc}) | ||
| 55 | if len(out) == searchLimit { | ||
| 56 | break | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | for _, k := range []struct { | ||
| 61 | kind string | ||
| 62 | query func(int64, string, int) ([]store.DashboardItem, error) | ||
| 63 | }{{"issue", st.SearchIssues}, {"mr", st.SearchMRs}} { | ||
| 64 | if !want(k.kind) { | ||
| 65 | continue | ||
| 66 | } | ||
| 67 | items, err := k.query(userID, q, searchLimit) | ||
| 68 | if err != nil { | ||
| 69 | return nil, err | ||
| 70 | } | ||
| 71 | for _, it := range items { | ||
| 72 | out = append(out, SearchResult{Kind: k.kind, Repo: it.RepoPath, Number: it.Number, | ||
| 73 | Title: it.Title, Author: it.Author, State: it.State, UpdatedAt: it.UpdatedAt}) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | return out, nil | ||
| 77 | } | ||
| 78 | |||
| 79 | func runSearch(c *Ctx, args []string) int { | ||
| 80 | const usage = "search <query> [--kind repo|issue|mr]" | ||
| 81 | f, err := parseFlags(args, flagSpec{Multi: []string{"--kind"}, MaxPos: 1, Usage: usage}) | ||
| 82 | if err != nil { | ||
| 83 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 84 | } | ||
| 85 | if len(f.Pos) != 1 { | ||
| 86 | return c.fail(protocol.ExitUsage, "usage: %s", usage) | ||
| 87 | } | ||
| 88 | if err := validQuery(f.Pos[0]); err != nil { | ||
| 89 | return c.failErr(err) | ||
| 90 | } | ||
| 91 | kinds := f.List("--kind") | ||
| 92 | for _, k := range kinds { | ||
| 93 | if k != "repo" && k != "issue" && k != "mr" { | ||
| 94 | return c.fail(protocol.ExitUsage, "--kind must be repo, issue or mr") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | results, err := Search(c.Store, c.Cfg.Server.Root, c.User.ID, f.Pos[0], kinds) | ||
| 98 | if err != nil { | ||
| 99 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 100 | } | ||
| 101 | return c.emit(results, func(w io.Writer) { | ||
| 102 | for _, r := range results { | ||
| 103 | switch r.Kind { | ||
| 104 | case "repo": | ||
| 105 | fmt.Fprintf(w, "repo\t%s\t%s\n", r.Repo, r.Title) | ||
| 106 | default: | ||
| 107 | fmt.Fprintf(w, "%s\t%s%s%d\t%s\t%s\n", r.Kind, r.Repo, | ||
| 108 | SearchMarker(r.Kind), r.Number, r.State, r.Title) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | }) | ||
| 112 | } | ||
| 113 | |||
| 114 | // SearchMarker is the sigil a result's number carries, shared with the web | ||
| 115 | // so a hit reads the same in both places. | ||
| 116 | func SearchMarker(kind string) string { | ||
| 117 | if kind == "mr" { | ||
| 118 | return "!" | ||
| 119 | } | ||
| 120 | return "#" | ||
| 121 | } | ||
internal/httpd/notifyweb.go added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strconv" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/policy" | ||
| 8 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // noticeView is one inbox row with the pieces the template needs: the | ||
| 12 | // sigil for its kind and the link, already absolute. | ||
| 13 | type noticeView struct { | ||
| 14 | store.Notice | ||
| 15 | Href string | ||
| 16 | Read bool | ||
| 17 | } | ||
| 18 | |||
| 19 | // notifications is /notifications: the inbox behind the rail's badge. | ||
| 20 | // Unread by default; ?all=1 keeps what has been read. | ||
| 21 | func (s *Server) notifications(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 22 | all := r.URL.Query().Get("all") == "1" | ||
| 23 | rows, err := s.st.Inbox(u.ID, !all, 200, 0) | ||
| 24 | if err != nil { | ||
| 25 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 26 | return | ||
| 27 | } | ||
| 28 | views := make([]noticeView, 0, len(rows)) | ||
| 29 | for _, n := range rows { | ||
| 30 | views = append(views, noticeView{n, "/" + n.Path, n.ReadAt != ""}) | ||
| 31 | } | ||
| 32 | s.render(w, "notifications.html", struct { | ||
| 33 | basePage | ||
| 34 | Tab string | ||
| 35 | All bool | ||
| 36 | Notices []noticeView | ||
| 37 | }{s.baseFor(u), "notifications", all, views}) | ||
| 38 | } | ||
| 39 | |||
| 40 | // notificationsRead marks one notice read, or the whole inbox when no id | ||
| 41 | // is given, then returns to the list. | ||
| 42 | func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 43 | var ids []int64 | ||
| 44 | if v := r.FormValue("id"); v != "" { | ||
| 45 | n, err := strconv.ParseInt(v, 10, 64) | ||
| 46 | if err != nil { | ||
| 47 | http.Error(w, "bad id", http.StatusBadRequest) | ||
| 48 | return | ||
| 49 | } | ||
| 50 | ids = append(ids, n) | ||
| 51 | } | ||
| 52 | if _, err := s.st.MarkNoticesRead(u.ID, ids); err != nil { | ||
| 53 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 54 | return | ||
| 55 | } | ||
| 56 | http.Redirect(w, r, "/notifications", http.StatusSeeOther) | ||
| 57 | } | ||
| 58 | |||
| 59 | // watchToggle turns watching a repository on and off from its header, | ||
| 60 | // the way the pin button does. | ||
| 61 | func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 62 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | ||
| 63 | if !ok { | ||
| 64 | return | ||
| 65 | } | ||
| 66 | state := "watching" | ||
| 67 | if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { | ||
| 68 | state = "muted" | ||
| 69 | } | ||
| 70 | s.st.SetRepoWatch(repo.ID, u.ID, state) | ||
| 71 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | ||
| 72 | } | ||
internal/httpd/page.go +2
| @@ -27,6 +27,7 @@ type railItem struct { | |||
| 27 | type rail struct { | 27 | type rail struct { |
| 28 | Pinned []railRepo | 28 | Pinned []railRepo |
| 29 | Reviews []railItem | 29 | Reviews []railItem |
| 30 | Unread int | ||
| 30 | } | 31 | } |
| 31 | 32 | ||
| 32 | // Empty reports whether the rail has nothing to show beyond the global | 33 | // Empty reports whether the rail has nothing to show beyond the global |
| @@ -78,6 +79,7 @@ func (s *Server) railFor(viewer store.User) rail { | |||
| 78 | rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name}) | 79 | rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name}) |
| 79 | } | 80 | } |
| 80 | } | 81 | } |
| 82 | rl.Unread = s.st.UnreadNotices(viewer.ID) | ||
| 81 | queue, _ := s.st.ReviewQueue(viewer.ID) | 83 | queue, _ := s.st.ReviewQueue(viewer.ID) |
| 82 | for _, q := range queue { | 84 | for _, q := range queue { |
| 83 | rl.Reviews = append(rl.Reviews, railItem{ | 85 | rl.Reviews = append(rl.Reviews, railItem{ |
internal/httpd/routes.go +6
| @@ -41,6 +41,7 @@ func (s *Server) Routes() []Route { | |||
| 41 | routes = append(routes, | 41 | routes = append(routes, |
| 42 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, | 42 | Route{Method: "GET", Pattern: "/{$}", Handler: s.index}, |
| 43 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, | 43 | Route{Method: "GET", Pattern: "/explore", Handler: s.explore}, |
| 44 | Route{Method: "GET", Pattern: "/search", Handler: s.globalSearch}, | ||
| 44 | Route{Method: "GET", Pattern: "/healthz", Handler: s.healthz}, | 45 | Route{Method: "GET", Pattern: "/healthz", Handler: s.healthz}, |
| 45 | Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy}, | 46 | Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy}, |
| 46 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, | 47 | Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet}, |
| @@ -101,6 +102,9 @@ func (s *Server) Routes() []Route { | |||
| 101 | Handler: s.checkOrigin(s.logout)}, | 102 | Handler: s.checkOrigin(s.logout)}, |
| 102 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, | 103 | Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, |
| 103 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, | 104 | Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)}, |
| 105 | Route{Method: "GET", Pattern: "/notifications", Handler: s.requireUser(s.notifications)}, | ||
| 106 | Route{Method: "POST", Pattern: "/notifications", Mutating: true, | ||
| 107 | Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, | ||
| 104 | Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)}, | 108 | Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)}, |
| 105 | Route{Method: "POST", Pattern: "/{owner}", Mutating: true, | 109 | Route{Method: "POST", Pattern: "/{owner}", Mutating: true, |
| 106 | Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, | 110 | Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, |
| @@ -121,6 +125,8 @@ func (s *Server) Routes() []Route { | |||
| 121 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, | 125 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, |
| 122 | Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true, | 126 | Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true, |
| 123 | Handler: s.checkOrigin(s.requireUser(s.pinToggle))}, | 127 | Handler: s.checkOrigin(s.requireUser(s.pinToggle))}, |
| 128 | Route{Method: "POST", Pattern: "/{owner}/{repo}/watch", Mutating: true, | ||
| 129 | Handler: s.checkOrigin(s.requireUser(s.watchToggle))}, | ||
| 124 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", | 130 | Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", |
| 125 | Handler: s.requireUser(s.issueCreateForm)}, | 131 | Handler: s.requireUser(s.issueCreateForm)}, |
| 126 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, | 132 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, |
internal/httpd/searchweb.go added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strconv" | ||
| 6 | "strings" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/control" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // searchResult is one hit as the template renders it: the link is built | ||
| 13 | // here so the template does not have to know each kind's URL shape. | ||
| 14 | type searchResult struct { | ||
| 15 | control.SearchResult | ||
| 16 | Marker string | ||
| 17 | Href string | ||
| 18 | } | ||
| 19 | |||
| 20 | // globalSearch is /search: repositories, issues and merge requests across | ||
| 21 | // the instance. It is readable without a session — a visitor sees exactly | ||
| 22 | // the public rows — so it runs control.Search directly rather than | ||
| 23 | // dispatching a command as a user who may not exist. | ||
| 24 | func (s *Server) globalSearch(w http.ResponseWriter, r *http.Request) { | ||
| 25 | var viewer store.User | ||
| 26 | if s.cfg.Web.Mode == "accounts" { | ||
| 27 | viewer = s.viewer(r) | ||
| 28 | } | ||
| 29 | q := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 30 | kind := r.URL.Query().Get("kind") | ||
| 31 | if kind != "repo" && kind != "issue" && kind != "mr" { | ||
| 32 | kind = "" | ||
| 33 | } | ||
| 34 | var kinds []string | ||
| 35 | if kind != "" { | ||
| 36 | kinds = []string{kind} | ||
| 37 | } | ||
| 38 | var results []searchResult | ||
| 39 | var queryErr string | ||
| 40 | if q != "" { | ||
| 41 | if len(q) < 2 || len(q) > 200 { | ||
| 42 | queryErr = "query must be 2 to 200 characters" | ||
| 43 | } else { | ||
| 44 | hits, err := control.Search(s.st, s.cfg.Server.Root, viewer.ID, q, kinds) | ||
| 45 | if err != nil { | ||
| 46 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 47 | return | ||
| 48 | } | ||
| 49 | for _, h := range hits { | ||
| 50 | results = append(results, searchResult{h, control.SearchMarker(h.Kind), searchHref(h)}) | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | s.render(w, "globalsearch.html", struct { | ||
| 55 | basePage | ||
| 56 | Query string | ||
| 57 | Kind string | ||
| 58 | QueryErr string | ||
| 59 | Results []searchResult | ||
| 60 | }{s.baseFor(viewer), q, kind, queryErr, results}) | ||
| 61 | } | ||
| 62 | |||
| 63 | func searchHref(h control.SearchResult) string { | ||
| 64 | switch h.Kind { | ||
| 65 | case "issue": | ||
| 66 | return "/" + h.Repo + "/issues/" + strconv.FormatInt(h.Number, 10) | ||
| 67 | case "mr": | ||
| 68 | return "/" + h.Repo + "/mrs/" + strconv.FormatInt(h.Number, 10) | ||
| 69 | default: | ||
| 70 | return "/" + h.Repo | ||
| 71 | } | ||
| 72 | } | ||
internal/httpd/web.go +8 −14
| @@ -221,25 +221,16 @@ func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { | |||
| 221 | }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice}) | 221 | }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice}) |
| 222 | } | 222 | } |
| 223 | 223 | ||
| 224 | // filterRepos keeps repos whose path, description, or topics contain the | 224 | // filterRepos keeps repos matching the query by the same rule `repo |
| 225 | // query, case-insensitively. An empty query keeps everything. | 225 | // search` uses. An empty query keeps everything. |
| 226 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { | 226 | func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo { |
| 227 | if q == "" { | 227 | if q == "" { |
| 228 | return repos | 228 | return repos |
| 229 | } | 229 | } |
| 230 | q = strings.ToLower(q) | ||
| 231 | var out []describedRepo | 230 | var out []describedRepo |
| 232 | for _, d := range repos { | 231 | for _, d := range repos { |
| 233 | if strings.Contains(strings.ToLower(d.Path()), q) || | 232 | if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) { |
| 234 | strings.Contains(strings.ToLower(d.Desc), q) { | ||
| 235 | out = append(out, d) | 233 | out = append(out, d) |
| 236 | continue | ||
| 237 | } | ||
| 238 | for _, t := range d.Topics { | ||
| 239 | if strings.Contains(t, q) { | ||
| 240 | out = append(out, d) | ||
| 241 | break | ||
| 242 | } | ||
| 243 | } | 234 | } |
| 244 | } | 235 | } |
| 245 | return out | 236 | return out |
| @@ -255,7 +246,8 @@ type repoPage struct { | |||
| 255 | Dir string | 246 | Dir string |
| 256 | Tab string // active tab in the repo header | 247 | Tab string // active tab in the repo header |
| 257 | Topics []string | 248 | Topics []string |
| 258 | Pinned bool // by the viewer | 249 | Pinned bool // by the viewer |
| 250 | Watch string // the viewer's watch state: watching, muted, or "" | ||
| 259 | HasWiki bool | 251 | HasWiki bool |
| 260 | Host string | 252 | Host string |
| 261 | Mirrors []mirrorLine // repo admins only | 253 | Mirrors []mirrorLine // repo admins only |
| @@ -315,9 +307,10 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | |||
| 315 | ref = repo.DefaultBranch | 307 | ref = repo.DefaultBranch |
| 316 | } | 308 | } |
| 317 | topics, _ := s.st.ListTopics(repo.ID) | 309 | topics, _ := s.st.ListTopics(repo.ID) |
| 318 | pinned := false | 310 | pinned, watch := false, "" |
| 319 | if viewer.ID != 0 { | 311 | if viewer.ID != 0 { |
| 320 | pinned = s.st.IsPinned(viewer.ID, repo.ID) | 312 | pinned = s.st.IsPinned(viewer.ID, repo.ID) |
| 313 | watch = s.st.RepoWatchState(repo.ID, viewer.ID) | ||
| 321 | } | 314 | } |
| 322 | canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) | 315 | canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) |
| 323 | var mirrors []mirrorLine | 316 | var mirrors []mirrorLine |
| @@ -339,6 +332,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | |||
| 339 | CanAdmin: canAdmin, | 332 | CanAdmin: canAdmin, |
| 340 | Mirrors: mirrors, | 333 | Mirrors: mirrors, |
| 341 | Pinned: pinned, | 334 | Pinned: pinned, |
| 335 | Watch: watch, | ||
| 342 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", | 336 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", |
| 343 | Host: s.cfg.SiteHost(), | 337 | Host: s.cfg.SiteHost(), |
| 344 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), | 338 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), |
internal/policy/names.go +18 −16
| @@ -11,22 +11,24 @@ import ( | |||
| 11 | // the httpd mux's top-level routes must be reflected here; the httpd package | 11 | // the httpd mux's top-level routes must be reflected here; the httpd package |
| 12 | // asserts this in its tests. | 12 | // asserts this in its tests. |
| 13 | var reservedNames = map[string]bool{ | 13 | var reservedNames = map[string]bool{ |
| 14 | "admin": true, | 14 | "admin": true, |
| 15 | "api": true, | 15 | "api": true, |
| 16 | "archive": true, | 16 | "archive": true, |
| 17 | "explore": true, | 17 | "explore": true, |
| 18 | "favicon.svg": true, | 18 | "favicon.svg": true, |
| 19 | "gitbay": true, // vanity go-import path on gitbay.org | 19 | "gitbay": true, // vanity go-import path on gitbay.org |
| 20 | "gitbay-bot": true, // authors dependency-update issues | 20 | "gitbay-bot": true, // authors dependency-update issues |
| 21 | "healthz": true, | 21 | "healthz": true, |
| 22 | "login": true, | 22 | "login": true, |
| 23 | "logout": true, | 23 | "logout": true, |
| 24 | "new": true, | 24 | "new": true, |
| 25 | "privacy": true, | 25 | "notifications": true, |
| 26 | "raw": true, | 26 | "privacy": true, |
| 27 | "register": true, | 27 | "raw": true, |
| 28 | "settings": true, | 28 | "register": true, |
| 29 | "static": true, | 29 | "search": true, |
| 30 | "settings": true, | ||
| 31 | "static": true, | ||
| 30 | } | 32 | } |
| 31 | 33 | ||
| 32 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, | 34 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, |
internal/store/search.go added +79
| @@ -0,0 +1,79 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | // Search across repositories, for the `search` command and the web's | ||
| 4 | // /search page. Visibility is the same rule everywhere: public plus what | ||
| 5 | // the user reaches, so an anonymous caller (user id 0) sees only public | ||
| 6 | // rows rather than a different query. | ||
| 7 | |||
| 8 | // visibleCond admits public repositories and anything the user reaches. | ||
| 9 | const visibleCond = `(r.visibility = 'public' OR ` + reachableCond + `)` | ||
| 10 | |||
| 11 | // VisibleRepos returns every repository the user may read, public ones | ||
| 12 | // included, ordered by owner then name. | ||
| 13 | func (s *Store) VisibleRepos(userID int64) ([]Repo, error) { | ||
| 14 | rows, err := s.DB.Query(repoSelect+" WHERE "+visibleCond+" ORDER BY 4, r.name", userID) | ||
| 15 | if err != nil { | ||
| 16 | return nil, err | ||
| 17 | } | ||
| 18 | defer rows.Close() | ||
| 19 | var out []Repo | ||
| 20 | for rows.Next() { | ||
| 21 | r, err := scanRepo(rows) | ||
| 22 | if err != nil { | ||
| 23 | return nil, err | ||
| 24 | } | ||
| 25 | out = append(out, r) | ||
| 26 | } | ||
| 27 | return out, rows.Err() | ||
| 28 | } | ||
| 29 | |||
| 30 | // SearchIssues returns issues whose title contains q, newest activity | ||
| 31 | // first. Titles only: body search is FTS work that belongs with the | ||
| 32 | // per-repository issue search. | ||
| 33 | func (s *Store) SearchIssues(userID int64, q string, limit int) ([]DashboardItem, error) { | ||
| 34 | return s.searchQuery("issues", userID, q, limit) | ||
| 35 | } | ||
| 36 | |||
| 37 | // SearchMRs is SearchIssues for merge requests. | ||
| 38 | func (s *Store) SearchMRs(userID int64, q string, limit int) ([]DashboardItem, error) { | ||
| 39 | return s.searchQuery("merge_requests", userID, q, limit) | ||
| 40 | } | ||
| 41 | |||
| 42 | func (s *Store) searchQuery(table string, userID int64, q string, limit int) ([]DashboardItem, error) { | ||
| 43 | rows, err := s.DB.Query(` | ||
| 44 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | ||
| 45 | x.number, x.title, au.username, x.state, x.updated_at | ||
| 46 | FROM `+table+` x | ||
| 47 | JOIN repos r ON r.id = x.repo_id | ||
| 48 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 49 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | ||
| 50 | JOIN users au ON au.id = x.author_id | ||
| 51 | WHERE x.title LIKE '%' || ?2 || '%' ESCAPE '\' AND `+visibleCond+` | ||
| 52 | ORDER BY x.updated_at DESC LIMIT ?3`, userID, escapeLike(q), limit) | ||
| 53 | if err != nil { | ||
| 54 | return nil, err | ||
| 55 | } | ||
| 56 | defer rows.Close() | ||
| 57 | var out []DashboardItem | ||
| 58 | for rows.Next() { | ||
| 59 | var d DashboardItem | ||
| 60 | if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil { | ||
| 61 | return nil, err | ||
| 62 | } | ||
| 63 | out = append(out, d) | ||
| 64 | } | ||
| 65 | return out, rows.Err() | ||
| 66 | } | ||
| 67 | |||
| 68 | // escapeLike neutralises the LIKE wildcards, so a query containing % or _ | ||
| 69 | // matches those characters rather than everything. | ||
| 70 | func escapeLike(q string) string { | ||
| 71 | var b []byte | ||
| 72 | for i := 0; i < len(q); i++ { | ||
| 73 | if c := q[i]; c == '%' || c == '_' || c == '\\' { | ||
| 74 | b = append(b, '\\') | ||
| 75 | } | ||
| 76 | b = append(b, q[i]) | ||
| 77 | } | ||
| 78 | return string(b) | ||
| 79 | } | ||
internal/web/static/style.css +16 −1
| @@ -224,6 +224,19 @@ a.brand svg.mark { display: block; flex: none; } | |||
| 224 | a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | 224 | a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 225 | 225 | ||
| 226 | .railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; } | 226 | .railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; } |
| 227 | /* search is reachable from every page, so it sits above the rail's links | ||
| 228 | rather than on one page of its own */ | ||
| 229 | form.railsearch { padding: 0 var(--sp-4) var(--sp-3); } | ||
| 230 | form.railsearch input[type="search"] { | ||
| 231 | width: 100%; | ||
| 232 | padding: var(--sp-1) var(--sp-2); | ||
| 233 | font-size: var(--fs-1); | ||
| 234 | color: var(--shell-fg); | ||
| 235 | background: var(--shell-hover); | ||
| 236 | border: 1px solid var(--shell-line); | ||
| 237 | border-radius: var(--r-sm); | ||
| 238 | } | ||
| 239 | form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); } | ||
| 227 | ul.raillist { list-style: none; margin: 0; padding: 0; } | 240 | ul.raillist { list-style: none; margin: 0; padding: 0; } |
| 228 | ul.raillist a { | 241 | ul.raillist a { |
| 229 | display: block; | 242 | display: block; |
| @@ -258,7 +271,7 @@ ul.raillist.wide .owner { font-size: var(--fs-0); } | |||
| 258 | text-transform: uppercase; | 271 | text-transform: uppercase; |
| 259 | color: var(--shell-muted); | 272 | color: var(--shell-muted); |
| 260 | } | 273 | } |
| 261 | .raillabel .count { | 274 | .raillabel .count, ul.raillist .count { |
| 262 | font-family: var(--sans); | 275 | font-family: var(--sans); |
| 263 | font-size: var(--fs-0); | 276 | font-size: var(--fs-0); |
| 264 | font-weight: 700; | 277 | font-weight: 700; |
| @@ -409,6 +422,8 @@ nav.tabs a i { | |||
| 409 | } | 422 | } |
| 410 | a.brand { border-bottom: 0; padding-right: var(--sp-2); } | 423 | a.brand { border-bottom: 0; padding-right: var(--sp-2); } |
| 411 | .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; } | 424 | .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; } |
| 425 | form.railsearch { padding: 0 var(--sp-2); } | ||
| 426 | form.railsearch input[type="search"] { min-width: 8rem; } | ||
| 412 | /* pinned repos and the review queue both live on the dashboard, so the | 427 | /* pinned repos and the review queue both live on the dashboard, so the |
| 413 | strip keeps only what has nowhere else to go */ | 428 | strip keeps only what has nowhere else to go */ |
| 414 | .railgroup { display: none; } | 429 | .railgroup { display: none; } |
internal/web/templates/globalsearch.html added +34
| @@ -0,0 +1,34 @@ | |||
| 1 | {{define "title"}}search · {{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="listhead"> | ||
| 4 | <h1>Search</h1> | ||
| 5 | <nav class="filters"> | ||
| 6 | <a {{if eq .Kind ""}}class="active" aria-current="page" {{end}}href="?q={{.Query}}">everything</a> | ||
| 7 | <a {{if eq .Kind "repo"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=repo">repositories</a> | ||
| 8 | <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=issue">issues</a> | ||
| 9 | <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=mr">merge requests</a> | ||
| 10 | </nav> | ||
| 11 | </div> | ||
| 12 | <form method="get" action="/search" class="searchform"> | ||
| 13 | <input type="search" name="q" aria-label="Search" value="{{.Query}}" placeholder="repository names, topics, issue and merge request titles" autofocus> | ||
| 14 | {{if .Kind}}<input type="hidden" name="kind" value="{{.Kind}}">{{end}} | ||
| 15 | <button type="submit">search</button> | ||
| 16 | </form> | ||
| 17 | {{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p> | ||
| 18 | {{else if .Query}} | ||
| 19 | {{if .Results}} | ||
| 20 | <ul class="issuelist"> | ||
| 21 | {{range .Results}}<li> | ||
| 22 | <div class="issuemain"> | ||
| 23 | <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p> | ||
| 24 | <p class="meta">{{if .Number}}<a href="/{{.Repo}}">{{.Repo}}</a>{{.Marker}}{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{else if .Title}}{{.Title}}{{else}}repository{{end}}</p> | ||
| 25 | </div> | ||
| 26 | {{if .State}}<span class="chip chip-{{.State}}">{{.State}}</span>{{end}} | ||
| 27 | </li> | ||
| 28 | {{end}} | ||
| 29 | </ul> | ||
| 30 | {{else}}<p class="empty-note">no matches for “{{.Query}}”</p>{{end}} | ||
| 31 | {{else}} | ||
| 32 | <p class="empty-note">Repository names, descriptions and topics, and issue and merge request titles. File contents are searched per repository, from a repository's Code tab.</p> | ||
| 33 | {{end}} | ||
| 34 | {{end}} | ||
internal/web/templates/layout.html +7 −2
| @@ -15,10 +15,14 @@ | |||
| 15 | <nav class="rail" aria-label="Site"> | 15 | <nav class="rail" aria-label="Site"> |
| 16 | <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a> | 16 | <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a> |
| 17 | <div class="railbody"> | 17 | <div class="railbody"> |
| 18 | <form method="get" action="/search" class="railsearch" role="search"> | ||
| 19 | <input type="search" name="q" aria-label="Search {{.Site}}" placeholder="Search"> | ||
| 20 | </form> | ||
| 18 | <ul class="raillist"> | 21 | <ul class="raillist"> |
| 19 | {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} | 22 | {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} |
| 20 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> | 23 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> |
| 21 | {{if .Viewer}}<li><a href="/new">New repository</a></li>{{end}} | 24 | {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> |
| 25 | <li><a href="/new">New repository</a></li>{{end}} | ||
| 22 | </ul> | 26 | </ul> |
| 23 | {{with .Rail.Pinned}} | 27 | {{with .Rail.Pinned}} |
| 24 | <div class="railgroup"> | 28 | <div class="railgroup"> |
| @@ -59,7 +63,8 @@ | |||
| 59 | {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} | 63 | {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} |
| 60 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} | 64 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} |
| 61 | <span class="grow"></span> | 65 | <span class="grow"></span> |
| 62 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>{{end}} | 66 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> |
| 67 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>{{end}} | ||
| 63 | </div> | 68 | </div> |
| 64 | {{$top := topTab (str $ "Tab")}} | 69 | {{$top := topTab (str $ "Tab")}} |
| 65 | {{/* The header is the same on every tab: it sits above the tab bar, | 70 | {{/* The header is the same on every tab: it sits above the tab bar, |
internal/web/templates/notifications.html added +22
| @@ -0,0 +1,22 @@ | |||
| 1 | {{define "title"}}notifications · {{.Site}}{{end}} | ||
| 2 | {{define "content"}} | ||
| 3 | <div class="listhead"> | ||
| 4 | <h1>Notifications</h1> | ||
| 5 | <nav class="filters"> | ||
| 6 | <a {{if not .All}}class="active" aria-current="page" {{end}}href="/notifications">unread</a> | ||
| 7 | <a {{if .All}}class="active" aria-current="page" {{end}}href="/notifications?all=1">all</a> | ||
| 8 | </nav> | ||
| 9 | <span class="spacer"></span> | ||
| 10 | {{if .Notices}}<form method="post" action="/notifications" class="inline"><button type="submit" class="btn">Mark all read</button></form>{{end}} | ||
| 11 | </div> | ||
| 12 | <ul class="issuelist"> | ||
| 13 | {{range .Notices}}<li> | ||
| 14 | <div class="issuemain"> | ||
| 15 | <p class="title"><a href="{{.Href}}">{{.Actor}} {{.Summary}}</a></p> | ||
| 16 | <p class="meta"><a href="/{{.RepoPath}}">{{.RepoPath}}</a> · {{when .CreatedAt}}</p> | ||
| 17 | </div> | ||
| 18 | {{if not .Read}}<form method="post" action="/notifications" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn">Mark read</button></form>{{end}} | ||
| 19 | </li> | ||
| 20 | {{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}} | ||
| 21 | </ul> | ||
| 22 | {{end}} | ||