web: search across the instance, and a page for the inbox !227

merged merged by cmc on 2026-09-04 14:25 UTC · krz/gitbay:web-search-notifications into main

16 files changed, +580 −37

Layout: unified · split

cmd/gitbay/main.go +2
@@ -59,6 +59,8 @@ func newRoot() *cobra.Command {
5959 passOpts{server: []string{"feed"}}),
6060 pass("explore", "public repositories on this instance [--limit n] [--cursor c]",
6161 passOpts{server: []string{"explore"}}),
62 pass("search", "find repositories, issues and merge requests: <query> [--kind repo|issue|mr]",
63 passOpts{server: []string{"search"}}),
6264 group("notifications", "your notification inbox",
6365 pass("list", "unread notifications, or [--all] [--limit n] [--cursor c]",
6466 passOpts{server: []string{"notifications", "list"}}),
e2e/readonly_test.go +1
@@ -143,6 +143,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
143143 "release show": {"alice/app", "v1"},
144144 "release asset get": {"alice/app", "v1", "a.txt"},
145145 "notifications list": nil,
146 "search": {"app"},
146147 "webhook list": {"alice/app"},
147148 "webhook deliveries": {"alice/app"},
148149 "wiki list": {"alice/app"},
e2e/searchweb_test.go added +111
@@ -0,0 +1,111 @@
1package e2e
2
3import (
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.
11func 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 {
783783 seen[r.ID] = true
784784 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
785785 topics, _ := c.Store.ListTopics(r.ID)
786 if !matchesRepo(q, r, desc, topics) {
786 if !MatchesRepo(q, r.Path(), desc, topics) {
787787 continue
788788 }
789789 ds = append(ds, out{r.Path(), r.Visibility, desc, topics})
@@ -795,13 +795,18 @@ func runRepoSearch(c *Ctx, args []string) int {
795795 })
796796}
797797
798func matchesRepo(q string, r store.Repo, desc string, topics []string) bool {
799 if strings.Contains(strings.ToLower(r.Path()), q) ||
798// MatchesRepo is the one rule for matching a repository against a text
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.
802func MatchesRepo(q, path, desc string, topics []string) bool {
803 q = strings.ToLower(q)
804 if strings.Contains(strings.ToLower(path), q) ||
800805 strings.Contains(strings.ToLower(desc), q) {
801806 return true
802807 }
803808 for _, t := range topics {
804 if strings.Contains(t, q) {
809 if strings.Contains(strings.ToLower(t), q) {
805810 return true
806811 }
807812 }
internal/control/search.go added +121
@@ -0,0 +1,121 @@
1package control
2
3import (
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
13func 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.
22const searchLimit = 50
23
24// SearchResult is one match, in the shape all three kinds share.
25type 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.
40func 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
79func 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.
116func SearchMarker(kind string) string {
117 if kind == "mr" {
118 return "!"
119 }
120 return "#"
121}
internal/httpd/notifyweb.go added +72
@@ -0,0 +1,72 @@
1package httpd
2
3import (
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.
13type 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.
21func (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.
42func (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.
61func (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 {
2727type rail struct {
2828 Pinned []railRepo
2929 Reviews []railItem
30 Unread int
3031}
3132
3233// Empty reports whether the rail has nothing to show beyond the global
@@ -78,6 +79,7 @@ func (s *Server) railFor(viewer store.User) rail {
7879 rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name})
7980 }
8081 }
82 rl.Unread = s.st.UnreadNotices(viewer.ID)
8183 queue, _ := s.st.ReviewQueue(viewer.ID)
8284 for _, q := range queue {
8385 rl.Reviews = append(rl.Reviews, railItem{
internal/httpd/routes.go +6
@@ -41,6 +41,7 @@ func (s *Server) Routes() []Route {
4141 routes = append(routes,
4242 Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
4343 Route{Method: "GET", Pattern: "/explore", Handler: s.explore},
44 Route{Method: "GET", Pattern: "/search", Handler: s.globalSearch},
4445 Route{Method: "GET", Pattern: "/healthz", Handler: s.healthz},
4546 Route{Method: "GET", Pattern: "/privacy", Handler: s.privacy},
4647 Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
@@ -101,6 +102,9 @@ func (s *Server) Routes() []Route {
101102 Handler: s.checkOrigin(s.logout)},
102103 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
103104 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))},
104108 Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)},
105109 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
106110 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
@@ -121,6 +125,8 @@ func (s *Server) Routes() []Route {
121125 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
122126 Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true,
123127 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))},
124130 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new",
125131 Handler: s.requireUser(s.issueCreateForm)},
126132 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true,
internal/httpd/searchweb.go added +72
@@ -0,0 +1,72 @@
1package httpd
2
3import (
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.
14type 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.
24func (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
63func 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) {
221221 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
222222}
223223
224// filterRepos keeps repos whose path, description, or topics contain the
225// query, case-insensitively. An empty query keeps everything.
224// filterRepos keeps repos matching the query by the same rule `repo
225// search` uses. An empty query keeps everything.
226226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
227227 if q == "" {
228228 return repos
229229 }
230 q = strings.ToLower(q)
231230 var out []describedRepo
232231 for _, d := range repos {
233 if strings.Contains(strings.ToLower(d.Path()), q) ||
234 strings.Contains(strings.ToLower(d.Desc), q) {
232 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
235233 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 }
243234 }
244235 }
245236 return out
@@ -255,7 +246,8 @@ type repoPage struct {
255246 Dir string
256247 Tab string // active tab in the repo header
257248 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 ""
259251 HasWiki bool
260252 Host string
261253 Mirrors []mirrorLine // repo admins only
@@ -315,9 +307,10 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
315307 ref = repo.DefaultBranch
316308 }
317309 topics, _ := s.st.ListTopics(repo.ID)
318 pinned := false
310 pinned, watch := false, ""
319311 if viewer.ID != 0 {
320312 pinned = s.st.IsPinned(viewer.ID, repo.ID)
313 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
321314 }
322315 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
323316 var mirrors []mirrorLine
@@ -339,6 +332,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
339332 CanAdmin: canAdmin,
340333 Mirrors: mirrors,
341334 Pinned: pinned,
335 Watch: watch,
342336 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
343337 Host: s.cfg.SiteHost(),
344338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
internal/policy/names.go +18 −16
@@ -11,22 +11,24 @@ import (
1111// the httpd mux's top-level routes must be reflected here; the httpd package
1212// asserts this in its tests.
1313var reservedNames = map[string]bool{
14 "admin": true,
15 "api": true,
16 "archive": true,
17 "explore": true,
18 "favicon.svg": true,
19 "gitbay": true, // vanity go-import path on gitbay.org
20 "gitbay-bot": true, // authors dependency-update issues
21 "healthz": true,
22 "login": true,
23 "logout": true,
24 "new": true,
25 "privacy": true,
26 "raw": true,
27 "register": true,
28 "settings": true,
29 "static": true,
14 "admin": true,
15 "api": true,
16 "archive": true,
17 "explore": true,
18 "favicon.svg": true,
19 "gitbay": true, // vanity go-import path on gitbay.org
20 "gitbay-bot": true, // authors dependency-update issues
21 "healthz": true,
22 "login": true,
23 "logout": true,
24 "new": true,
25 "notifications": true,
26 "privacy": true,
27 "raw": true,
28 "register": true,
29 "search": true,
30 "settings": true,
31 "static": true,
3032}
3133
3234// namePat matches valid user, org, and repo names: lowercase alphanumerics,
internal/store/search.go added +79
@@ -0,0 +1,79 @@
1package 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.
9const 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.
13func (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.
33func (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.
38func (s *Store) SearchMRs(userID int64, q string, limit int) ([]DashboardItem, error) {
39 return s.searchQuery("merge_requests", userID, q, limit)
40}
41
42func (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.
70func 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; }
224224a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
225225
226226.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 */
229form.railsearch { padding: 0 var(--sp-4) var(--sp-3); }
230form.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}
239form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); }
227240ul.raillist { list-style: none; margin: 0; padding: 0; }
228241ul.raillist a {
229242 display: block;
@@ -258,7 +271,7 @@ ul.raillist.wide .owner { font-size: var(--fs-0); }
258271 text-transform: uppercase;
259272 color: var(--shell-muted);
260273}
261.raillabel .count {
274.raillabel .count, ul.raillist .count {
262275 font-family: var(--sans);
263276 font-size: var(--fs-0);
264277 font-weight: 700;
@@ -409,6 +422,8 @@ nav.tabs a i {
409422 }
410423 a.brand { border-bottom: 0; padding-right: var(--sp-2); }
411424 .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; }
412427 /* pinned repos and the review queue both live on the dashboard, so the
413428 strip keeps only what has nowhere else to go */
414429 .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}}&amp;kind=repo">repositories</a>
8 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a>
9 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;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 @@
1515<nav class="rail" aria-label="Site">
1616 <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a>
1717 <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>
1821 <ul class="raillist">
1922 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
2023 <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}}
2226 </ul>
2327 {{with .Rail.Pinned}}
2428 <div class="railgroup">
@@ -59,7 +63,8 @@
5963 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
6064 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
6165 <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}}
6368 </div>
6469 {{$top := topTab (str $ "Tab")}}
6570 {{/* 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}}