Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

search.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

search.tsxBlame996 lines · 1 contributor
3ef4c9dClaude1/**
2 * Global search — across repos, users, issues, PRs.
3 *
4 * GET /search?q=...&type=repos|users|issues|prs
5 *
6 * Text search uses Postgres ILIKE — good enough for the scale GlueCron is at
7 * today. If traffic grows, swap in pgvector + AI embeddings.
8 */
9
10import { Hono } from "hono";
283fbc2Claude11import { and, desc, eq, sql } from "drizzle-orm";
3ef4c9dClaude12import { db } from "../db";
13import {
14 issues,
15 pullRequests,
16 repositories,
17 users,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { getUnreadCount } from "../lib/unread";
23
24const search = new Hono<AuthEnv>();
25search.use("*", softAuth);
26
283fbc2Claude27const SearchPageStyle = () => (
28 <style
29 dangerouslySetInnerHTML={{
30 __html: `
31 /* ─── Hero ─── */
32 .search-page-hero {
33 position: relative;
34 margin: 4px 0 22px;
35 padding: 32px 32px 28px;
36 background: var(--bg-elevated);
37 border: 1px solid var(--border);
38 border-radius: 16px;
39 overflow: hidden;
40 }
41 .search-page-hero::before {
42 content: '';
43 position: absolute;
44 top: 0; left: 0; right: 0;
45 height: 2px;
6fd5915Claude46 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
283fbc2Claude47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .search-page-hero-bg {
51 position: absolute;
52 inset: -30% -10% auto auto;
53 width: 360px;
54 height: 360px;
55 pointer-events: none;
56 z-index: 0;
57 }
58 .search-page-hero-orb {
59 position: absolute;
60 inset: 0;
6fd5915Claude61 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
283fbc2Claude62 filter: blur(80px);
63 opacity: 0.7;
64 animation: searchPageHeroOrb 14s ease-in-out infinite;
65 }
66 @keyframes searchPageHeroOrb {
67 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
68 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
69 }
70 @media (prefers-reduced-motion: reduce) {
71 .search-page-hero-orb { animation: none; }
72 }
73 .search-page-hero-inner {
74 position: relative;
75 z-index: 1;
76 display: flex;
77 flex-direction: column;
78 gap: 16px;
79 }
80 .search-page-hero-eyebrow {
81 font-size: 12.5px;
82 color: var(--text-muted);
83 letter-spacing: 0.06em;
84 text-transform: uppercase;
85 font-weight: 600;
86 }
87 .search-page-hero-title {
88 font-family: var(--font-display);
89 font-size: clamp(28px, 4vw, 40px);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0;
94 color: var(--text-strong);
95 }
96 .search-page-hero-sub {
97 font-size: 15px;
98 color: var(--text-muted);
99 margin: 0;
100 line-height: 1.5;
101 max-width: 620px;
102 }
103 .search-page-form {
104 margin-top: 6px;
105 position: relative;
106 display: flex;
107 align-items: stretch;
108 gap: 0;
109 }
110 .search-page-input-wrap {
111 position: relative;
112 flex: 1;
113 }
114 .search-page-input-icon {
115 position: absolute;
116 top: 50%;
117 left: 18px;
118 transform: translateY(-50%);
119 color: var(--text-muted);
120 pointer-events: none;
121 width: 18px;
122 height: 18px;
123 }
124 .search-page-input {
125 width: 100%;
126 padding: 14px 18px 14px 48px;
127 background: var(--bg);
128 border: 1px solid var(--border);
129 border-radius: 14px;
130 color: var(--text);
131 font-size: 15px;
132 font-family: inherit;
133 transition: border-color 140ms ease, box-shadow 140ms ease;
134 }
135 .search-page-input::placeholder { color: var(--text-muted); }
136 .search-page-input:focus {
137 outline: none;
6fd5915Claude138 border-color: rgba(91,110,232,0.55);
139 box-shadow: 0 0 0 4px rgba(91,110,232,0.12);
283fbc2Claude140 }
141 .search-page-kbd {
142 position: absolute;
143 right: 14px;
144 top: 50%;
145 transform: translateY(-50%);
146 font-family: var(--font-mono, monospace);
147 font-size: 11px;
148 color: var(--text-muted);
149 background: rgba(255,255,255,0.04);
150 border: 1px solid var(--border);
151 padding: 2px 8px;
152 border-radius: 6px;
153 pointer-events: none;
154 }
155 @media (max-width: 720px) {
156 .search-page-hero { padding: 24px 20px; }
157 .search-page-kbd { display: none; }
4bab295Claude158 .search-page-input { padding: 14px 16px 14px 44px; min-height: 44px; }
159 .search-page-tabs { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; }
160 .search-page-tab { white-space: nowrap; min-height: 40px; padding: 10px 14px; }
161 .search-page-result { padding: 14px; gap: 10px; }
162 .search-page-result-avatar { width: 36px; height: 36px; font-size: 13px; }
163 .search-page-empty { padding: 40px 20px; }
164 .search-page-code { padding: 12px; }
165 .search-page-shortcut-row { padding: 12px 14px; flex-wrap: wrap; gap: 6px; }
283fbc2Claude166 }
167
168 /* ─── Tab pills ─── */
169 .search-page-tabs {
170 display: inline-flex;
171 background: var(--bg-elevated);
172 border: 1px solid var(--border);
173 border-radius: 9999px;
174 padding: 4px;
175 gap: 2px;
176 margin: 0 0 18px;
177 flex-wrap: wrap;
178 }
179 .search-page-tab {
180 display: inline-flex;
181 align-items: center;
182 gap: 7px;
183 padding: 7px 16px;
184 border-radius: 9999px;
185 font-size: 13px;
186 font-weight: 500;
187 color: var(--text-muted);
188 text-decoration: none;
189 transition: color 120ms ease, background 120ms ease;
190 line-height: 1.4;
191 }
192 .search-page-tab:hover {
193 color: var(--text-strong);
194 text-decoration: none;
195 }
196 .search-page-tab.is-active {
6fd5915Claude197 background: rgba(91,110,232,0.14);
283fbc2Claude198 color: var(--text-strong);
199 }
200 .search-page-tab-count {
201 font-variant-numeric: tabular-nums;
202 font-size: 11.5px;
203 color: var(--text-muted);
204 background: rgba(255,255,255,0.04);
205 padding: 1px 8px;
206 border-radius: 9999px;
207 }
208 .search-page-tab.is-active .search-page-tab-count {
6fd5915Claude209 background: rgba(91,110,232,0.20);
283fbc2Claude210 color: var(--text);
211 }
212
213 /* ─── Result list ─── */
214 .search-page-results {
215 display: flex;
216 flex-direction: column;
217 gap: 10px;
218 }
219 .search-page-result {
220 position: relative;
221 display: flex;
222 gap: 14px;
223 padding: 16px 20px;
224 background: var(--bg-elevated);
225 border: 1px solid var(--border);
226 border-radius: 14px;
227 text-decoration: none;
228 color: inherit;
229 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
230 }
231 .search-page-result:hover {
232 transform: translateY(-1px);
6fd5915Claude233 border-color: rgba(91,110,232,0.45);
234 box-shadow: 0 10px 22px -16px rgba(0,0,0,0.5), 0 0 18px -8px rgba(91,110,232,0.18);
283fbc2Claude235 text-decoration: none;
236 }
237 .search-page-result-avatar {
238 width: 40px;
239 height: 40px;
240 border-radius: 10px;
241 display: inline-flex;
242 align-items: center;
243 justify-content: center;
244 font-family: var(--font-display);
245 font-weight: 700;
246 font-size: 15px;
247 color: #fff;
6fd5915Claude248 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
283fbc2Claude249 flex-shrink: 0;
250 letter-spacing: -0.01em;
251 }
252 .search-page-result-avatar.is-user {
253 border-radius: 9999px;
254 }
255 .search-page-result-body {
256 flex: 1;
257 min-width: 0;
258 display: flex;
259 flex-direction: column;
260 gap: 4px;
261 }
262 .search-page-result-title {
263 font-family: var(--font-display);
264 font-size: 15.5px;
265 font-weight: 600;
266 color: var(--text-strong);
267 letter-spacing: -0.014em;
268 line-height: 1.35;
269 margin: 0;
270 display: flex;
271 flex-wrap: wrap;
272 align-items: center;
273 gap: 8px;
274 }
275 .search-page-result:hover .search-page-result-title { color: var(--accent); }
276 .search-page-result-meta {
277 font-size: 12.5px;
278 color: var(--text-muted);
279 line-height: 1.5;
280 }
281 .search-page-result-meta strong {
282 color: var(--text);
283 font-weight: 600;
284 }
285 .search-page-result-desc {
286 font-size: 13.5px;
287 color: var(--text-muted);
288 margin: 4px 0 0;
289 line-height: 1.5;
290 display: -webkit-box;
291 -webkit-line-clamp: 2;
292 -webkit-box-orient: vertical;
293 overflow: hidden;
294 }
295
296 /* ─── State pill (issues/PRs) ─── */
297 .search-page-state {
298 display: inline-flex;
299 align-items: center;
300 gap: 5px;
301 padding: 2px 10px;
302 border-radius: 9999px;
303 font-size: 11.5px;
304 font-weight: 600;
305 line-height: 1.5;
306 letter-spacing: 0.005em;
307 }
308 .search-page-state-open {
309 background: rgba(52,211,153,0.10);
310 color: #34d399;
311 border: 1px solid rgba(52,211,153,0.30);
312 }
313 .search-page-state-closed {
6fd5915Claude314 background: rgba(91,110,232,0.12);
315 color: #5b6ee8;
316 border: 1px solid rgba(91,110,232,0.30);
283fbc2Claude317 }
318 .search-page-state-merged {
6fd5915Claude319 background: rgba(95,143,160,0.10);
320 color: #5f8fa0;
321 border: 1px solid rgba(95,143,160,0.28);
283fbc2Claude322 }
323 .search-page-state-draft {
324 background: rgba(255,255,255,0.04);
325 color: var(--text-muted);
326 border: 1px solid var(--border);
327 }
328
329 /* ─── Code result card ─── */
330 .search-page-code {
331 display: flex;
332 flex-direction: column;
333 gap: 10px;
334 padding: 14px 18px;
335 background: var(--bg-elevated);
336 border: 1px solid var(--border);
337 border-radius: 14px;
338 }
339 .search-page-code-path {
340 font-family: var(--font-mono, monospace);
341 font-size: 12.5px;
342 color: var(--text);
343 display: flex;
344 align-items: center;
345 gap: 8px;
346 }
347 .search-page-code-snippet {
348 display: flex;
349 background: var(--bg);
350 border: 1px solid var(--border);
351 border-radius: 10px;
352 padding: 8px 12px;
353 font-family: var(--font-mono, monospace);
354 font-size: 12.5px;
355 overflow-x: auto;
356 }
357 .search-page-code-lineno {
358 color: var(--text-muted);
359 flex-shrink: 0;
360 padding-right: 14px;
361 border-right: 1px solid var(--border);
362 margin-right: 12px;
363 text-align: right;
364 min-width: 32px;
365 font-variant-numeric: tabular-nums;
366 }
367 .search-page-code-line {
368 color: var(--text);
369 white-space: pre;
370 }
371 .search-page-code-mark {
6fd5915Claude372 background: rgba(91,110,232,0.22);
283fbc2Claude373 color: var(--text-strong);
374 border-radius: 3px;
375 padding: 0 2px;
376 }
377
378 /* ─── Empty / hint states ─── */
379 .search-page-empty {
380 margin: 0;
381 padding: 56px 32px;
382 background: var(--bg-elevated);
383 border: 1px solid var(--border);
384 border-radius: 16px;
385 text-align: center;
386 position: relative;
387 overflow: hidden;
388 }
389 .search-page-empty::before {
390 content: '';
391 position: absolute;
392 top: 0; left: 0; right: 0;
393 height: 2px;
6fd5915Claude394 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
283fbc2Claude395 opacity: 0.55;
396 pointer-events: none;
397 }
398 .search-page-empty-art {
399 width: 88px;
400 height: 88px;
401 margin: 0 auto 18px;
402 display: block;
403 opacity: 0.85;
404 }
405 .search-page-empty-title {
406 font-family: var(--font-display);
407 font-size: 22px;
408 font-weight: 700;
409 letter-spacing: -0.018em;
410 color: var(--text-strong);
411 margin: 0 0 8px;
412 }
413 .search-page-empty-sub {
414 font-size: 14.5px;
415 color: var(--text-muted);
416 line-height: 1.55;
417 margin: 0 auto 22px;
418 max-width: 480px;
419 }
420 .search-page-empty-cta {
421 display: inline-flex;
422 gap: 10px;
423 flex-wrap: wrap;
424 justify-content: center;
425 }
426 .search-page-empty-tip {
427 display: inline-flex;
428 flex-direction: column;
429 gap: 6px;
430 margin-top: 12px;
431 font-size: 13px;
432 color: var(--text-muted);
433 text-align: left;
434 }
435 .search-page-empty-tip-row { color: var(--text-muted); }
436 .search-page-empty-tip-row strong { color: var(--text); font-weight: 600; }
437
438 /* ─── Shortcuts page (kept simple — reuses panel) ─── */
439 .search-page-shortcut-row {
440 display: flex;
441 justify-content: space-between;
442 align-items: center;
443 padding: 11px 16px;
444 border-bottom: 1px solid var(--border);
445 }
446 .search-page-shortcut-row:last-child { border-bottom: none; }
447 `,
448 }}
449 />
450);
451
452function userInitials(u: typeof users.$inferSelect): string {
453 const src = u.displayName || u.username || "·";
454 const clean = src.replace(/[^a-zA-Z0-9]+/g, " ").trim();
455 if (!clean) return src.charAt(0).toUpperCase();
456 const parts = clean.split(/\s+/);
457 if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
458 return (parts[0][0] + parts[1][0]).toUpperCase();
459}
460
461function repoInitials(ownerName: string, name: string): string {
462 const src = name || ownerName || "·";
463 const clean = src.replace(/[^a-zA-Z0-9]+/g, " ").trim();
464 if (!clean) return src.charAt(0).toUpperCase();
465 const parts = clean.split(/\s+/);
466 if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
467 return (parts[0][0] + parts[1][0]).toUpperCase();
468}
469
3ef4c9dClaude470search.get("/search", async (c) => {
471 const user = c.get("user");
472 const q = (c.req.query("q") || "").trim();
473 const type = c.req.query("type") || "repos";
474 const unread = user ? await getUnreadCount(user.id) : 0;
475
476 let repoHits: Array<{
477 repo: typeof repositories.$inferSelect;
478 ownerName: string;
479 }> = [];
480 let userHits: Array<typeof users.$inferSelect> = [];
481 let issueHits: Array<{
482 id: string;
483 number: number;
484 title: string;
485 state: string;
486 repoName: string;
487 repoOwner: string;
488 }> = [];
489 let prHits: Array<{
490 id: string;
491 number: number;
492 title: string;
493 state: string;
494 repoName: string;
495 repoOwner: string;
496 }> = [];
497
498 if (q) {
499 const pat = `%${q}%`;
500 if (type === "repos") {
501 const results = await db
502 .select({ repo: repositories, ownerName: users.username })
503 .from(repositories)
504 .innerJoin(users, eq(repositories.ownerId, users.id))
505 .where(
506 and(
507 eq(repositories.isPrivate, false),
508 sql`(${repositories.name} ILIKE ${pat} OR ${repositories.description} ILIKE ${pat})`
509 )
510 )
511 .orderBy(desc(repositories.starCount))
512 .limit(30);
513 repoHits = results;
514 } else if (type === "users") {
515 userHits = await db
516 .select()
517 .from(users)
518 .where(
519 sql`(${users.username} ILIKE ${pat} OR ${users.displayName} ILIKE ${pat})`
520 )
521 .limit(30);
522 } else if (type === "issues") {
523 issueHits = await db
524 .select({
525 id: issues.id,
526 number: issues.number,
527 title: issues.title,
528 state: issues.state,
529 repoName: repositories.name,
530 repoOwner: users.username,
531 })
532 .from(issues)
533 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
534 .innerJoin(users, eq(repositories.ownerId, users.id))
535 .where(
536 and(
537 eq(repositories.isPrivate, false),
538 sql`(${issues.title} ILIKE ${pat} OR ${issues.body} ILIKE ${pat})`
539 )
540 )
541 .orderBy(desc(issues.updatedAt))
542 .limit(30);
543 } else if (type === "prs") {
544 prHits = await db
545 .select({
546 id: pullRequests.id,
547 number: pullRequests.number,
548 title: pullRequests.title,
549 state: pullRequests.state,
550 repoName: repositories.name,
551 repoOwner: users.username,
552 })
553 .from(pullRequests)
554 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
555 .innerJoin(users, eq(repositories.ownerId, users.id))
556 .where(
557 and(
558 eq(repositories.isPrivate, false),
559 sql`(${pullRequests.title} ILIKE ${pat} OR ${pullRequests.body} ILIKE ${pat})`
560 )
561 )
562 .orderBy(desc(pullRequests.updatedAt))
563 .limit(30);
564 }
565 }
566
283fbc2Claude567 // Active-tab count for the count chip (only the active tab knows its count).
568 const activeCount =
569 type === "repos"
570 ? repoHits.length
571 : type === "users"
572 ? userHits.length
573 : type === "issues"
574 ? issueHits.length
575 : type === "prs"
576 ? prHits.length
577 : 0;
578
579 const tabPill = (id: string, label: string) => {
580 const isActive = type === id;
581 const showCount = isActive && q;
582 return (
583 <a
584 href={`/search?q=${encodeURIComponent(q)}&type=${id}`}
585 class={`search-page-tab${isActive ? " is-active" : ""}`}
586 role="tab"
587 aria-selected={isActive ? "true" : "false"}
588 >
589 <span>{label}</span>
590 {showCount && (
591 <span class="search-page-tab-count">{activeCount}</span>
592 )}
593 </a>
594 );
595 };
596
597 const stateClass = (state: string) =>
598 state === "open"
599 ? "search-page-state search-page-state-open"
600 : state === "merged"
601 ? "search-page-state search-page-state-merged"
602 : state === "draft"
603 ? "search-page-state search-page-state-draft"
604 : "search-page-state search-page-state-closed";
605
606 const noResultsFor = (label: string) => (
607 <div class="search-page-empty">
608 <svg
609 class="search-page-empty-art"
610 viewBox="0 0 96 96"
611 fill="none"
612 xmlns="http://www.w3.org/2000/svg"
613 aria-hidden="true"
614 >
615 <defs>
616 <linearGradient id="searchEmptyG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
6fd5915Claude617 <stop stop-color="#5b6ee8" />
618 <stop offset="1" stop-color="#5f8fa0" />
283fbc2Claude619 </linearGradient>
620 </defs>
621 <circle cx="40" cy="40" r="22" stroke="url(#searchEmptyG)" stroke-width="4" />
622 <path d="M56 56L78 78" stroke="url(#searchEmptyG)" stroke-width="4" stroke-linecap="round" />
623 <path d="M32 40h16M40 32v16" stroke="url(#searchEmptyG)" stroke-width="3" stroke-linecap="round" opacity="0.55" />
624 </svg>
625 <h2 class="search-page-empty-title">No {label} for "{q}"</h2>
626 <p class="search-page-empty-sub">
627 Try a different keyword, switch categories, or browse{" "}
628 <a href="/explore">Explore</a> to discover what's shipping on Gluecron.
629 </p>
630 <div class="search-page-empty-cta">
631 <a href="/explore" class="btn btn-primary">
632 Go to Explore
633 </a>
634 <a href={`/search?q=&type=${type}`} class="btn">
635 Clear search
636 </a>
637 </div>
638 </div>
3ef4c9dClaude639 );
640
641 return c.html(
642 <Layout
643 title={q ? `Search — ${q}` : "Search"}
644 user={user}
645 notificationCount={unread}
646 >
283fbc2Claude647 <SearchPageStyle />
648
649 <section class="search-page-hero">
650 <div class="search-page-hero-bg" aria-hidden="true">
651 <div class="search-page-hero-orb" />
652 </div>
653 <div class="search-page-hero-inner">
654 <div class="search-page-hero-eyebrow">Global search</div>
655 <h1 class="search-page-hero-title">
656 <span class="gradient-text">Search</span> Gluecron.
657 </h1>
658 <p class="search-page-hero-sub">
659 Find repos, people, issues, pull requests, and code across the
660 platform. Everything public is fair game.
661 </p>
662 <form
663 method="get"
664 action="/search"
665 class="search-page-form"
666 role="search"
667 >
668 <input type="hidden" name="type" value={type} />
669 <div class="search-page-input-wrap">
670 <svg
671 class="search-page-input-icon"
672 viewBox="0 0 24 24"
673 fill="none"
674 xmlns="http://www.w3.org/2000/svg"
675 aria-hidden="true"
676 >
677 <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
678 <path d="M20 20L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
679 </svg>
680 <input
681 type="search"
682 name="q"
683 value={q}
684 placeholder="Search repos, users, issues, code…"
685 aria-label="Search Gluecron"
686 class="search-page-input"
687 autofocus
688 autocomplete="off"
689 />
690 <span class="search-page-kbd" aria-hidden="true">
691 Enter
692 </span>
693 </div>
694 </form>
695 </div>
696 </section>
3ef4c9dClaude697
283fbc2Claude698 <div
699 class="search-page-tabs"
700 role="tablist"
701 aria-label="Result categories"
702 >
703 {tabPill("repos", "Repositories")}
704 {tabPill("users", "Users")}
705 {tabPill("issues", "Issues")}
706 {tabPill("prs", "Pull requests")}
707 {tabPill("code", "Code")}
3ef4c9dClaude708 </div>
709
710 {!q && (
283fbc2Claude711 <div class="search-page-empty">
712 <svg
713 class="search-page-empty-art"
714 viewBox="0 0 96 96"
715 fill="none"
716 xmlns="http://www.w3.org/2000/svg"
717 aria-hidden="true"
718 >
719 <defs>
720 <linearGradient id="searchIdleG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
6fd5915Claude721 <stop stop-color="#5b6ee8" />
722 <stop offset="1" stop-color="#5f8fa0" />
283fbc2Claude723 </linearGradient>
724 </defs>
725 <circle cx="42" cy="42" r="22" stroke="url(#searchIdleG)" stroke-width="4" />
726 <path d="M58 58L78 78" stroke="url(#searchIdleG)" stroke-width="4" stroke-linecap="round" />
727 <circle cx="42" cy="42" r="8" stroke="url(#searchIdleG)" stroke-width="3" opacity="0.55" />
728 </svg>
729 <h2 class="search-page-empty-title">Search Gluecron</h2>
730 <p class="search-page-empty-sub">
731 Type to search across repos, users, issues, and pull requests.
732 Switch categories with the tabs above.
733 </p>
734 <div class="search-page-empty-tip">
735 <span class="search-page-empty-tip-row">
736 <strong>Tip:</strong> Press <code>/</code> from any page to focus
737 the global search.
738 </span>
c6018a5Claude739 <span class="search-page-empty-tip-row">
740 <strong>Semantic search:</strong> the global tab is keyword-only.
741 For embedding-backed code search, open a repo and click{" "}
742 <em>Semantic search</em> in the AI surfaces row, or hit{" "}
743 <code>/:owner/:repo/semantic-search?q=…</code> directly.
744 </span>
283fbc2Claude745 <span class="search-page-empty-tip-row">
746 Looking for something to browse?{" "}
747 <a href="/explore">Head to Explore</a>.
748 </span>
749 </div>
3ef4c9dClaude750 </div>
751 )}
752
753 {q && type === "repos" && (
754 repoHits.length === 0 ? (
283fbc2Claude755 noResultsFor("repositories")
3ef4c9dClaude756 ) : (
283fbc2Claude757 <div class="search-page-results">
3ef4c9dClaude758 {repoHits.map(({ repo, ownerName }) => (
283fbc2Claude759 <a
760 class="search-page-result"
761 href={`/${ownerName}/${repo.name}`}
762 aria-label={`${ownerName}/${repo.name}`}
763 >
764 <div class="search-page-result-avatar" aria-hidden="true">
765 {repoInitials(ownerName, repo.name)}
766 </div>
767 <div class="search-page-result-body">
768 <h3 class="search-page-result-title">
769 <span>
770 {ownerName}/{repo.name}
771 </span>
772 {repo.forkedFromId && (
773 <span class="search-page-state search-page-state-merged">
774 Fork
775 </span>
776 )}
777 </h3>
778 {repo.description && (
779 <p class="search-page-result-desc">{repo.description}</p>
780 )}
781 <div class="search-page-result-meta">
782 <span>{"★"} {repo.starCount}</span>
783 {" · "}
784 <span>{"⑂"} {repo.forkCount}</span>
785 {repo.pushedAt && (
786 <>
787 {" · "}
788 <span>
789 Updated{" "}
790 {new Date(repo.pushedAt).toLocaleDateString("en-US", {
791 month: "short",
792 day: "numeric",
793 year: "numeric",
794 })}
795 </span>
796 </>
797 )}
798 </div>
799 </div>
800 </a>
3ef4c9dClaude801 ))}
802 </div>
803 )
804 )}
805
806 {q && type === "users" && (
807 userHits.length === 0 ? (
283fbc2Claude808 noResultsFor("users")
3ef4c9dClaude809 ) : (
283fbc2Claude810 <div class="search-page-results">
3ef4c9dClaude811 {userHits.map((u) => (
283fbc2Claude812 <a
813 class="search-page-result"
814 href={`/${u.username}`}
815 aria-label={u.username}
816 >
817 <div
818 class="search-page-result-avatar is-user"
819 aria-hidden="true"
820 >
821 {userInitials(u)}
822 </div>
823 <div class="search-page-result-body">
824 <h3 class="search-page-result-title">
825 <span>{u.displayName || u.username}</span>
826 </h3>
827 <div class="search-page-result-meta">@{u.username}</div>
828 {u.bio && (
829 <p class="search-page-result-desc">{u.bio}</p>
830 )}
3ef4c9dClaude831 </div>
283fbc2Claude832 </a>
3ef4c9dClaude833 ))}
834 </div>
835 )
836 )}
837
838 {q && type === "issues" && (
839 issueHits.length === 0 ? (
283fbc2Claude840 noResultsFor("issues")
3ef4c9dClaude841 ) : (
283fbc2Claude842 <div class="search-page-results">
3ef4c9dClaude843 {issueHits.map((i) => (
283fbc2Claude844 <a
845 class="search-page-result"
846 href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}
847 aria-label={`Issue ${i.title}`}
848 >
849 <div class="search-page-result-avatar" aria-hidden="true">
850 {repoInitials(i.repoOwner, i.repoName)}
851 </div>
852 <div class="search-page-result-body">
853 <h3 class="search-page-result-title">
854 <span>{i.title}</span>
855 <span class={stateClass(i.state)}>{i.state}</span>
856 </h3>
857 <div class="search-page-result-meta">
858 <strong>
859 {i.repoOwner}/{i.repoName}
860 </strong>{" "}
861 · #{i.number}
3ef4c9dClaude862 </div>
863 </div>
283fbc2Claude864 </a>
3ef4c9dClaude865 ))}
866 </div>
867 )
868 )}
869
870 {q && type === "prs" && (
871 prHits.length === 0 ? (
283fbc2Claude872 noResultsFor("pull requests")
3ef4c9dClaude873 ) : (
283fbc2Claude874 <div class="search-page-results">
3ef4c9dClaude875 {prHits.map((pr) => (
283fbc2Claude876 <a
877 class="search-page-result"
878 href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}
879 aria-label={`Pull request ${pr.title}`}
880 >
881 <div class="search-page-result-avatar" aria-hidden="true">
882 {repoInitials(pr.repoOwner, pr.repoName)}
883 </div>
884 <div class="search-page-result-body">
885 <h3 class="search-page-result-title">
886 <span>{pr.title}</span>
887 <span class={stateClass(pr.state)}>{pr.state}</span>
888 </h3>
889 <div class="search-page-result-meta">
890 <strong>
891 {pr.repoOwner}/{pr.repoName}
892 </strong>{" "}
893 · #{pr.number}
3ef4c9dClaude894 </div>
895 </div>
283fbc2Claude896 </a>
3ef4c9dClaude897 ))}
898 </div>
899 )
900 )}
283fbc2Claude901
902 {q && type === "code" && (
903 <div class="search-page-empty">
904 <svg
905 class="search-page-empty-art"
906 viewBox="0 0 96 96"
907 fill="none"
908 xmlns="http://www.w3.org/2000/svg"
909 aria-hidden="true"
910 >
911 <defs>
912 <linearGradient id="searchCodeG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
6fd5915Claude913 <stop stop-color="#5b6ee8" />
914 <stop offset="1" stop-color="#5f8fa0" />
283fbc2Claude915 </linearGradient>
916 </defs>
917 <path d="M34 30L18 48L34 66" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
918 <path d="M62 30L78 48L62 66" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
919 <path d="M54 24L42 72" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" opacity="0.6" />
920 </svg>
921 <h2 class="search-page-empty-title">Code search is per-repo</h2>
922 <p class="search-page-empty-sub">
923 Cross-repo code search is on the roadmap. For now, open a repo and
924 use its search tab to find symbols and matching lines.
925 </p>
926 <div class="search-page-empty-cta">
927 <a href="/explore" class="btn btn-primary">
928 Browse repos
929 </a>
930 <a href={`/search?q=${encodeURIComponent(q)}&type=repos`} class="btn">
931 Search repos instead
932 </a>
933 </div>
934 </div>
935 )}
3ef4c9dClaude936 </Layout>
937 );
938});
939
940// Keyboard shortcuts help page — linked from Cmd+? in nav
941search.get("/shortcuts", async (c) => {
942 const user = c.get("user");
943 const unread = user ? await getUnreadCount(user.id) : 0;
5882af3Claude944 const shortcuts: Array<{ keys: string; desc: string; section?: string }> = [
945 { keys: "/", desc: "Focus global search", section: "Global" },
641aa42Claude946 { keys: "Cmd/Ctrl + K", desc: "Open command palette (merges repo-context commands on repo pages)" },
5882af3Claude947 { keys: "?", desc: "Show keyboard shortcuts" },
641aa42Claude948 { keys: "n", desc: "New repository (or wait for chord)" },
949 { keys: "n i", desc: "New issue in current repo (on repo pages)", section: "Repo chords" },
950 { keys: "n p", desc: "New pull request in current repo (on repo pages)" },
951 { keys: "g d", desc: "Go to dashboard", section: "Global" },
3ef4c9dClaude952 { keys: "g n", desc: "Go to notifications" },
953 { keys: "g e", desc: "Go to explore" },
5882af3Claude954 { keys: "g a", desc: "Go to AI ask" },
955 { keys: "j", desc: "Move selection down on list pages", section: "Lists" },
956 { keys: "k", desc: "Move selection up on list pages" },
957 { keys: "Enter", desc: "Open selected item" },
958 { keys: "x", desc: "Toggle select on focused item" },
641aa42Claude959 { keys: "c", desc: "Focus comment textarea", section: "Pull requests" },
960 { keys: "e", desc: "Edit PR title" },
961 { keys: "m", desc: "Focus merge button" },
962 { keys: "a", desc: "Approve (navigate to review page with approve action)" },
963 { keys: "r", desc: "Request changes (navigate to review page)" },
964 { keys: "Escape", desc: "Blur current focus" },
965 { keys: "c", desc: "Focus comment textarea", section: "Issues" },
966 { keys: "e", desc: "Scroll to issue title" },
967 { keys: "x", desc: "Close/reopen issue (with confirm)" },
3ef4c9dClaude968 ];
969
5882af3Claude970 const sections = [...new Set(shortcuts.map((s) => s.section ?? "Global"))];
971
3ef4c9dClaude972 return c.html(
973 <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}>
974 <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2>
5882af3Claude975 {sections.map((section) => (
976 <>
977 <h3 style="color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; margin: 20px 0 8px">{section}</h3>
978 <div class="panel">
979 {shortcuts.filter((s) => (s.section ?? "Global") === section).map((s) => (
980 <div class="panel-item" style="justify-content: space-between">
981 <span>{s.desc}</span>
982 <kbd
983 style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px"
984 >
985 {s.keys}
986 </kbd>
987 </div>
988 ))}
3ef4c9dClaude989 </div>
5882af3Claude990 </>
991 ))}
3ef4c9dClaude992 </Layout>
993 );
994});
995
996export default search;