Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

cross-repo-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.

cross-repo-search.tsxBlame1047 lines · 1 contributor
a2b3e99Claude1/**
2 * Cross-repo code search.
3 *
4 * GET /search/code?q=&repos=&page= — HTML page
5 * GET /api/search/code?q=&repos=&page= — JSON API
6 *
7 * Auth: softAuth — public repos are searchable by anonymous visitors;
8 * private repos only appear when the authenticated user owns or collaborates
9 * on them.
10 *
11 * Strategy (in priority order):
12 * 1. `code_chunks` table — populated by the per-repo semantic-index path.
13 * Uses ILIKE for keyword matching against the stored `content` column.
14 * 2. `git grep` fallback — when a repo has no indexed chunks we spawn a
15 * git grep subprocess against the bare repo on disk. Matches are
16 * capped at 5 lines per file and 20 files per repo so the fallback
17 * never bogs down a full page load.
18 *
19 * Scoped CSS: `.crs-*`
20 */
21
22import { Hono } from "hono";
23import { and, desc, eq, ilike, inArray, or, sql } from "drizzle-orm";
24import { join } from "path";
25import { db } from "../db";
26import { codeChunks, repositories, repoCollaborators, users } from "../db/schema";
27import { Layout } from "../views/layout";
28import { softAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import { getUnreadCount } from "../lib/unread";
31import { config } from "../lib/config";
32
33// ---------------------------------------------------------------------------
34// Router
35// ---------------------------------------------------------------------------
36
37const crossRepoSearch = new Hono<AuthEnv>();
38crossRepoSearch.use("/search/code*", softAuth);
39crossRepoSearch.use("/api/search/code*", softAuth);
40
41// ---------------------------------------------------------------------------
42// Types
43// ---------------------------------------------------------------------------
44
45interface CodeMatch {
46 repoId: string;
47 repoOwner: string;
48 repoName: string;
49 filePath: string;
50 startLine: number;
51 endLine: number;
52 /** Raw matched text (may span several lines). */
53 snippet: string;
54 /** Source of the match: "index" for code_chunks, "grep" for git-grep fallback. */
55 source: "index" | "grep";
56}
57
58interface PagedResult {
59 matches: CodeMatch[];
60 total: number;
61 page: number;
62 pageSize: number;
63 totalPages: number;
64}
65
66// ---------------------------------------------------------------------------
67// Helpers
68// ---------------------------------------------------------------------------
69
70const PAGE_SIZE = 20;
71
72/** Highlight all occurrences of `q` in `text` using HTML <mark> tags. */
73function highlight(text: string, q: string): string {
74 if (!q) return escHtml(text);
75 const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76 const re = new RegExp(`(${escaped})`, "gi");
77 return escHtml(text).replace(re, "<mark class=\"crs-mark\">$1</mark>");
78}
79
80function escHtml(s: string): string {
81 return s
82 .replace(/&/g, "&amp;")
83 .replace(/</g, "&lt;")
84 .replace(/>/g, "&gt;")
85 .replace(/"/g, "&quot;");
86}
87
88/**
89 * Run `git grep -n <pattern>` in a bare repo directory.
90 * Returns at most `maxFiles` files with up to `linesPerFile` matching line
91 * snippets each. Returns [] on any error.
92 */
93async function gitGrep(
94 diskPath: string,
95 pattern: string,
96 maxFiles = 20,
97 linesPerFile = 5
98): Promise<Array<{ path: string; line: number; text: string }>> {
99 try {
100 const reposRoot = config.gitReposPath;
101 const repoDir = join(reposRoot, diskPath);
102 const proc = Bun.spawn(
103 [
104 "git",
105 "--git-dir",
106 repoDir,
107 "grep",
108 "-n",
109 "-i",
110 "--max-count",
111 String(linesPerFile),
112 "-e",
113 pattern,
114 "HEAD",
115 "--",
116 ],
117 { stdout: "pipe", stderr: "pipe" }
118 );
119 const raw = await new Response(proc.stdout).text();
120 await proc.exited;
121
122 const results: Array<{ path: string; line: number; text: string }> = [];
123 const seen = new Set<string>();
124
125 for (const line of raw.split("\n")) {
126 if (!line.trim()) continue;
127 // Format: HEAD:path:lineno:content (git grep with --git-dir uses HEAD:)
128 const m = line.match(/^HEAD:(.+?):(\d+):(.*)$/);
129 if (!m) continue;
130 const [, filePath, lineStr, text] = m;
131 if (!seen.has(filePath)) {
132 if (seen.size >= maxFiles) break;
133 seen.add(filePath);
134 }
135 results.push({ path: filePath, line: Number(lineStr), text });
136 }
137 return results;
138 } catch {
139 return [];
140 }
141}
142
143/**
144 * Build a set of repository IDs the current user is allowed to see.
145 * For anon: only public repos owned by any user.
146 * For authed: also private repos they own OR are an accepted collaborator on.
147 */
148async function accessibleRepos(
149 userId: string | null,
150 repoFilter: string[]
151): Promise<
152 Array<{
153 id: string;
154 name: string;
155 ownerName: string;
156 diskPath: string;
157 isPrivate: boolean;
158 }>
159> {
160 // Build base query: always include public repos.
161 const conditions = [eq(repositories.isPrivate, false)];
162
163 if (userId) {
164 // Also include private repos the user owns.
165 conditions.push(eq(repositories.ownerId, userId));
166 }
167
168 let rows = await db
169 .select({
170 id: repositories.id,
171 name: repositories.name,
172 ownerName: users.username,
173 diskPath: repositories.diskPath,
174 isPrivate: repositories.isPrivate,
175 })
176 .from(repositories)
177 .innerJoin(users, eq(repositories.ownerId, users.id))
178 .where(or(...conditions))
179 .orderBy(desc(repositories.pushedAt))
180 .limit(500);
181
182 // If auth'd, also pull private repos where they are an accepted collaborator.
183 if (userId) {
184 const collabRepoIds = await db
185 .select({ repositoryId: repoCollaborators.repositoryId })
186 .from(repoCollaborators)
187 .where(
188 and(
189 eq(repoCollaborators.userId, userId),
190 sql`${repoCollaborators.acceptedAt} IS NOT NULL`
191 )
192 );
193 const collabIds = collabRepoIds.map((r) => r.repositoryId);
194 if (collabIds.length > 0) {
195 const privateCollabs = await db
196 .select({
197 id: repositories.id,
198 name: repositories.name,
199 ownerName: users.username,
200 diskPath: repositories.diskPath,
201 isPrivate: repositories.isPrivate,
202 })
203 .from(repositories)
204 .innerJoin(users, eq(repositories.ownerId, users.id))
205 .where(
206 and(eq(repositories.isPrivate, true), inArray(repositories.id, collabIds))
207 );
208 const existing = new Set(rows.map((r) => r.id));
209 for (const r of privateCollabs) {
210 if (!existing.has(r.id)) rows.push(r);
211 }
212 }
213 }
214
215 // Apply optional repo filter (owner/name slugs).
216 if (repoFilter.length > 0) {
217 rows = rows.filter((r) =>
218 repoFilter.includes(`${r.ownerName}/${r.name}`)
219 );
220 }
221
222 return rows;
223}
224
225/**
226 * Search `code_chunks` for repos that have an index, then fall back to
227 * `git grep` for repos without one.
228 */
229async function runSearch(
230 q: string,
231 repos: Array<{
232 id: string;
233 name: string;
234 ownerName: string;
235 diskPath: string;
236 isPrivate: boolean;
237 }>,
238 page: number
239): Promise<PagedResult> {
240 if (!q || repos.length === 0) {
241 return { matches: [], total: 0, page, pageSize: PAGE_SIZE, totalPages: 0 };
242 }
243
244 const pat = `%${q}%`;
245 const repoIds = repos.map((r) => r.id);
246
247 // 1. Find which repos have code_chunks.
248 let indexedRepoIds: string[] = [];
249 try {
250 const indexed = await db
251 .selectDistinct({ repositoryId: codeChunks.repositoryId })
252 .from(codeChunks)
253 .where(inArray(codeChunks.repositoryId, repoIds));
254 indexedRepoIds = indexed.map((r) => r.repositoryId);
255 } catch {
256 indexedRepoIds = [];
257 }
258
259 const repoById = new Map(repos.map((r) => [r.id, r]));
260
261 // 2. Keyword search on the code_chunks index.
262 const indexMatches: CodeMatch[] = [];
263 if (indexedRepoIds.length > 0) {
264 try {
265 const hits = await db
266 .select({
267 repositoryId: codeChunks.repositoryId,
268 path: codeChunks.path,
269 startLine: codeChunks.startLine,
270 endLine: codeChunks.endLine,
271 content: codeChunks.content,
272 })
273 .from(codeChunks)
274 .where(
275 and(
276 inArray(codeChunks.repositoryId, indexedRepoIds),
277 ilike(codeChunks.content, pat)
278 )
279 )
280 .orderBy(desc(codeChunks.createdAt))
281 .limit(200);
282
283 for (const h of hits) {
284 const repo = repoById.get(h.repositoryId);
285 if (!repo) continue;
286 indexMatches.push({
287 repoId: h.repositoryId,
288 repoOwner: repo.ownerName,
289 repoName: repo.name,
290 filePath: h.path,
291 startLine: h.startLine,
292 endLine: h.endLine,
293 snippet: h.content,
294 source: "index",
295 });
296 }
297 } catch {
298 // DB unavailable — skip index results.
299 }
300 }
301
302 // 3. git grep fallback for un-indexed repos.
303 const unindexedRepos = repos.filter((r) => !indexedRepoIds.includes(r.id));
304 const grepMatches: CodeMatch[] = [];
305
306 await Promise.all(
307 unindexedRepos.slice(0, 10).map(async (repo) => {
308 const lines = await gitGrep(repo.diskPath, q);
309 for (const { path, line, text } of lines) {
310 grepMatches.push({
311 repoId: repo.id,
312 repoOwner: repo.ownerName,
313 repoName: repo.name,
314 filePath: path,
315 startLine: line,
316 endLine: line,
317 snippet: text,
318 source: "grep",
319 });
320 }
321 })
322 );
323
324 const allMatches = [...indexMatches, ...grepMatches];
325 const total = allMatches.length;
326 const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
327 const safePage = Math.max(1, Math.min(page, totalPages));
328 const slice = allMatches.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
329
330 return { matches: slice, total, page: safePage, pageSize: PAGE_SIZE, totalPages };
331}
332
333// ---------------------------------------------------------------------------
334// JSON API GET /api/search/code
335// ---------------------------------------------------------------------------
336
337crossRepoSearch.get("/api/search/code", async (c) => {
338 const user = c.get("user");
339 const q = (c.req.query("q") || "").trim();
340 const reposParam = (c.req.query("repos") || "").trim();
341 const page = Math.max(1, Number(c.req.query("page") || "1"));
342 const repoFilter = reposParam ? reposParam.split(",").map((s) => s.trim()).filter(Boolean) : [];
343
344 if (!q) {
345 return c.json({ matches: [], total: 0, page: 1, pageSize: PAGE_SIZE, totalPages: 0 });
346 }
347
348 const repos = await accessibleRepos(user?.id ?? null, repoFilter);
349 const result = await runSearch(q, repos, page);
350 return c.json(result);
351});
352
353// ---------------------------------------------------------------------------
354// HTML page GET /search/code
355// ---------------------------------------------------------------------------
356
357crossRepoSearch.get("/search/code", async (c) => {
358 const user = c.get("user");
359 const q = (c.req.query("q") || "").trim();
360 const reposParam = (c.req.query("repos") || "").trim();
361 const page = Math.max(1, Number(c.req.query("page") || "1"));
362 const repoFilter = reposParam ? reposParam.split(",").map((s) => s.trim()).filter(Boolean) : [];
363 const unread = user ? await getUnreadCount(user.id) : 0;
364
365 // Fetch accessible repos for filter dropdown.
366 const accessibleRepoList = await accessibleRepos(user?.id ?? null, []);
367
368 // Only run query when there is a search term.
369 let result: PagedResult = {
370 matches: [],
371 total: 0,
372 page: 1,
373 pageSize: PAGE_SIZE,
374 totalPages: 0,
375 };
376 if (q) {
377 result = await runSearch(q, await accessibleRepos(user?.id ?? null, repoFilter), page);
378 }
379
380 const buildPageUrl = (p: number) => {
381 const ps = new URLSearchParams({ q, page: String(p) });
382 if (reposParam) ps.set("repos", reposParam);
383 return `/search/code?${ps.toString()}`;
384 };
385
386 // Group matches by repo for the UI.
387 const byRepo = new Map<
388 string,
389 { repoOwner: string; repoName: string; hits: CodeMatch[] }
390 >();
391 for (const m of result.matches) {
392 const key = `${m.repoOwner}/${m.repoName}`;
393 if (!byRepo.has(key)) {
394 byRepo.set(key, { repoOwner: m.repoOwner, repoName: m.repoName, hits: [] });
395 }
396 byRepo.get(key)!.hits.push(m);
397 }
398
399 const repoGroups = [...byRepo.values()];
400
401 return c.html(
402 <Layout
403 title={q ? `Code search — ${q}` : "Cross-repo code search"}
404 user={user}
405 notificationCount={unread}
406 >
407 <style
408 dangerouslySetInnerHTML={{
409 __html: `
410/* ─── Cross-repo search .crs-* ─── */
411.crs-hero {
412 position: relative;
413 margin: 4px 0 22px;
414 padding: 32px 32px 28px;
415 background: var(--bg-elevated);
416 border: 1px solid var(--border);
417 border-radius: 16px;
418 overflow: hidden;
419}
420.crs-hero::before {
421 content: '';
422 position: absolute;
423 top: 0; left: 0; right: 0;
424 height: 2px;
425 background: linear-gradient(90deg,transparent 0%,#8c6dff 30%,#36c5d6 70%,transparent 100%);
426 opacity: 0.7;
427 pointer-events: none;
428}
429.crs-hero-orb {
430 position: absolute;
431 inset: -30% -10% auto auto;
432 width: 340px; height: 340px;
433 background: radial-gradient(circle,rgba(140,109,255,0.18),rgba(54,197,214,0.09) 45%,transparent 70%);
434 filter: blur(80px);
435 opacity: 0.7;
436 pointer-events: none;
437 z-index: 0;
438 animation: crsOrb 14s ease-in-out infinite;
439}
440@keyframes crsOrb {
441 0%,100% { transform: scale(1) translate(0,0); opacity: 0.6; }
442 50% { transform: scale(1.1) translate(-12px,8px); opacity: 0.85; }
443}
444@media (prefers-reduced-motion: reduce) { .crs-hero-orb { animation: none; } }
445.crs-hero-inner {
446 position: relative;
447 z-index: 1;
448 display: flex;
449 flex-direction: column;
450 gap: 14px;
451}
452.crs-eyebrow {
453 font-size: 11.5px;
454 color: var(--text-muted);
455 letter-spacing: 0.08em;
456 text-transform: uppercase;
457 font-weight: 600;
458}
459.crs-title {
460 font-family: var(--font-display);
461 font-size: clamp(26px,4vw,38px);
462 font-weight: 800;
463 letter-spacing: -0.028em;
464 line-height: 1.05;
465 margin: 0;
466 color: var(--text-strong);
467}
468.crs-sub {
469 font-size: 14.5px;
470 color: var(--text-muted);
471 margin: 0;
472 line-height: 1.5;
473 max-width: 600px;
474}
475/* ─── Search bar ─── */
476.crs-form {
477 display: flex;
478 gap: 8px;
479 flex-wrap: wrap;
480 align-items: stretch;
481}
482.crs-input-wrap { position: relative; flex: 1; min-width: 220px; }
483.crs-icon {
484 position: absolute;
485 top: 50%; left: 14px;
486 transform: translateY(-50%);
487 color: var(--text-muted);
488 pointer-events: none;
489 width: 16px; height: 16px;
490}
491.crs-input {
492 width: 100%;
493 box-sizing: border-box;
494 padding: 12px 14px 12px 42px;
495 background: var(--bg);
496 border: 1px solid var(--border);
497 border-radius: 12px;
498 color: var(--text);
499 font: inherit;
500 font-size: 14.5px;
501 transition: border-color 120ms ease, box-shadow 120ms ease;
502}
503.crs-input::placeholder { color: var(--text-muted); }
504.crs-input:focus {
505 outline: none;
506 border-color: rgba(140,109,255,0.55);
507 box-shadow: 0 0 0 3px rgba(140,109,255,0.12);
508}
509.crs-select {
510 padding: 12px 14px;
511 background: var(--bg);
512 border: 1px solid var(--border);
513 border-radius: 12px;
514 color: var(--text);
515 font: inherit;
516 font-size: 13.5px;
517 cursor: pointer;
518 max-width: 220px;
519}
520.crs-select:focus {
521 outline: none;
522 border-color: rgba(140,109,255,0.55);
523}
524.crs-btn {
525 display: inline-flex;
526 align-items: center;
527 gap: 6px;
528 padding: 0 18px;
529 height: 44px;
530 border-radius: 12px;
531 font: inherit;
532 font-size: 14px;
533 font-weight: 600;
534 background: linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);
535 color: #fff;
536 border: none;
537 cursor: pointer;
538 transition: transform 120ms ease, box-shadow 120ms ease;
539 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.5);
540 white-space: nowrap;
541}
542.crs-btn:hover {
543 transform: translateY(-1px);
544 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.65);
545}
546/* ─── Tabs (back-link to global /search) ─── */
547.crs-tabs {
548 display: inline-flex;
549 background: var(--bg-elevated);
550 border: 1px solid var(--border);
551 border-radius: 9999px;
552 padding: 4px;
553 gap: 2px;
554 margin: 0 0 18px;
555}
556.crs-tab {
557 display: inline-flex;
558 align-items: center;
559 gap: 6px;
560 padding: 7px 16px;
561 border-radius: 9999px;
562 font-size: 13px;
563 font-weight: 500;
564 color: var(--text-muted);
565 text-decoration: none;
566 transition: color 120ms ease, background 120ms ease;
567}
568.crs-tab:hover { color: var(--text-strong); text-decoration: none; }
569.crs-tab.is-active {
570 background: rgba(140,109,255,0.14);
571 color: var(--text-strong);
572}
573/* ─── Stats bar ─── */
574.crs-stats {
575 font-size: 12.5px;
576 color: var(--text-muted);
577 margin: 0 0 16px;
578 display: flex;
579 align-items: center;
580 gap: 10px;
581 flex-wrap: wrap;
582}
583.crs-stats strong { color: var(--text); }
584.crs-badge {
585 display: inline-flex;
586 align-items: center;
587 gap: 4px;
588 padding: 2px 9px;
589 border-radius: 9999px;
590 font-size: 11px;
591 font-weight: 600;
592 background: rgba(140,109,255,0.12);
593 color: #c4b5fd;
594 border: 1px solid rgba(140,109,255,0.28);
595}
596.crs-badge-grep {
597 background: rgba(54,197,214,0.10);
598 color: #67e8f9;
599 border-color: rgba(54,197,214,0.28);
600}
601/* ─── Repo group ─── */
602.crs-groups { display: flex; flex-direction: column; gap: 16px; }
603.crs-group {
604 background: var(--bg-elevated);
605 border: 1px solid var(--border);
606 border-radius: 14px;
607 overflow: hidden;
608}
609.crs-group-head {
610 display: flex;
611 align-items: center;
612 gap: 10px;
613 padding: 12px 16px;
614 border-bottom: 1px solid var(--border);
615 background: rgba(255,255,255,0.02);
616}
617.crs-group-avatar {
618 width: 28px; height: 28px;
619 border-radius: 8px;
620 background: linear-gradient(135deg,#8c6dff,#36c5d6);
621 display: inline-flex;
622 align-items: center;
623 justify-content: center;
624 font-weight: 700;
625 font-size: 12px;
626 color: #fff;
627 flex-shrink: 0;
628}
629.crs-group-title {
630 font-family: var(--font-mono);
631 font-size: 13.5px;
632 font-weight: 600;
633 color: var(--text-strong);
634 text-decoration: none;
635}
636.crs-group-title:hover { color: var(--accent); text-decoration: none; }
637.crs-group-count {
638 margin-left: auto;
639 font-size: 11.5px;
640 color: var(--text-muted);
641 background: rgba(255,255,255,0.04);
642 border: 1px solid var(--border);
643 padding: 1px 8px;
644 border-radius: 9999px;
645}
646/* ─── File match ─── */
647.crs-file {
648 border-bottom: 1px solid var(--border);
649 padding: 10px 16px 12px;
650}
651.crs-file:last-child { border-bottom: none; }
652.crs-file-path {
653 font-family: var(--font-mono);
654 font-size: 12px;
655 color: var(--text);
656 text-decoration: none;
657 display: inline-flex;
658 align-items: center;
659 gap: 6px;
660 margin-bottom: 6px;
661}
662.crs-file-path:hover { color: var(--accent); text-decoration: none; }
663.crs-file-lines {
664 color: var(--text-muted);
665 font-weight: 400;
666}
667.crs-snippet {
668 margin: 0;
669 padding: 8px 12px;
670 background: var(--bg);
671 border: 1px solid var(--border);
672 border-radius: 9px;
673 font-family: var(--font-mono);
674 font-size: 12px;
675 line-height: 1.6;
676 overflow-x: auto;
677 white-space: pre-wrap;
678 word-break: break-word;
679 color: var(--text);
680}
681.crs-mark {
682 background: rgba(140,109,255,0.25);
683 color: var(--text-strong);
684 border-radius: 3px;
685 padding: 0 2px;
686}
687/* ─── Empty state ─── */
688.crs-empty {
689 margin: 0;
690 padding: 56px 32px;
691 background: var(--bg-elevated);
692 border: 1px solid var(--border);
693 border-radius: 16px;
694 text-align: center;
695 position: relative;
696 overflow: hidden;
697}
698.crs-empty::before {
699 content: '';
700 position: absolute;
701 top: 0; left: 0; right: 0;
702 height: 2px;
703 background: linear-gradient(90deg,transparent 0%,#8c6dff 30%,#36c5d6 70%,transparent 100%);
704 opacity: 0.55;
705 pointer-events: none;
706}
707.crs-empty-title {
708 font-family: var(--font-display);
709 font-size: 20px;
710 font-weight: 700;
711 color: var(--text-strong);
712 margin: 0 0 8px;
713}
714.crs-empty-sub {
715 font-size: 14px;
716 color: var(--text-muted);
717 line-height: 1.55;
718 margin: 0 auto 20px;
719 max-width: 460px;
720}
721/* ─── Pagination ─── */
722.crs-pagination {
723 display: flex;
724 align-items: center;
725 justify-content: center;
726 gap: 8px;
727 margin-top: 24px;
728 flex-wrap: wrap;
729}
730.crs-page-btn {
731 display: inline-flex;
732 align-items: center;
733 padding: 6px 14px;
734 border-radius: 9px;
735 font-size: 13px;
736 font-weight: 500;
737 color: var(--text);
738 background: var(--bg-elevated);
739 border: 1px solid var(--border);
740 text-decoration: none;
741 transition: border-color 120ms ease, background 120ms ease;
742}
743.crs-page-btn:hover { border-color: rgba(140,109,255,0.45); background: rgba(140,109,255,0.06); text-decoration: none; }
744.crs-page-btn.is-active {
745 background: rgba(140,109,255,0.14);
746 border-color: rgba(140,109,255,0.45);
747 color: var(--text-strong);
748}
749.crs-page-btn[aria-disabled="true"] {
750 opacity: 0.4;
751 pointer-events: none;
752}
753@media (max-width: 720px) {
754 .crs-hero { padding: 20px 16px; }
755 .crs-form { flex-direction: column; }
756 .crs-select { max-width: 100%; }
757 .crs-btn { width: 100%; justify-content: center; }
758}
759 `,
760 }}
761 />
762
763 {/* ── Hero ── */}
764 <section class="crs-hero">
765 <div class="crs-hero-orb" aria-hidden="true" />
766 <div class="crs-hero-inner">
767 <div class="crs-eyebrow">Cross-repo · Code search</div>
768 <h1 class="crs-title">
769 <span class="gradient-text">Search code</span> across every repo.
770 </h1>
771 <p class="crs-sub">
772 Keyword search inside file contents across all your accessible
773 repositories. Results use the semantic index when available, or fall
774 back to live{" "}
775 <code style="font-size:13px">git grep</code>.
776 </p>
777 <form method="get" action="/search/code" class="crs-form" role="search">
778 <div class="crs-input-wrap">
779 <svg class="crs-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
780 <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
781 <path d="M20 20L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
782 </svg>
783 <input
784 type="search"
785 name="q"
786 value={q}
787 placeholder="Function name, error message, pattern…"
788 aria-label="Search code"
789 class="crs-input"
790 autofocus
791 autocomplete="off"
792 />
793 </div>
794 <select name="repos" class="crs-select" aria-label="Filter by repository">
795 <option value="" selected={!reposParam}>
796 All repos
797 </option>
798 {accessibleRepoList.map((r) => {
799 const slug = `${r.ownerName}/${r.name}`;
800 return (
801 <option value={slug} selected={reposParam === slug}>
802 {slug}
803 </option>
804 );
805 })}
806 </select>
807 <button type="submit" class="crs-btn">
808 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
809 <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
810 <path d="M20 20L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
811 </svg>
812 Search
813 </button>
814 </form>
815 </div>
816 </section>
817
818 {/* ── Nav tabs ── */}
819 <div class="crs-tabs" role="tablist" aria-label="Search categories">
820 <a
821 href={`/search?q=${encodeURIComponent(q)}&type=repos`}
822 class="crs-tab"
823 role="tab"
824 aria-selected="false"
825 >
826 Global search
827 </a>
828 <a
829 href={`/search/code?q=${encodeURIComponent(q)}`}
830 class="crs-tab is-active"
831 role="tab"
832 aria-selected="true"
833 >
834 Code
835 </a>
836 </div>
837
838 {/* ── No query yet ── */}
839 {!q && (
840 <div class="crs-empty">
841 <svg
842 style="width:72px;height:72px;margin:0 auto 16px;display:block;opacity:0.8"
843 viewBox="0 0 96 96"
844 fill="none"
845 aria-hidden="true"
846 >
847 <defs>
848 <linearGradient id="crsIdleG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
849 <stop stop-color="#8c6dff" />
850 <stop offset="1" stop-color="#36c5d6" />
851 </linearGradient>
852 </defs>
853 <path d="M30 28L14 48L30 68" stroke="url(#crsIdleG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
854 <path d="M66 28L82 48L66 68" stroke="url(#crsIdleG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
855 <path d="M54 22L42 74" stroke="url(#crsIdleG)" stroke-width="4" stroke-linecap="round" opacity="0.55" />
856 </svg>
857 <h2 class="crs-empty-title">Search code across repos</h2>
858 <p class="crs-empty-sub">
859 Type a keyword above to search file contents across all
860 {user ? " your" : " public"} repositories. Use the repo dropdown to
861 narrow to a single repo.
862 </p>
863 {!user && (
864 <p style="font-size:13px;color:var(--text-muted);margin:0">
865 <a href="/login">Sign in</a> to also search your private
866 repositories.
867 </p>
868 )}
869 </div>
870 )}
871
872 {/* ── Results ── */}
873 {q && result.total === 0 && (
874 <div class="crs-empty">
875 <h2 class="crs-empty-title">No code matches for "{q}"</h2>
876 <p class="crs-empty-sub">
877 Try a different keyword, broaden the repo filter, or check that your
878 repositories have been indexed for semantic search.
879 </p>
880 <div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap">
881 <a href="/explore" class="btn btn-primary">
882 Browse repos
883 </a>
884 <a
885 href={`/search?q=${encodeURIComponent(q)}&type=repos`}
886 class="btn"
887 >
888 Global search
889 </a>
890 </div>
891 </div>
892 )}
893
894 {q && result.total > 0 && (
895 <>
896 {/* Stats */}
897 <div class="crs-stats">
898 <strong>{result.total}</strong>{" "}
899 {result.total === 1 ? "match" : "matches"} across{" "}
900 <strong>{repoGroups.length}</strong>{" "}
901 {repoGroups.length === 1 ? "repo" : "repos"}
902 {reposParam && (
903 <>
904 {" "}
905 in{" "}
906 <strong>
907 <code style="font-size:12px">{reposParam}</code>
908 </strong>
909 </>
910 )}
911 {" "}·{" "}
912 page {result.page} of {result.totalPages}
913 </div>
914
915 {/* Grouped results */}
916 <div class="crs-groups">
917 {repoGroups.map(({ repoOwner, repoName, hits }) => {
918 const initials =
919 repoName.slice(0, 2).toUpperCase() || "??";
920 return (
921 <div class="crs-group">
922 <div class="crs-group-head">
923 <span class="crs-group-avatar" aria-hidden="true">
924 {initials}
925 </span>
926 <a
927 href={`/${repoOwner}/${repoName}`}
928 class="crs-group-title"
929 >
930 {repoOwner}/{repoName}
931 </a>
932 <span class="crs-group-count">
933 {hits.length}{" "}
934 {hits.length === 1 ? "match" : "matches"}
935 </span>
936 </div>
937 {hits.map((hit) => {
938 const blobHref = `/${repoOwner}/${repoName}/blob/HEAD/${hit.filePath}#L${hit.startLine}`;
939 const preview =
940 hit.snippet.length > 500
941 ? hit.snippet.slice(0, 500) + "\n…"
942 : hit.snippet;
943 const lineLabel =
944 hit.startLine === hit.endLine
945 ? `L${hit.startLine}`
946 : `L${hit.startLine}–${hit.endLine}`;
947 return (
948 <div class="crs-file">
949 <a href={blobHref} class="crs-file-path">
950 <svg
951 width="13"
952 height="13"
953 viewBox="0 0 24 24"
954 fill="none"
955 stroke="currentColor"
956 stroke-width="2"
957 stroke-linecap="round"
958 stroke-linejoin="round"
959 aria-hidden="true"
960 >
961 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
962 <polyline points="14 2 14 8 20 8" />
963 </svg>
964 {hit.filePath}
965 <span class="crs-file-lines">
966 :{lineLabel}
967 </span>
968 </a>
969 <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
970 <span
971 class={
972 hit.source === "index"
973 ? "crs-badge"
974 : "crs-badge crs-badge-grep"
975 }
976 >
977 {hit.source === "index" ? "indexed" : "live grep"}
978 </span>
979 </div>
980 <pre
981 class="crs-snippet"
982 dangerouslySetInnerHTML={{
983 __html: highlight(preview, q),
984 }}
985 />
986 </div>
987 );
988 })}
989 </div>
990 );
991 })}
992 </div>
993
994 {/* Pagination */}
995 {result.totalPages > 1 && (
996 <nav class="crs-pagination" aria-label="Result pages">
997 <a
998 href={buildPageUrl(result.page - 1)}
999 class="crs-page-btn"
1000 aria-disabled={result.page <= 1 ? "true" : "false"}
1001 aria-label="Previous page"
1002 >
1003 ← Prev
1004 </a>
1005 {Array.from({ length: result.totalPages }, (_, i) => i + 1)
1006 .filter(
1007 (p) =>
1008 p === 1 ||
1009 p === result.totalPages ||
1010 Math.abs(p - result.page) <= 2
1011 )
1012 .reduce<Array<number | "…">>((acc, p, idx, arr) => {
1013 if (idx > 0 && p - (arr[idx - 1] as number) > 1)
1014 acc.push("…");
1015 acc.push(p);
1016 return acc;
1017 }, [])
1018 .map((p) =>
1019 p === "…" ? (
1020 <span style="color:var(--text-muted);padding:0 4px">…</span>
1021 ) : (
1022 <a
1023 href={buildPageUrl(p as number)}
1024 class={`crs-page-btn${p === result.page ? " is-active" : ""}`}
1025 aria-current={p === result.page ? "page" : undefined}
1026 >
1027 {p}
1028 </a>
1029 )
1030 )}
1031 <a
1032 href={buildPageUrl(result.page + 1)}
1033 class="crs-page-btn"
1034 aria-disabled={result.page >= result.totalPages ? "true" : "false"}
1035 aria-label="Next page"
1036 >
1037 Next →
1038 </a>
1039 </nav>
1040 )}
1041 </>
1042 )}
1043 </Layout>
1044 );
1045});
1046
1047export default crossRepoSearch;