tenseleyflow/shithub / 6358fdc

Browse files

Align search results UI

Authored by espadonne
SHA
6358fdc5e1366e2fba38bf35f9c7044cfbcc9906
Parents
9dc6deb
Tree
142919d

10 changed files

StatusFile+-
M docs/internal/search.md 8 10
M internal/web/handlers/search/search.go 87 26
M internal/web/handlers/search/search_test.go 19 0
M internal/web/render/render.go 22 0
M internal/web/render/render_test.go 23 0
M internal/web/static/css/shithub.css 211 20
M internal/web/templates/_layout.html 86 0
M internal/web/templates/_nav.html 4 3
M internal/web/templates/search/_quick_dropdown.html 53 34
M internal/web/templates/search/results.html 55 40
docs/internal/search.mdmodified
@@ -137,15 +137,16 @@ pathological-length queries; longer inputs are silently truncated.
137137
 
138138
 ## Routes
139139
 
140
-| Method | Path             | Notes                                    |
141
-|--------|------------------|------------------------------------------|
142
-| GET    | `/search`        | Full results page with type tabs         |
143
-| GET    | `/search/quick`  | htmx fragment endpoint for top-bar drop  |
140
+| Method | Path             | Notes                                      |
141
+|--------|------------------|--------------------------------------------|
142
+| GET    | `/search`        | Full results page with GitHub-style filters |
143
+| GET    | `/search/quick`  | HTML fragment endpoint for top-bar drop     |
144144
 
145145
 The top-bar nav embeds a search form pointing at `/search`; the
146
-htmx-driven dropdown wiring is intentionally deferred (the
147
-endpoint exists; the JS to invoke it on keystroke comes when we
148
-add htmx-the-library to the static asset bundle).
146
+same input now calls `/search/quick` as the user types and renders
147
+the returned fragment under the nav search box. Full-page type URLs
148
+emit GitHub-style `type=repositories` and `type=pullrequests` while
149
+still accepting the legacy `type=repos` and `type=pulls` aliases.
149150
 
150151
 ## What we deferred from the spec
151152
 
@@ -161,9 +162,6 @@ add htmx-the-library to the static asset bundle).
161162
   S33 webhooks sprint pulls in the rest of the API surface so we
162163
   do them together (consistency on auth + body cap + scope shapes).
163164
   **Forward-deferred to S33 / S34 API consolidation.**
164
-* **Quick-dropdown htmx wiring**: the endpoint returns the right
165
-  HTML; the static HTML form posts to `/search` directly. The
166
-  dropdown lights up when we land htmx in the static asset bundle.
167165
 * **`path:` operator**: parser falls through; querying `path:foo`
168166
   treats it as free text today. Documented above.
169167
 
internal/web/handlers/search/search.gomodified
@@ -1,7 +1,7 @@
11
 // SPDX-License-Identifier: AGPL-3.0-or-later
22
 
33
 // Package search wires the S28 web search surface. The full results
4
-// page lives at GET /search; the htmx quick dropdown lives at GET
4
+// page lives at GET /search; the nav quick dropdown lives at GET
55
 // /search/quick.
66
 package search
77
 
@@ -64,13 +64,7 @@ func (h *Handlers) actor(r *http.Request) policy.Actor {
6464
 // results renders the full /search page with type tabs.
6565
 func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
6666
 	rawQ := r.URL.Query().Get("q")
67
-	tab := r.URL.Query().Get("type")
68
-	if tab == "" {
69
-		tab = "repos"
70
-	}
71
-	if !validSearchTab(tab) {
72
-		tab = "repos"
73
-	}
67
+	tab := normalizeSearchTab(r.URL.Query().Get("type"))
7468
 	page := pageFromRequest(r)
7569
 
7670
 	parsed := srch.ParseQuery(rawQ)
@@ -85,6 +79,7 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
8579
 		"Page":              page,
8680
 		"Parsed":            parsed,
8781
 		"PageSize":          srch.PageSize,
82
+		"SearchProTip":      searchProTip(tab),
8883
 	}
8984
 
