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

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