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

semantic-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.

semantic-search.tsxBlame692 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D1 — Semantic code search UI + reindex trigger.
3 *
4 * GET /:owner/:repo/search/semantic?q=... — results page (ILIKE parity)
5 * POST /:owner/:repo/search/semantic/reindex — owner-only, fire-and-forget
6 *
7 * Intentionally tolerates a missing DB / missing repo / missing index so the
8 * page is always navigable. When there's no index yet, the page shows a
9 * "Build index" CTA pointing at the reindex endpoint.
e3eb5ffClaude10 *
11 * 2026 polish:
12 * - Scoped `.ss-*` CSS — sits below RepoHeader + IssueNav.
13 * - Eyebrow + display headline + 1-line subtitle.
14 * - Prominent search input w/ focus ring + gradient submit button.
15 * - Result cards show file:line in mono, snippet, and match score chip.
16 * - Dashed empty state w/ orb + Build/Reindex CTA when no index exists.
17 *
18 * All query strings + POST handlers preserved verbatim.
3cbe3d6Claude19 */
20
21import { Hono } from "hono";
22import { eq, and, desc } from "drizzle-orm";
23import { db } from "../db";
24import { repositories, users, codeChunks } from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader } from "../views/components";
27import { IssueNav } from "./issues";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 getDefaultBranch,
32 resolveRef,
33 repoExists,
34} from "../git/repository";
35import {
36 indexRepository,
37 searchRepository,
38 isEmbeddingsProviderAvailable,
39} from "../lib/semantic-search";
40
41const semanticSearch = new Hono<AuthEnv>();
42semanticSearch.use("*", softAuth);
43
e3eb5ffClaude44// ─── Scoped CSS (.ss-*) ──────────────────────────────────────────────────
45const ssStyles = `
46 .ss-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
47
48 .ss-head {
49 margin-bottom: var(--space-5);
50 display: flex;
51 align-items: flex-end;
52 justify-content: space-between;
53 gap: var(--space-4);
54 flex-wrap: wrap;
55 }
56 .ss-head-text { flex: 1; min-width: 280px; }
57 .ss-eyebrow {
58 display: inline-flex;
59 align-items: center;
60 gap: 8px;
61 text-transform: uppercase;
62 font-family: var(--font-mono);
63 font-size: 11px;
64 letter-spacing: 0.16em;
65 color: var(--text-muted);
66 font-weight: 600;
67 margin-bottom: 10px;
68 }
69 .ss-eyebrow-dot {
70 width: 8px; height: 8px;
71 border-radius: 9999px;
72 background: linear-gradient(135deg, #8c6dff, #36c5d6);
73 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
74 }
75 .ss-title {
76 font-family: var(--font-display);
77 font-size: clamp(24px, 3.4vw, 36px);
78 font-weight: 800;
79 letter-spacing: -0.028em;
80 line-height: 1.1;
81 margin: 0 0 6px;
82 color: var(--text-strong);
83 }
84 .ss-title-grad {
85 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
86 -webkit-background-clip: text;
87 background-clip: text;
88 -webkit-text-fill-color: transparent;
89 color: transparent;
90 }
91 .ss-sub {
92 margin: 0;
93 font-size: 14px;
94 color: var(--text-muted);
95 line-height: 1.5;
96 max-width: 720px;
97 }
98
99 .ss-provider {
100 display: inline-flex;
101 align-items: center;
102 gap: 6px;
103 padding: 5px 10px;
104 border-radius: 9999px;
105 font-family: var(--font-mono);
106 font-size: 11px;
107 color: var(--text-muted);
108 background: rgba(255,255,255,0.03);
109 border: 1px solid var(--border);
110 }
111 .ss-provider .dot {
112 width: 6px; height: 6px;
113 border-radius: 9999px;
114 background: linear-gradient(135deg, #8c6dff, #36c5d6);
115 }
116
117 .ss-banner {
118 margin-bottom: var(--space-4);
119 padding: 10px 14px;
120 border-radius: 10px;
121 font-size: 13.5px;
122 border: 1px solid var(--border);
123 background: rgba(255,255,255,0.025);
124 color: var(--text);
125 display: flex;
126 align-items: center;
127 gap: 10px;
128 }
129 .ss-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
130 .ss-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
131
132 /* ─── Search bar ─── */
133 .ss-search {
134 display: flex;
135 gap: 10px;
136 align-items: stretch;
137 margin-bottom: var(--space-4);
138 }
139 .ss-search-input-wrap { position: relative; flex: 1; }
140 .ss-search-icon {
141 position: absolute;
142 top: 50%;
143 left: 14px;
144 transform: translateY(-50%);
145 color: var(--text-muted);
146 pointer-events: none;
147 }
148 .ss-search-input {
149 width: 100%;
150 box-sizing: border-box;
151 padding: 12px 14px 12px 40px;
152 font: inherit;
153 font-size: 14.5px;
154 color: var(--text);
155 background: rgba(255,255,255,0.03);
156 border: 1px solid var(--border-strong);
157 border-radius: 12px;
158 outline: none;
159 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
160 }
161 .ss-search-input:focus {
162 border-color: rgba(140,109,255,0.55);
163 background: rgba(255,255,255,0.05);
164 box-shadow: 0 0 0 3px rgba(140,109,255,0.20);
165 }
166 .ss-btn {
167 display: inline-flex;
168 align-items: center;
169 justify-content: center;
170 gap: 6px;
171 padding: 0 18px;
172 border-radius: 12px;
173 font-size: 14px;
174 font-weight: 600;
175 text-decoration: none;
176 border: 1px solid transparent;
177 cursor: pointer;
178 font: inherit;
179 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
180 line-height: 1;
181 white-space: nowrap;
182 }
183 .ss-btn-primary {
184 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
185 color: #ffffff;
186 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
187 }
188 .ss-btn-primary:hover {
189 transform: translateY(-1px);
190 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
191 text-decoration: none;
192 color: #ffffff;
193 }
194 .ss-btn-ghost {
195 background: transparent;
196 color: var(--text);
197 border-color: var(--border-strong);
198 padding: 7px 12px;
199 font-size: 12.5px;
200 border-radius: 9px;
201 }
202 .ss-btn-ghost:hover {
203 background: rgba(140,109,255,0.06);
204 border-color: rgba(140,109,255,0.45);
205 color: var(--text-strong);
206 text-decoration: none;
207 }
208
209 /* ─── Index status bar ─── */
210 .ss-status {
211 display: flex;
212 align-items: center;
213 justify-content: space-between;
214 gap: var(--space-3);
215 flex-wrap: wrap;
216 margin-bottom: var(--space-3);
217 padding: 9px 14px;
218 border-radius: 10px;
219 background: rgba(255,255,255,0.025);
220 border: 1px solid var(--border);
221 font-size: 12px;
222 color: var(--text-muted);
223 font-variant-numeric: tabular-nums;
224 }
225 .ss-status .num { color: var(--text-strong); font-weight: 600; }
226
227 /* ─── Result cards ─── */
228 .ss-results { display: flex; flex-direction: column; gap: 10px; }
229 .ss-result {
230 padding: 14px;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 12px;
234 transition: border-color 120ms ease, background 120ms ease;
235 }
236 .ss-result:hover {
237 border-color: var(--border-strong);
238 background: rgba(255,255,255,0.025);
239 }
240 .ss-result-head {
241 display: flex;
242 align-items: center;
243 justify-content: space-between;
244 gap: 10px;
245 flex-wrap: wrap;
246 margin-bottom: 10px;
247 }
248 .ss-result-path {
249 font-family: var(--font-mono);
250 font-size: 13px;
251 font-weight: 600;
252 color: var(--text-strong);
253 text-decoration: none;
254 word-break: break-all;
255 letter-spacing: -0.005em;
256 }
257 .ss-result-path .lines { color: var(--text-muted); font-weight: 500; }
258 .ss-result-path:hover { color: #c4b5fd; text-decoration: none; }
259 .ss-score {
260 display: inline-flex;
261 align-items: center;
262 gap: 5px;
263 padding: 2px 9px;
264 border-radius: 9999px;
265 background: rgba(54,197,214,0.12);
266 color: #67e8f9;
267 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
268 font-family: var(--font-mono);
269 font-size: 11px;
270 font-weight: 600;
271 font-variant-numeric: tabular-nums;
272 }
273 .ss-snippet {
274 margin: 0;
275 padding: 10px 12px;
276 background: rgba(0,0,0,0.25);
277 border: 1px solid var(--border);
278 border-radius: 8px;
279 font-family: var(--font-mono);
280 font-size: 12px;
281 line-height: 1.55;
282 color: var(--text);
283 overflow-x: auto;
284 white-space: pre-wrap;
285 word-break: break-word;
286 }
287
288 /* ─── Empty state ─── */
289 .ss-empty {
290 position: relative;
291 overflow: hidden;
292 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
293 text-align: center;
294 background: var(--bg-elevated);
295 border: 1px dashed var(--border-strong);
296 border-radius: 16px;
297 }
298 .ss-empty-orb {
299 position: absolute;
300 inset: -40% 25% auto 25%;
301 height: 300px;
302 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
303 filter: blur(72px);
304 opacity: 0.7;
305 pointer-events: none;
306 z-index: 0;
307 }
308 .ss-empty-inner { position: relative; z-index: 1; }
309 .ss-empty-icon {
310 width: 56px; height: 56px;
311 margin: 0 auto 14px;
312 border-radius: 9999px;
313 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
314 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
315 display: inline-flex;
316 align-items: center;
317 justify-content: center;
318 color: #c4b5fd;
319 }
320 .ss-empty-title {
321 font-family: var(--font-display);
322 font-size: 18px;
323 font-weight: 700;
324 margin: 0 0 6px;
325 color: var(--text-strong);
326 }
327 .ss-empty-sub {
328 margin: 0 auto 16px;
329 font-size: 13.5px;
330 color: var(--text-muted);
331 max-width: 480px;
332 line-height: 1.5;
333 }
334`;
335
336function IconSearch() {
337 return (
338 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
339 <circle cx="11" cy="11" r="7" />
340 <line x1="21" y1="21" x2="16.65" y2="16.65" />
341 </svg>
342 );
343}
344function IconSparkles() {
345 return (
346 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
347 <path d="M12 3l1.7 4.6L18 9.3l-4.3 1.7L12 15l-1.7-4-4.3-1.7L10 7.6 12 3z" />
348 <path d="M19 13l.9 2.4L22 16l-2.1.6L19 19l-.9-2.4L16 16l2.1-.6L19 13z" />
349 </svg>
350 );
351}
352
3cbe3d6Claude353async function resolveRepo(ownerName: string, repoName: string) {
354 try {
355 const [owner] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, ownerName))
359 .limit(1);
360 if (!owner) return null;
361 const [repo] = await db
362 .select()
363 .from(repositories)
364 .where(
365 and(
366 eq(repositories.ownerId, owner.id),
367 eq(repositories.name, repoName)
368 )
369 )
370 .limit(1);
371 if (!repo) return null;
372 return { owner, repo };
373 } catch {
374 return null;
375 }
376}
377
378function NotFound({ user }: { user: any }) {
379 return (
380 <Layout title="Not Found" user={user}>
381 <div class="empty-state">
382 <h2>Repository not found</h2>
383 <p>No such repository, or you don't have access.</p>
384 </div>
385 </Layout>
386 );
387}
388
389semanticSearch.get("/:owner/:repo/search/semantic", async (c) => {
390 const { owner: ownerName, repo: repoName } = c.req.param();
391 const user = c.get("user");
392 const q = (c.req.query("q") || "").trim();
393 const flash = c.req.query("flash");
394
395 const resolved = await resolveRepo(ownerName, repoName);
396 if (!resolved) {
397 return c.html(<NotFound user={user} />, 404);
398 }
399 const { repo } = resolved;
400
401 // Figure out last-indexed state (chunk count + most recent createdAt).
402 let indexedCount = 0;
403 let lastIndexedAt: Date | null = null;
404 let indexedCommitSha: string | null = null;
405 try {
406 const [newest] = await db
407 .select({
408 createdAt: codeChunks.createdAt,
409 commitSha: codeChunks.commitSha,
410 })
411 .from(codeChunks)
412 .where(eq(codeChunks.repositoryId, repo.id))
413 .orderBy(desc(codeChunks.createdAt))
414 .limit(1);
415 if (newest) {
416 lastIndexedAt = newest.createdAt as unknown as Date;
417 indexedCommitSha = newest.commitSha || null;
418 const rows = await db
419 .select({ id: codeChunks.id })
420 .from(codeChunks)
421 .where(eq(codeChunks.repositoryId, repo.id))
422 .limit(5000);
423 indexedCount = rows.length;
424 }
425 } catch {
e3eb5ffClaude426 // DB unavailable — show the page anyway.
3cbe3d6Claude427 }
428
429 let hits: Awaited<ReturnType<typeof searchRepository>> = [];
430 if (q && indexedCount > 0) {
431 try {
432 hits = await searchRepository({
433 repositoryId: repo.id,
434 query: q,
435 limit: 20,
436 });
437 } catch {
438 hits = [];
439 }
440 }
441
442 const providers = isEmbeddingsProviderAvailable();
443 const providerLabel = providers.voyage
444 ? "Voyage voyage-code-3"
445 : "lexical fallback (512-dim)";
446
447 const isOwner = !!user && user.id === repo.ownerId;
448 const refForLinks = indexedCommitSha || repo.defaultBranch || "HEAD";
449
450 return c.html(
451 <Layout title={`Semantic search — ${ownerName}/${repoName}`} user={user}>
452 <RepoHeader owner={ownerName} repo={repoName} />
453 <IssueNav owner={ownerName} repo={repoName} active="code" />
454
e3eb5ffClaude455 <div class="ss-wrap">
456 <header class="ss-head">
457 <div class="ss-head-text">
458 <div class="ss-eyebrow">
459 <span class="ss-eyebrow-dot" aria-hidden="true" />
460 Repository · Semantic search
461 </div>
462 <h1 class="ss-title">
463 <span class="ss-title-grad">Ask the codebase anything.</span>
464 </h1>
465 <p class="ss-sub">
466 Embeddings-powered code search across every indexed chunk —
467 describe what you're looking for in natural language.
468 </p>
469 </div>
470 <div class="ss-provider" title="Active embeddings provider">
471 <span class="dot" aria-hidden="true" />
472 {providerLabel}
473 </div>
474 </header>
3cbe3d6Claude475
e3eb5ffClaude476 {flash && (
477 <div class="ss-banner is-ok" role="status">
478 <span class="ss-banner-dot" aria-hidden="true" />
479 {decodeURIComponent(flash)}
480 </div>
481 )}
3cbe3d6Claude482
e3eb5ffClaude483 <form
484 method="get"
485 action={`/${ownerName}/${repoName}/search/semantic`}
486 class="ss-search"
487 >
488 <div class="ss-search-input-wrap">
489 <span class="ss-search-icon" aria-hidden="true">
490 <IconSearch />
3cbe3d6Claude491 </span>
e3eb5ffClaude492 <input
493 type="search"
494 name="q"
495 value={q}
496 placeholder="Ask a question or describe what you're looking for…"
497 aria-label="Search"
498 class="ss-search-input"
499 autofocus
500 />
3cbe3d6Claude501 </div>
e3eb5ffClaude502 <button type="submit" class="ss-btn ss-btn-primary">
503 <IconSparkles />
504 Search
505 </button>
506 </form>
3cbe3d6Claude507
e3eb5ffClaude508 {indexedCount === 0 ? (
509 <div class="ss-empty">
510 <div class="ss-empty-orb" aria-hidden="true" />
511 <div class="ss-empty-inner">
512 <div class="ss-empty-icon" aria-hidden="true">
513 <IconSparkles />
514 </div>
515 <h3 class="ss-empty-title">No index yet</h3>
516 <p class="ss-empty-sub">
517 This repository hasn't been indexed for semantic search. Build
518 the index to enable AI-powered code lookup.
519 </p>
520 {isOwner ? (
521 <form
522 method="post"
523 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
524 >
525 <button type="submit" class="ss-btn ss-btn-primary">
526 <IconSparkles />
527 Build index
528 </button>
529 </form>
530 ) : (
531 <p
532 style="margin:0;color:var(--text-muted);font-size:13px"
533 >
534 Only the repository owner can trigger indexing.
535 </p>
536 )}
3cbe3d6Claude537 </div>
e3eb5ffClaude538 </div>
539 ) : (
540 <>
541 <div class="ss-status">
542 <span>
543 <span class="num">{indexedCount}</span> chunk
544 {indexedCount === 1 ? "" : "s"} indexed
545 {lastIndexedAt && (
546 <>
547 {" · last indexed "}
548 <span class="num">
549 {new Date(lastIndexedAt).toLocaleString()}
550 </span>
551 </>
552 )}
553 </span>
554 {isOwner && (
555 <form
556 method="post"
557 action={`/${ownerName}/${repoName}/search/semantic/reindex`}
558 style="display:inline"
559 >
560 <button type="submit" class="ss-btn ss-btn-ghost">
561 Reindex
562 </button>
563 </form>
564 )}
3cbe3d6Claude565 </div>
e3eb5ffClaude566
567 {!q ? (
568 <div class="ss-empty">
569 <div class="ss-empty-orb" aria-hidden="true" />
570 <div class="ss-empty-inner">
571 <div class="ss-empty-icon" aria-hidden="true">
572 <IconSearch />
3cbe3d6Claude573 </div>
e3eb5ffClaude574 <h3 class="ss-empty-title">Type a query to begin</h3>
575 <p class="ss-empty-sub">
576 Search across this repo's code — try a function name, a
577 file path, or a plain-English description of what you're
578 looking for.
579 </p>
580 </div>
581 </div>
582 ) : hits.length === 0 ? (
583 <div class="ss-empty">
584 <div class="ss-empty-orb" aria-hidden="true" />
585 <div class="ss-empty-inner">
586 <div class="ss-empty-icon" aria-hidden="true">
587 <IconSearch />
588 </div>
589 <h3 class="ss-empty-title">No results for "{q}"</h3>
590 <p class="ss-empty-sub">
591 Try a different phrasing or a related symbol name.
592 </p>
593 </div>
594 </div>
595 ) : (
596 <div class="ss-results">
597 {hits.map((h) => {
598 const href = `/${ownerName}/${repoName}/blob/${refForLinks}/${h.path}#L${h.startLine}`;
599 const preview =
600 h.content.length > 600
601 ? h.content.slice(0, 600) + "\n…"
602 : h.content;
603 return (
604 <div class="ss-result">
605 <div class="ss-result-head">
606 <a href={href} class="ss-result-path">
607 {h.path}
608 <span class="lines">
609 :{h.startLine}–{h.endLine}
610 </span>
611 </a>
612 <span class="ss-score" title="Cosine similarity score">
613 score {h.score.toFixed(3)}
614 </span>
615 </div>
616 <pre class="ss-snippet">{preview}</pre>
617 </div>
618 );
619 })}
620 </div>
621 )}
622 </>
623 )}
624 </div>
625 <style dangerouslySetInnerHTML={{ __html: ssStyles }} />
3cbe3d6Claude626 </Layout>
627 );
628});
629
630// Owner-only: rebuild the index. Fire-and-forget so the UI doesn't block on
631// potentially multi-minute work. Always redirects.
632semanticSearch.post(
633 "/:owner/:repo/search/semantic/reindex",
634 requireAuth,
635 async (c) => {
636 const { owner: ownerName, repo: repoName } = c.req.param();
637 const user = c.get("user")!;
638
639 const resolved = await resolveRepo(ownerName, repoName);
640 if (!resolved) {
641 return c.redirect(`/${ownerName}/${repoName}`);
642 }
643 const { repo } = resolved;
644
645 if (repo.ownerId !== user.id) {
646 return c.redirect(
647 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
648 "Only the repository owner can trigger indexing."
649 )}`
650 );
651 }
652
653 // Resolve the commit sha at the default branch. If the repo has no
654 // commits yet we bail with a friendly flash.
655 let sha: string | null = null;
656 try {
657 if (await repoExists(ownerName, repoName)) {
658 const branch =
659 (await getDefaultBranch(ownerName, repoName)) ||
660 repo.defaultBranch ||
661 "main";
662 sha = await resolveRef(ownerName, repoName, branch);
663 }
664 } catch {
665 sha = null;
666 }
667
668 if (!sha) {
669 return c.redirect(
670 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
671 "Repository has no commits yet — nothing to index."
672 )}`
673 );
674 }
675
676 // Fire-and-forget. Errors are swallowed inside indexRepository.
677 void indexRepository({
678 owner: ownerName,
679 repo: repoName,
680 repositoryId: repo.id,
681 commitSha: sha,
682 });
683
684 return c.redirect(
685 `/${ownerName}/${repoName}/search/semantic?flash=${encodeURIComponent(
686 "Indexing started — results will appear shortly."
687 )}`
688 );
689 }
690);
691
692export default semanticSearch;