9085
 	if !parsed.HasContent() {
@@ -96,7 +91,7 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
9691
 
9792
 	offset := (page - 1) * srch.PageSize
9893
 	switch tab {
99
-	case "repos":
94
+	case "repositories":
10095
 		rows, total, err := srch.SearchRepos(r.Context(), deps, actor, parsed, srch.PageSize, offset)
10196
 		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
10297
 			h.d.Logger.ErrorContext(r.Context(), "search repos", "error", err)
@@ -112,7 +107,7 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
112107
 		data["Issues"] = rows
113108
 		data["Total"] = total
114109
 		data["HasNext"] = int64(page*srch.PageSize) < total
115
-	case "pulls":
110
+	case "pullrequests":
116111
 		rows, total, err := srch.SearchIssues(r.Context(), deps, actor, parsed, "pr", srch.PageSize, offset)
117112
 		if err != nil && !errors.Is(err, srch.ErrEmptyQuery) {
118113
 			h.d.Logger.ErrorContext(r.Context(), "search pulls", "error", err)
@@ -144,18 +139,33 @@ func (h *Handlers) results(w http.ResponseWriter, r *http.Request) {
144139
 	}
145140
 	data["HasPrev"] = page > 1
146141
 	data["SearchTabs"] = h.searchTabs(r, actor, parsed, rawQ, tab)
142
+	data["ResultHeading"] = searchResultHeading(tab, data["Total"])
143
+	if page > 1 {
144
+		data["PrevHref"] = searchHref(rawQ, tab, page-1)
145
+	}
146
+	if next, ok := data["HasNext"].(bool); ok && next {
147
+		data["NextHref"] = searchHref(rawQ, tab, page+1)
148
+	}
147149
 
148150
 	if err := h.d.Render.RenderPage(w, r, "search/results", data); err != nil {
149151
 		h.d.Logger.ErrorContext(r.Context(), "search render", "error", err)
150152
 	}
151153
 }
152154
 
153
-func validSearchTab(tab string) bool {
155
+func normalizeSearchTab(tab string) string {
154156
 	switch tab {
155
-	case "repos", "issues", "pulls", "users", "code":
156
-		return true
157
+	case "", "repos", "repositories":
158
+		return "repositories"
159
+	case "code":
160
+		return "code"
161
+	case "issues":
162
+		return "issues"
163
+	case "pulls", "pullrequests":
164
+		return "pullrequests"
165
+	case "users":
166
+		return "users"
157167
 	default:
158
-		return false
168
+		return "repositories"
159169
 	}
160170
 }
161171
 
@@ -170,11 +180,11 @@ type searchTab struct {
170180
 
171181
 func (h *Handlers) searchTabs(r *http.Request, actor policy.Actor, parsed srch.ParsedQuery, rawQ, active string) []searchTab {
172182
 	tabs := []searchTab{
173
-		{Key: "repos", Label: "Repositories", Icon: "repo"},
174183
 		{Key: "code", Label: "Code", Icon: "code"},
184
+		{Key: "repositories", Label: "Repositories", Icon: "repo"},
175185
 		{Key: "issues", Label: "Issues", Icon: "issue-opened"},
176
-		{Key: "pulls", Label: "Pull requests", Icon: "git-pull-request"},
177
-		{Key: "users", Label: "Users", Icon: "person"},
186
+		{Key: "pullrequests", Label: "Pull requests", Icon: "git-pull-request"},
187
+		{Key: "users", Label: "Users", Icon: "people"},
178188
 	}
179189
 	for i := range tabs {
180190
 		tabs[i].Selected = tabs[i].Key == active
@@ -189,13 +199,13 @@ func (h *Handlers) searchTabs(r *http.Request, actor policy.Actor, parsed srch.P
189199
 		var total int64
190200
 		var err error
191201
 		switch tabs[i].Key {
192
-		case "repos":
202
+		case "repositories":
193203
 			_, total, err = srch.SearchRepos(r.Context(), deps, actor, parsed, 0, 0)
194204
 		case "code":
195205
 			_, total, err = srch.SearchCode(r.Context(), deps, actor, parsed, 0, 0)
196206
 		case "issues":
197207
 			_, total, err = srch.SearchIssues(r.Context(), deps, actor, parsed, "issue", 0, 0)
198
-		case "pulls":
208
+		case "pullrequests":
199209
 			_, total, err = srch.SearchIssues(r.Context(), deps, actor, parsed, "pr", 0, 0)
200210
 		case "users":
201211
 			_, total, err = srch.SearchUsers(r.Context(), deps, parsed, 0, 0)
@@ -219,8 +229,58 @@ func searchHref(q, tab string, page int) string {
219229
 	return "/search?" + v.Encode()
220230
 }
221231
 
222
-// quick is the htmx dropdown endpoint. Returns one fragment with
223
-// the top N results across all four types stacked vertically.
232
+func searchResultHeading(tab string, total any) string {
233
+	count, _ := total.(int64)
234
+	switch tab {
235
+	case "code":
236
+		return plural(count, "code result", "code results")
237
+	case "issues":
238
+		return plural(count, "issue result", "issue results")
239
+	case "pullrequests":
240
+		return plural(count, "pull request result", "pull request results")
241
+	case "users":
242
+		return plural(count, "user result", "user results")
243
+	default:
244
+		return plural(count, "repository result", "repository results")
245
+	}
246
+}
247
+
248
+func plural(count int64, one, many string) string {
249
+	if count == 1 {
250
+		return "1 " + one
251
+	}
252
+	return int64String(count) + " " + many
253
+}
254
+
255
+func int64String(n int64) string {
256
+	if n == 0 {
257
+		return "0"
258
+	}
259
+	var buf [20]byte
260
+	i := len(buf)
261
+	for n > 0 {
262
+		i--
263
+		buf[i] = byte('0' + n%10)
264
+		n /= 10
265
+	}
266
+	return string(buf[i:])
267
+}
268
+
269
+func searchProTip(tab string) string {
270
+	switch tab {
271
+	case "issues", "pullrequests":
272
+		return "Restrict your search to the title by using the in:title qualifier."
273
+	case "code":
274
+		return "Use repo:owner/name to limit code search to a single repository."
275
+	case "users":
276
+		return "Search by username or display name to find people faster."
277
+	default:
278
+		return "Press / to activate the search input again and adjust your query."
279
+	}
280
+}
281
+
282
+// quick is the nav dropdown endpoint. Returns one fragment with
283
+// the top N results across the implemented quick-search types.
224284
 func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
225285
 	rawQ := r.URL.Query().Get("q")
226286
 	parsed := srch.ParseQuery(rawQ)
@@ -236,12 +296,13 @@ func (h *Handlers) quick(w http.ResponseWriter, r *http.Request) {
236296
 	users, _, _ := srch.SearchUsers(r.Context(), deps, parsed, srch.QuickResultsLimit, 0)
237297
 
238298
 	data := map[string]any{
239
-		"Query":  rawQ,
240
-		"Repos":  repos,
241
-		"Issues": issues,
242
-		"Users":  users,
299
+		"Query":      rawQ,
300
+		"SearchHref": searchHref(rawQ, "repositories", 1),
301
+		"Repos":      repos,
302
+		"Issues":     issues,
303
+		"Users":      users,
243304
 	}
244
-	if err := h.d.Render.RenderPage(w, r, "search/_quick_dropdown", data); err != nil {
305
+	if err := h.d.Render.RenderFragment(w, "search/_quick_dropdown", data); err != nil {
245306
 		h.d.Logger.ErrorContext(r.Context(), "quick render", "error", err)
246307
 	}
247308
 }
internal/web/handlers/search/search_test.gomodified
@@ -28,3 +28,22 @@ func TestSearchHrefEscapesQuery(t *testing.T) {
2828
 		t.Fatalf("searchHref = %q, want %q", got, want)
2929
 	}
3030
 }
31
+
32
+func TestNormalizeSearchTabAcceptsGitHubTypesAndLegacyAliases(t *testing.T) {
33
+	cases := map[string]string{
34
+		"":              "repositories",
35
+		"repos":         "repositories",
36
+		"repositories":  "repositories",
37
+		"pulls":         "pullrequests",
38
+		"pullrequests":  "pullrequests",
39
+		"code":          "code",
40
+		"issues":        "issues",
41
+		"users":         "users",
42
+		"unknown-value": "repositories",
43
+	}
44
+	for input, want := range cases {
45
+		if got := normalizeSearchTab(input); got != want {
46
+			t.Fatalf("normalizeSearchTab(%q) = %q, want %q", input, got, want)
47
+		}
48
+	}
49
+}
internal/web/render/render.gomodified
@@ -201,6 +201,28 @@ func (r *Renderer) Render(w io.Writer, name string, data any) error {
201201
 	return err
202202
 }
203203
 
204
+// RenderFragment executes only a page template's "page" definition. Use it
205
+// for HTML fragments returned to JavaScript or htmx; full browser pages should
206
+// continue to call Render/RenderPage so nav, footer, and document chrome stay
207
+// consistent.
208
+func (r *Renderer) RenderFragment(w io.Writer, name string, data any) error {
209
+	t, ok := r.pages[name]
210
+	if !ok {
211
+		return fmt.Errorf("render: unknown page %q", name)
212
+	}
213
+	var buf bytes.Buffer
214
+	if err := t.ExecuteTemplate(&buf, "page", data); err != nil {
215
+		return fmt.Errorf("execute fragment %s: %w", name, err)
216
+	}
217
+	if rw, ok := w.(http.ResponseWriter); ok {
218
+		if rw.Header().Get("Content-Type") == "" {
219
+			rw.Header().Set("Content-Type", "text/html; charset=utf-8")
220
+		}
221
+	}
222
+	_, err := w.Write(buf.Bytes())
223
+	return err
224
+}
225
+
204226
 // RenderPage is the request-aware Render: when data is a map[string]any, it
205227
 // injects "Viewer" (from middleware.CurrentUserFromContext) and "CSRFToken"
206228
 // (the per-request token) if the caller hasn't set them. The nav partial's
internal/web/render/render_test.gomodified
@@ -52,6 +52,29 @@ func TestNew_RegistersSubdirPages(t *testing.T) {
5252
 	}
5353
 }
5454
 
55
+func TestRenderFragmentExecutesPageWithoutLayout(t *testing.T) {
56
+	t.Parallel()
57
+	fsys := fstest.MapFS{
58
+		"_layout.html": &fstest.MapFile{Data: []byte(
59
+			`{{ define "layout" }}<html>{{ template "page" . }}</html>{{ end }}`,
60
+		)},
61
+		"fragment.html": &fstest.MapFile{Data: []byte(
62
+			`{{ define "page" }}fragment only{{ end }}`,
63
+		)},
64
+	}
65
+	r, err := New(fsys, Options{})
66
+	if err != nil {
67
+		t.Fatalf("New: %v", err)
68
+	}
69
+	var buf bytes.Buffer
70
+	if err := r.RenderFragment(&buf, "fragment", nil); err != nil {
71
+		t.Fatalf("render fragment: %v", err)
72
+	}
73
+	if got := buf.String(); got != "fragment only" {
74
+		t.Fatalf("RenderFragment body = %q, want fragment only", got)
75
+	}
76
+}
77
+
5578
 // Regression test for the inbound deferral from S30 dogfood: a partial
5679
 // at `profile/_tabs.html` that defines `{{ define "tabs" }}` was
5780
 // silently registered as an unparsed page. A page that called
internal/web/static/css/shithub.cssmodified
@@ -4111,6 +4111,17 @@ button.shithub-repo-action {
41114111
 }
41124112
 
41134113
 /* S28 — search */
4114
+.sr-only {
4115
+  position: absolute;
4116
+  width: 1px;
4117
+  height: 1px;
4118
+  padding: 0;
4119
+  margin: -1px;
4120
+  overflow: hidden;
4121
+  clip: rect(0, 0, 0, 0);
4122
+  white-space: nowrap;
4123
+  border: 0;
4124
+}
41144125
 .shithub-nav-search {
41154126
   position: relative;
41164127
   flex: 1 1 22rem;
@@ -4134,10 +4145,15 @@ button.shithub-repo-action {
41344145
   padding: 0.35rem 2rem 0.35rem 2rem;
41354146
   border: 1px solid var(--border-default);
41364147
   border-radius: 6px;
4137
-  background: var(--canvas-default);
4148
+  background: var(--canvas-subtle);
41384149
   color: var(--fg-default);
41394150
   font-size: 0.85rem;
41404151
 }
4152
+.shithub-nav-search input:focus {
4153
+  outline: 2px solid var(--accent-fg);
4154
+  outline-offset: -1px;
4155
+  background: var(--canvas-default);
4156
+}
41414157
 .shithub-nav-search-key {
41424158
   position: absolute;
41434159
   right: 0.55rem;
@@ -4153,18 +4169,36 @@ button.shithub-repo-action {
41534169
   text-align: center;
41544170
   pointer-events: none;
41554171
 }
4172
+.shithub-nav-search-popover {
4173
+  position: absolute;
4174
+  z-index: 80;
4175
+  top: calc(100% + 0.45rem);
4176
+  left: 0;
4177
+  right: 0;
4178
+  overflow: hidden;
4179
+  border: 1px solid var(--border-default);
4180
+  border-radius: 8px;
4181
+  background: var(--canvas-default);
4182
+  box-shadow: 0 16px 32px rgba(1, 4, 9, 0.22);
4183
+}
4184
+.shithub-nav-search-popover[hidden] {
4185
+  display: none;
4186
+}
41564187
 .shithub-search-page {
41574188
   padding: 1.5rem 1rem 3rem;
41584189
 }
41594190
 .shithub-search-shell {
41604191
   display: grid;
4161
-  grid-template-columns: 220px minmax(0, 1fr);
4162
-  gap: 2rem;
4192
+  grid-template-columns: 296px minmax(0, 1fr) 280px;
4193
+  gap: 1.5rem;
41634194
   max-width: 1280px;
41644195
   margin: 0 auto;
41654196
 }
41664197
 .shithub-search-sidebar {
4167
-  padding-top: 3.35rem;
4198
+  position: sticky;
4199
+  top: 1rem;
4200
+  align-self: start;
4201
+  padding-top: 0.2rem;
41684202
 }
41694203
 .shithub-search-sidebar h2 {
41704204
   margin: 0 0 0.55rem;
@@ -4180,8 +4214,8 @@ button.shithub-repo-action {
41804214
   align-items: center;
41814215
   justify-content: space-between;
41824216
   gap: 0.75rem;
4183
-  min-height: 2.25rem;
4184
-  padding: 0.4rem 0.65rem;
4217
+  min-height: 2rem;
4218
+  padding: 0.35rem 0.55rem;
41854219
   border-radius: 6px;
41864220
   color: var(--fg-default);
41874221
   font-size: 0.875rem;
@@ -4222,11 +4256,11 @@ button.shithub-repo-action {
42224256
 .shithub-search-query-form {
42234257
   display: flex;
42244258
   gap: 0.5rem;
4225
-  margin-bottom: 1.25rem;
4259
+  margin-bottom: 1rem;
42264260
 }
42274261
 .shithub-search-query-form input[type=text] {
42284262
   flex: 1;
4229
-  min-height: 2.35rem;
4263
+  min-height: 2.25rem;
42304264
   padding: 0.45rem 0.75rem;
42314265
   border: 1px solid var(--border-default);
42324266
   border-radius: 6px;
@@ -4246,6 +4280,16 @@ button.shithub-repo-action {
42464280
   margin: 0;
42474281
   font-size: 1.25rem;
42484282
   line-height: 1.3;
4283
+  font-weight: 600;
4284
+}
4285
+.shithub-search-results-head p {
4286
+  margin: 0.25rem 0 0;
4287
+  color: var(--fg-muted);
4288
+  font-size: 0.875rem;
4289
+}
4290
+.shithub-search-query-echo {
4291
+  color: var(--fg-default);
4292
+  font-weight: 600;
42494293
 }
42504294
 .shithub-search-sort {
42514295
   position: relative;
@@ -4290,15 +4334,16 @@ button.shithub-repo-action {
42904334
 }
42914335
 .shithub-search-result {
42924336
   display: flex;
4293
-  gap: 0.75rem;
4337
+  gap: 1rem;
42944338
   justify-content: space-between;
4295
-  padding: 1rem 0;
4339
+  padding: 1.15rem 0;
42964340
   border-bottom: 1px solid var(--border-default);
42974341
 }
42984342
 .shithub-search-result-main {
4343
+  flex: 1 1 auto;
42994344
   min-width: 0;
43004345
 }
4301
-.shithub-search-result h2 {
4346
+.shithub-search-result-title {
43024347
   display: flex;
43034348
   align-items: center;
43044349
   gap: 0.45rem;
@@ -4307,20 +4352,44 @@ button.shithub-repo-action {
43074352
   line-height: 1.35;
43084353
   font-weight: 600;
43094354
 }
4310
-.shithub-search-result h2 svg {
4355
+.shithub-search-result-title svg {
43114356
   flex: 0 0 auto;
43124357
   color: var(--fg-muted);
43134358
 }
4359
+.shithub-search-result-title a {
4360
+  min-width: 0;
4361
+}
4362
+.shithub-search-result-title a:hover {
4363
+  text-decoration: underline;
4364
+}
4365
+.shithub-search-match em,
4366
+.shithub-search-result-title em {
4367
+  font-style: normal;
4368
+  font-weight: 700;
4369
+}
43144370
 .shithub-search-result p {
43154371
   margin: 0.35rem 0 0;
43164372
   color: var(--fg-default);
43174373
   font-size: 0.875rem;
43184374
 }
4375
+.shithub-search-result-desc {
4376
+  max-width: 760px;
4377
+  line-height: 1.45;
4378
+}
4379
+.shithub-search-result-context {
4380
+  margin: 0 0 0.2rem !important;
4381
+  color: var(--fg-muted) !important;
4382
+}
4383
+.shithub-search-result-context a {
4384
+  color: var(--fg-muted);
4385
+}
43194386
 .shithub-search-result-path {
43204387
   color: var(--fg-muted) !important;
43214388
 }
43224389
 .shithub-search-avatar,
4323
-.shithub-search-user-avatar {
4390
+.shithub-search-user-avatar,
4391
+.shithub-search-mini-avatar,
4392
+.shithub-quick-avatar {
43244393
   border-radius: 50%;
43254394
   background: var(--canvas-subtle);
43264395
   flex: 0 0 auto;
@@ -4341,6 +4410,16 @@ button.shithub-repo-action {
43414410
   align-items: center;
43424411
   gap: 0.25rem;
43434412
 }
4413
+.shithub-search-result-meta a {
4414
+  display: inline-flex;
4415
+  align-items: center;
4416
+  gap: 0.25rem;
4417
+  color: var(--fg-muted);
4418
+}
4419
+.shithub-search-result-meta a:hover {
4420
+  color: var(--accent-fg);
4421
+  text-decoration: none;
4422
+}
43444423
 .shithub-search-result-meta li + li::before {
43454424
   content: "";
43464425
   width: 3px;
@@ -4367,6 +4446,11 @@ button.shithub-repo-action {
43674446
 .shithub-search-user-result {
43684447
   justify-content: flex-start;
43694448
 }
4449
+.shithub-search-user-login {
4450
+  color: var(--fg-muted);
4451
+  font-size: 0.875rem;
4452
+  font-weight: 400;
4453
+}
43704454
 .shithub-search-code-preview {
43714455
   margin: 0.75rem 0 0;
43724456
   padding: 0.65rem 0.75rem;
@@ -4399,13 +4483,108 @@ button.shithub-repo-action {
43994483
   gap: 0.5rem;
44004484
   padding: 1.5rem 0 0;
44014485
 }
4402
-.shithub-quick-dropdown { padding: 0.5rem; }
4403
-.shithub-quick-section { padding: 0.25rem 0; border-bottom: 1px solid var(--border-default); }
4404
-.shithub-quick-section:last-of-type { border-bottom: none; }
4405
-.shithub-quick-section h3 { font-size: 0.7rem; text-transform: uppercase; color: var(--fg-muted); margin: 0.25rem 0; }
4406
-.shithub-quick-section ul { list-style: none; padding: 0; margin: 0; }
4407
-.shithub-quick-section li { padding: 0.25rem 0; }
4408
-.shithub-quick-footer { padding: 0.5rem 0; border-top: 1px solid var(--border-default); text-align: center; }
4486
+.shithub-search-rightbar {
4487
+  padding-top: 3.25rem;
4488
+}
4489
+.shithub-search-tip-card {
4490
+  padding: 1rem 0;
4491
+  border-bottom: 1px solid var(--border-muted);
4492
+  color: var(--fg-muted);
4493
+  font-size: 0.875rem;
4494
+}
4495
+.shithub-search-tip-card:first-child {
4496
+  padding-top: 0;
4497
+}
4498
+.shithub-search-tip-card strong {
4499
+  display: inline-flex;
4500
+  align-items: center;
4501
+  gap: 0.35rem;
4502
+  color: var(--fg-default);
4503
+}
4504
+.shithub-search-tip-card p {
4505
+  margin: 0.4rem 0 0;
4506
+  line-height: 1.45;
4507
+}
4508
+.shithub-search-tip-card ul {
4509
+  display: flex;
4510
+  flex-direction: column;
4511
+  gap: 0.35rem;
4512
+  padding: 0;
4513
+  margin: 0.55rem 0 0;
4514
+  list-style: none;
4515
+}
4516
+.shithub-search-tip-card code {
4517
+  padding: 0.1rem 0.3rem;
4518
+  border: 1px solid var(--border-muted);
4519
+  border-radius: 6px;
4520
+  background: var(--canvas-subtle);
4521
+  color: var(--fg-default);
4522
+  font-size: 0.8rem;
4523
+}
4524
+.shithub-quick-dropdown {
4525
+  padding: 0.45rem 0;
4526
+}
4527
+.shithub-quick-section {
4528
+  padding: 0.25rem 0;
4529
+  border-bottom: 1px solid var(--border-default);
4530
+}
4531
+.shithub-quick-section:last-of-type {
4532
+  border-bottom: none;
4533
+}
4534
+.shithub-quick-section h3 {
4535
+  margin: 0.25rem 0.75rem 0.35rem;
4536
+  color: var(--fg-muted);
4537
+  font-size: 0.75rem;
4538
+  font-weight: 600;
4539
+}
4540
+.shithub-quick-section ul {
4541
+  padding: 0;
4542
+  margin: 0;
4543
+  list-style: none;
4544
+}
4545
+.shithub-quick-section a {
4546
+  display: grid;
4547
+  grid-template-columns: 20px minmax(0, 1fr) auto;
4548
+  gap: 0.55rem;
4549
+  align-items: center;
4550
+  padding: 0.4rem 0.75rem;
4551
+  color: var(--fg-default);
4552
+}
4553
+.shithub-quick-section a:hover,
4554
+.shithub-quick-section a:focus {
4555
+  background: var(--canvas-subtle);
4556
+  text-decoration: none;
4557
+}
4558
+.shithub-quick-leading {
4559
+  display: inline-flex;
4560
+  align-items: center;
4561
+  justify-content: center;
4562
+  color: var(--fg-muted);
4563
+}
4564
+.shithub-quick-title,
4565
+.shithub-quick-context {
4566
+  min-width: 0;
4567
+  overflow: hidden;
4568
+  text-overflow: ellipsis;
4569
+  white-space: nowrap;
4570
+}
4571
+.shithub-quick-context {
4572
+  grid-column: 2 / -1;
4573
+  margin-top: -0.15rem;
4574
+  color: var(--fg-muted);
4575
+  font-size: 0.75rem;
4576
+}
4577
+.shithub-quick-empty {
4578
+  margin: 0;
4579
+  padding: 0.8rem 0.75rem;
4580
+  color: var(--fg-muted);
4581
+  font-size: 0.875rem;
4582
+}
4583
+.shithub-quick-footer {
4584
+  padding: 0.6rem 0.75rem 0.2rem;
4585
+  border-top: 1px solid var(--border-default);
4586
+  font-size: 0.875rem;
4587
+}
44094588
 @media (max-width: 760px) {
44104589
   .shithub-nav-search {
44114590
     order: 3;
@@ -4421,8 +4600,12 @@ button.shithub-repo-action {
44214600
     gap: 1rem;
44224601
   }
44234602
   .shithub-search-sidebar {
4603
+    position: static;
44244604
     padding-top: 0;
44254605
   }
4606
+  .shithub-search-rightbar {
4607
+    display: none;
4608
+  }
44264609
   .shithub-search-filter-list {
44274610
     flex-direction: row;
44284611
     gap: 0.25rem;
@@ -4445,6 +4628,14 @@ button.shithub-repo-action {
44454628
     align-self: flex-start;
44464629
   }
44474630
 }
4631
+@media (min-width: 761px) and (max-width: 1100px) {
4632
+  .shithub-search-shell {
4633
+    grid-template-columns: 256px minmax(0, 1fr);
4634
+  }
4635
+  .shithub-search-rightbar {
4636
+    display: none;
4637
+  }
4638
+}
44484639
 
44494640
 /* S34 — admin impersonation banner. Sticky-top, loud red so the
44504641
    admin can't lose track of "I am viewing as someone else right
internal/web/templates/_layout.htmlmodified
@@ -65,6 +65,92 @@
6565
     });
6666
   })();
6767
 
68
+  (function () {
69
+    var root = document.querySelector("[data-search-root]");
70
+    if (!root) return;
71
+    var input = root.querySelector("[data-search-input]");
72
+    var panel = root.querySelector("[data-search-results]");
73
+    if (!input || !panel || !window.fetch) return;
74
+
75
+    var timer = 0;
76
+    var controller = null;
77
+
78
+    function closePanel() {
79
+      panel.hidden = true;
80
+      panel.innerHTML = "";
81
+      input.setAttribute("aria-expanded", "false");
82
+      if (controller) {
83
+        controller.abort();
84
+        controller = null;
85
+      }
86
+    }
87
+
88
+    function showPanel(html) {
89
+      panel.innerHTML = html;
90
+      panel.hidden = false;
91
+      input.setAttribute("aria-expanded", "true");
92
+    }
93
+
94
+    function loadSuggestions() {
95
+      var query = input.value.trim();
96
+      if (query.length < 2) {
97
+        closePanel();
98
+        return;
99
+      }
100
+      if (controller) controller.abort();
101
+      controller = new AbortController();
102
+      fetch("/search/quick?q=" + encodeURIComponent(query), {
103
+        headers: { "X-Requested-With": "XMLHttpRequest" },
104
+        signal: controller.signal
105
+      }).then(function (response) {
106
+        if (response.status === 204) return "";
107
+        if (!response.ok) throw new Error("search quick failed");
108
+        return response.text();
109
+      }).then(function (html) {
110
+        if (!html) {
111
+          closePanel();
112
+          return;
113
+        }
114
+        showPanel(html);
115
+      }).catch(function (error) {
116
+        if (error.name !== "AbortError") closePanel();
117
+      });
118
+    }
119
+
120
+    function queueSuggestions() {
121
+      window.clearTimeout(timer);
122
+      timer = window.setTimeout(loadSuggestions, 160);
123
+    }
124
+
125
+    input.addEventListener("input", queueSuggestions);
126
+    input.addEventListener("focus", function () {
127
+      if (input.value.trim().length >= 2) queueSuggestions();
128
+    });
129
+    input.addEventListener("keydown", function (event) {
130
+      if (event.key === "Escape") {
131
+        closePanel();
132
+        input.blur();
133
+      }
134
+    });
135
+    root.addEventListener("submit", closePanel);
136
+    document.addEventListener("click", function (event) {
137
+      if (!root.contains(event.target)) closePanel();
138
+    });
139
+    document.addEventListener("keydown", function (event) {
140
+      var active = document.activeElement;
141
+      var editable = active && (
142
+        active.tagName === "INPUT" ||
143
+        active.tagName === "TEXTAREA" ||
144
+        active.isContentEditable
145
+      );
146
+      if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey && !editable) {
147
+        event.preventDefault();
148
+        input.focus();
149
+        input.select();
150
+      }
151
+    });
152
+  })();
153
+
68154
   (function () {
69155
     var modal = document.querySelector("[data-pins-modal]");
70156
     if (!modal) return;
internal/web/templates/_nav.htmlmodified
@@ -4,11 +4,12 @@
44
     {{ octicon "shithub" }}
55
     <span>shithub</span>
66
   </a>
7
-  <form action="/search" method="get" class="shithub-nav-search" role="search">
7
+  <form action="/search" method="get" class="shithub-nav-search" role="search" data-search-root>
88
     <span class="shithub-nav-search-icon">{{ octicon "search" }}</span>
9
-    <input type="text" name="q"{{ with .GlobalSearchQuery }} value="{{ . }}"{{ end }} placeholder="Type / to search" aria-label="Search" autocomplete="off">
9
+    <input type="text" name="q"{{ with .GlobalSearchQuery }} value="{{ . }}"{{ end }} placeholder="Type / to search" aria-label="Search" autocomplete="off" aria-haspopup="listbox" aria-expanded="false" aria-controls="global-search-suggestions" data-search-input>
1010
     <span class="shithub-nav-search-key" aria-hidden="true">/</span>
11
-    <input type="hidden" name="type" value="repos">
11
+    <input type="hidden" name="type" value="repositories">
12
+    <div id="global-search-suggestions" class="shithub-nav-search-popover" data-search-results hidden></div>
1213
   </form>
1314
   <nav class="shithub-nav-links" aria-label="Primary">
1415
     <a href="/explore">Explore</a>
internal/web/templates/search/_quick_dropdown.htmlmodified
@@ -1,40 +1,59 @@
11
 {{ define "page" -}}
2
-<div class="shithub-quick-dropdown" role="listbox">
3
-  {{ if .Repos }}
4
-    <div class="shithub-quick-section">
5
-      <h3>Repositories</h3>
6
-      <ul>
7
-        {{ range .Repos }}
8
-        <li>
9
-          <a href="/{{ .OwnerUsername }}/{{ .Name }}">{{ .OwnerUsername }}/{{ .Name }}</a>
10
-          {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">private</span>{{ end }}
11
-        </li>
12
-        {{ end }}
13
-      </ul>
14
-    </div>
15
-  {{ end }}
16
-  {{ if .Issues }}
17
-    <div class="shithub-quick-section">
18
-      <h3>Issues</h3>
19
-      <ul>
20
-        {{ range .Issues }}
21
-        <li><a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }} — {{ .Title }}</a></li>
22
-        {{ end }}
23
-      </ul>
24
-    </div>
25
-  {{ end }}
26
-  {{ if .Users }}
27
-    <div class="shithub-quick-section">
28
-      <h3>Users</h3>
29
-      <ul>
30
-        {{ range .Users }}
31
-        <li><a href="/{{ .Username }}">@{{ .Username }} — {{ .DisplayName }}</a></li>
32
-        {{ end }}
33
-      </ul>
34
-    </div>
2
+<div class="shithub-quick-dropdown" role="listbox" aria-label="Search suggestions">
3
+  {{ if or .Repos .Issues .Users }}
4
+    {{ if .Repos }}
5
+      <section class="shithub-quick-section" aria-labelledby="quick-repositories-heading">
6
+        <h3 id="quick-repositories-heading">Repositories</h3>
7
+        <ul>
8
+          {{ range .Repos }}
9
+          <li>
10
+            <a href="/{{ .OwnerUsername }}/{{ .Name }}" role="option">
11
+              <span class="shithub-quick-leading">{{ octicon "repo" }}</span>
12
+              <span class="shithub-quick-title">{{ .OwnerUsername }}/{{ .Name }}</span>
13
+              {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ end }}
14
+            </a>
15
+          </li>
16
+          {{ end }}
17
+        </ul>
18
+      </section>
19
+    {{ end }}
20
+    {{ if .Issues }}
21
+      <section class="shithub-quick-section" aria-labelledby="quick-issues-heading">
22
+        <h3 id="quick-issues-heading">Issues and pull requests</h3>
23
+        <ul>
24
+          {{ range .Issues }}
25
+          <li>
26
+            <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/{{ if eq .Kind "pr" }}pulls{{ else }}issues{{ end }}/{{ .Number }}" role="option">
27
+              <span class="shithub-quick-leading">{{ if eq .Kind "pr" }}{{ octicon "git-pull-request" }}{{ else }}{{ octicon "issue-opened" }}{{ end }}</span>
28
+              <span class="shithub-quick-title">{{ .Title }}</span>
29
+              <span class="shithub-quick-context">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</span>
30
+            </a>
31
+          </li>
32
+          {{ end }}
33
+        </ul>
34
+      </section>
35
+    {{ end }}
36
+    {{ if .Users }}
37
+      <section class="shithub-quick-section" aria-labelledby="quick-users-heading">
38
+        <h3 id="quick-users-heading">Users</h3>
39
+        <ul>
40
+          {{ range .Users }}
41
+          <li>
42
+            <a href="/{{ .Username }}" role="option">
43
+              <img src="/avatars/{{ .Username }}" alt="" width="20" height="20" class="shithub-quick-avatar">
44
+              <span class="shithub-quick-title">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</span>
45
+              <span class="shithub-quick-context">@{{ .Username }}</span>
46
+            </a>
47
+          </li>
48
+          {{ end }}
49
+        </ul>
50
+      </section>
51
+    {{ end }}
52
+  {{ else }}
53
+    <p class="shithub-quick-empty">No results found.</p>
3554
   {{ end }}
3655
   <div class="shithub-quick-footer">
37
-    <a href="/search?q={{ .Query }}">See all results &rarr;</a>
56
+    <a href="{{ .SearchHref }}">See all results for <strong>{{ .Query }}</strong></a>
3857
   </div>
3958
 </div>
4059
 {{- end }}
internal/web/templates/search/results.htmlmodified
@@ -2,12 +2,12 @@
22
 <section class="shithub-search-page">
33
   <div class="shithub-search-shell">
44
     <aside class="shithub-search-sidebar" aria-label="Search filters">
5
-      <h2>Filter by</h2>
6
-      <nav class="shithub-search-filter-list">
5
+      <h2 id="search-filters-title">Filter by</h2>
6
+      <nav class="shithub-search-filter-list" aria-labelledby="search-filters-title">
77
         {{ range .SearchTabs }}
88
           <a href="{{ .Href }}" class="shithub-search-filter{{ if .Selected }} is-selected{{ end }}"{{ if .Selected }} aria-current="page"{{ end }}>
99
             <span class="shithub-search-filter-label">{{ octicon .Icon }} {{ .Label }}</span>
10
-            {{ if .Count }}<span class="shithub-search-filter-count">{{ .Count }}</span>{{ end }}
10
+            <span class="shithub-search-filter-count">{{ if .Count }}{{ .Count }}{{ else }}0{{ end }}</span>
1111
           </a>
1212
         {{ end }}
1313
       </nav>
@@ -15,7 +15,8 @@
1515
 
1616
     <div class="shithub-search-results">
1717
       <form action="/search" method="get" class="shithub-search-query-form">
18
-        <input type="text" name="q" value="{{ .Query }}" placeholder="Search shithub" autofocus>
18
+        <label class="sr-only" for="search-page-query">Search query</label>
19
+        <input id="search-page-query" type="text" name="q" value="{{ .Query }}" placeholder="Search shithub" autofocus>
1920
         <input type="hidden" name="type" value="{{ .Tab }}">
2021
         <button type="submit" class="shithub-button shithub-button-primary">Search</button>
2122
       </form>
@@ -27,39 +28,33 @@
2728
         </div>
2829
       {{ else }}
2930
         <div class="shithub-search-results-head">
30
-          <h1>
31
-            {{ .Total }}
32
-            {{ if eq .Tab "repos" }}repository{{ if ne .Total 1 }} results{{ else }} result{{ end }}
33
-            {{ else if eq .Tab "code" }}code result{{ if ne .Total 1 }}s{{ end }}
34
-            {{ else if eq .Tab "issues" }}issue result{{ if ne .Total 1 }}s{{ end }}
35
-            {{ else if eq .Tab "pulls" }}pull request result{{ if ne .Total 1 }}s{{ end }}
36
-            {{ else if eq .Tab "users" }}user result{{ if ne .Total 1 }}s{{ end }}
37
-            {{ else }}result{{ if ne .Total 1 }}s{{ end }}
38
-            {{ end }}
39
-          </h1>
31
+          <div>
32
+            <h1>{{ .ResultHeading }}</h1>
33
+            <p>for <span class="shithub-search-query-echo">{{ .Query }}</span></p>
34
+          </div>
4035
           <details class="shithub-search-sort">
4136
             <summary>Sort: Best match</summary>
42
-            <div>
43
-              <span>Best match</span>
44
-              <span>Most recently updated</span>
37
+            <div role="menu">
38
+              <span role="menuitem">Best match</span>
39
+              <span role="menuitem" aria-disabled="true">Most recently updated</span>
4540
             </div>
4641
           </details>
4742
         </div>
4843
 
49
-        {{ if eq .Tab "repos" }}
44
+        {{ if eq .Tab "repositories" }}
5045
           {{ if .Repos }}
5146
             <ol class="shithub-search-result-list">
5247
             {{ range .Repos }}
5348
               <li class="shithub-search-result shithub-search-repo-result">
5449
                 <div class="shithub-search-result-main">
55
-                  <h2>
50
+                  <h2 class="shithub-search-result-title">
5651
                     <img src="/avatars/{{ .OwnerUsername }}" alt="" width="20" height="20" class="shithub-search-avatar">
57
-                    <a href="/{{ .OwnerUsername }}/{{ .Name }}">{{ .OwnerUsername }}/{{ .Name }}</a>
52
+                    <a href="/{{ .OwnerUsername }}/{{ .Name }}"><span class="shithub-search-match">{{ .OwnerUsername }}/{{ .Name }}</span></a>
5853
                     {{ if eq .Visibility "private" }}<span class="shithub-pill shithub-pill-private">Private</span>{{ end }}
5954
                   </h2>
60
-                  {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
55
+                  {{ if .Description }}<p class="shithub-search-result-desc">{{ .Description }}</p>{{ end }}
6156
                   <ul class="shithub-search-result-meta">
62
-                    <li>{{ octicon "star" }} {{ .StarCount }}</li>
57
+                    <li><a href="/{{ .OwnerUsername }}/{{ .Name }}/stargazers">{{ octicon "star" }} {{ .StarCount }}</a></li>
6358
                     <li>Updated {{ relativeTime .UpdatedAt }}</li>
6459
                   </ul>
6560
                 </div>
@@ -75,17 +70,18 @@
7570
             {{ range .Issues }}
7671
               <li class="shithub-search-result">
7772
                 <div class="shithub-search-result-main">
78
-                  <h2>
73
+                  <p class="shithub-search-result-context"><a href="/{{ .OwnerUsername }}/{{ .RepoName }}">{{ .OwnerUsername }}/{{ .RepoName }}</a></p>
74
+                  <h2 class="shithub-search-result-title">
7975
                     <span class="shithub-search-state shithub-search-state-{{ .State }}">
8076
                       {{ if eq .State "closed" }}{{ octicon "issue-closed" }}{{ else }}{{ octicon "issue-opened" }}{{ end }}
8177
                     </span>
82
-                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">{{ .Title }}</a>
78
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}"><span class="shithub-search-match">{{ .Title }}</span></a>
8379
                   </h2>
84
-                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</p>
8580
                   <ul class="shithub-search-result-meta">
81
+                    {{ if .AuthorName }}<li><img src="/avatars/{{ .AuthorName }}" alt="" width="16" height="16" class="shithub-search-mini-avatar"> {{ .AuthorName }}</li>{{ end }}
8682
                     <li>{{ .State }}</li>
87
-                    {{ if .AuthorName }}<li>Opened by @{{ .AuthorName }}</li>{{ end }}
8883
                     <li>Updated {{ relativeTime .UpdatedAt }}</li>
84
+                    <li><a href="/{{ .OwnerUsername }}/{{ .RepoName }}/issues/{{ .Number }}">#{{ .Number }}</a></li>
8985
                   </ul>
9086
                 </div>
9187
               </li>
@@ -93,21 +89,22 @@
9389
             </ol>
9490
           {{ else }}<p class="shithub-search-empty">No issues matched your search.</p>{{ end }}
9591
 
96
-        {{ else if eq .Tab "pulls" }}
92
+        {{ else if eq .Tab "pullrequests" }}
9793
           {{ if .Issues }}
9894
             <ol class="shithub-search-result-list">
9995
             {{ range .Issues }}
10096
               <li class="shithub-search-result">
10197
                 <div class="shithub-search-result-main">
102
-                  <h2>
98
+                  <p class="shithub-search-result-context"><a href="/{{ .OwnerUsername }}/{{ .RepoName }}">{{ .OwnerUsername }}/{{ .RepoName }}</a></p>
99
+                  <h2 class="shithub-search-result-title">
103100
                     <span class="shithub-search-state shithub-search-state-pr">{{ octicon "git-pull-request" }}</span>
104
-                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/pulls/{{ .Number }}">{{ .Title }}</a>
101
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/pulls/{{ .Number }}"><span class="shithub-search-match">{{ .Title }}</span></a>
105102
                   </h2>
106
-                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }}#{{ .Number }}</p>
107103
                   <ul class="shithub-search-result-meta">
104
+                    {{ if .AuthorName }}<li><img src="/avatars/{{ .AuthorName }}" alt="" width="16" height="16" class="shithub-search-mini-avatar"> {{ .AuthorName }}</li>{{ end }}
108105
                     <li>{{ .State }}</li>
109
-                    {{ if .AuthorName }}<li>Opened by @{{ .AuthorName }}</li>{{ end }}
110106
                     <li>Updated {{ relativeTime .UpdatedAt }}</li>
107
+                    <li><a href="/{{ .OwnerUsername }}/{{ .RepoName }}/pulls/{{ .Number }}">#{{ .Number }}</a></li>
111108
                   </ul>
112109
                 </div>
113110
               </li>
@@ -120,11 +117,13 @@
120117
             <ol class="shithub-search-result-list">
121118
             {{ range .Users }}
122119
               <li class="shithub-search-result shithub-search-user-result">
123
-                <img src="/avatars/{{ .Username }}" alt="" width="40" height="40" class="shithub-search-user-avatar">
120
+                <img src="/avatars/{{ .Username }}" alt="" width="48" height="48" class="shithub-search-user-avatar">
124121
                 <div class="shithub-search-result-main">
125
-                  <h2><a href="/{{ .Username }}">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a></h2>
126
-                  <p class="shithub-search-result-path">@{{ .Username }}</p>
127
-                  {{ if .Bio }}<p>{{ .Bio }}</p>{{ end }}
122
+                  <h2 class="shithub-search-result-title">
123
+                    <a href="/{{ .Username }}">{{ if .DisplayName }}{{ .DisplayName }}{{ else }}{{ .Username }}{{ end }}</a>
124
+                    <span class="shithub-search-user-login">{{ .Username }}</span>
125
+                  </h2>
126
+                  {{ if .Bio }}<p class="shithub-search-result-desc">{{ .Bio }}</p>{{ end }}
128127
                 </div>
129128
               </li>
130129
             {{ end }}
@@ -137,11 +136,12 @@
137136
             {{ range .Code }}
138137
               <li class="shithub-search-result">
139138
                 <div class="shithub-search-result-main">
140
-                  <h2>
139
+                  <p class="shithub-search-result-context"><a href="/{{ .OwnerUsername }}/{{ .RepoName }}">{{ .OwnerUsername }}/{{ .RepoName }}</a></p>
140
+                  <h2 class="shithub-search-result-title">
141141
                     {{ octicon "file" }}
142
-                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/blob/{{ .RefName }}/{{ .Path }}">{{ .Path }}</a>
142
+                    <a href="/{{ .OwnerUsername }}/{{ .RepoName }}/blob/{{ .RefName }}/{{ .Path }}"><span class="shithub-search-match">{{ .Path }}</span></a>
143143
                   </h2>
144
-                  <p class="shithub-search-result-path">{{ .OwnerUsername }}/{{ .RepoName }} on {{ .RefName }}</p>
144
+                  <p class="shithub-search-result-path">on {{ .RefName }}</p>
145145
                   {{ if .PreviewLine }}<pre class="shithub-search-code-preview"><code>{{ .PreviewLine }}</code></pre>{{ end }}
146146
                 </div>
147147
               </li>
@@ -152,12 +152,27 @@
152152
 
153153
         {{ if or .HasPrev .HasNext }}
154154
         <nav class="shithub-search-pagination" aria-label="Pagination">
155
-          {{ if .HasPrev }}<a href="?q={{ .Query }}&type={{ .Tab }}&p={{ sub .Page 1 }}" class="shithub-button">Previous</a>{{ else }}<span class="shithub-button shithub-button-disabled">Previous</span>{{ end }}
156
-          {{ if .HasNext }}<a href="?q={{ .Query }}&type={{ .Tab }}&p={{ add .Page 1 }}" class="shithub-button">Next</a>{{ else }}<span class="shithub-button shithub-button-disabled">Next</span>{{ end }}
155
+          {{ if .HasPrev }}<a href="{{ .PrevHref }}" class="shithub-button">Previous</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Previous</span>{{ end }}
156
+          {{ if .HasNext }}<a href="{{ .NextHref }}" class="shithub-button">Next</a>{{ else }}<span class="shithub-button shithub-button-disabled" aria-disabled="true">Next</span>{{ end }}
157157
         </nav>
158158
         {{ end }}
159159
       {{ end }}
160160
     </div>
161
+
162
+    <aside class="shithub-search-rightbar" aria-label="Search tips">
163
+      <div class="shithub-search-tip-card">
164
+        <strong>{{ octicon "search" }} ProTip!</strong>
165
+        <p>{{ .SearchProTip }}</p>
166
+      </div>
167
+      <div class="shithub-search-tip-card">
168
+        <strong>Search syntax tips</strong>
169
+        <ul>
170
+          <li><code>repo:owner/name</code></li>
171
+          <li><code>is:open</code></li>
172
+          <li><code>author:username</code></li>
173
+        </ul>
174
+      </div>
175
+    </aside>
161176
   </div>
162177
 </section>
163178
 {{- end }}