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

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

nl-search.tsxBlame662 lines · 1 contributor
77cf834Claude1/**
2 * Natural Language Code Search UI
3 *
4 * GET /:owner/:repo/search/nl?q=... — search form + server-rendered results
5 *
6 * Uses Claude as the reasoner over actual file content (not embeddings).
7 * Complements the embedding-based semantic search at /search/semantic.
8 *
9 * - softAuth: public repos searchable without login
10 * - Server-side rendering: accept the wait (no client-side streaming needed)
11 * - Results link to /:owner/:repo/blob/HEAD/<filePath>#L<lineStart>
12 */
13
14import { Hono } from "hono";
15import { eq, and } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { IssueNav } from "./issues";
21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { nlSearch } from "../lib/nl-search";
24import { isAiAvailable } from "../lib/ai-client";
25
26const nlSearchRoutes = new Hono<AuthEnv>();
27nlSearchRoutes.use("*", softAuth);
28
29// ─── Scoped CSS (.nl-*) ──────────────────────────────────────────────────────
30const nlStyles = `
31 .nl-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
32
33 .nl-head {
34 margin-bottom: var(--space-5);
35 display: flex;
36 align-items: flex-end;
37 justify-content: space-between;
38 gap: var(--space-4);
39 flex-wrap: wrap;
40 }
41 .nl-head-text { flex: 1; min-width: 280px; }
42 .nl-eyebrow {
43 display: inline-flex;
44 align-items: center;
45 gap: 8px;
46 text-transform: uppercase;
47 font-family: var(--font-mono);
48 font-size: 11px;
49 letter-spacing: 0.16em;
50 color: var(--text-muted);
51 font-weight: 600;
52 margin-bottom: 10px;
53 }
54 .nl-eyebrow-dot {
55 width: 8px; height: 8px;
56 border-radius: 9999px;
57 background: linear-gradient(135deg, #f59e0b, #ef4444);
58 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
59 }
60 .nl-title {
61 font-family: var(--font-display);
62 font-size: clamp(24px, 3.4vw, 36px);
63 font-weight: 800;
64 letter-spacing: -0.028em;
65 line-height: 1.1;
66 margin: 0 0 6px;
67 color: var(--text-strong);
68 }
69 .nl-title-grad {
70 background-image: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #ef4444 100%);
71 -webkit-background-clip: text;
72 background-clip: text;
73 -webkit-text-fill-color: transparent;
74 color: transparent;
75 }
76 .nl-sub {
77 margin: 0;
78 font-size: 14px;
79 color: var(--text-muted);
80 line-height: 1.5;
81 max-width: 720px;
82 }
83
84 .nl-provider {
85 display: inline-flex;
86 align-items: center;
87 gap: 6px;
88 padding: 5px 10px;
89 border-radius: 9999px;
90 font-family: var(--font-mono);
91 font-size: 11px;
92 color: var(--text-muted);
93 background: rgba(255,255,255,0.03);
94 border: 1px solid var(--border);
95 }
96 .nl-provider .dot {
97 width: 6px; height: 6px;
98 border-radius: 9999px;
99 background: linear-gradient(135deg, #f59e0b, #ef4444);
100 }
101
102 /* ─── Search bar ─── */
103 .nl-search {
104 display: flex;
105 gap: 10px;
106 align-items: stretch;
107 margin-bottom: var(--space-4);
108 }
109 .nl-search-input-wrap { position: relative; flex: 1; }
110 .nl-search-icon {
111 position: absolute;
112 top: 50%;
113 left: 14px;
114 transform: translateY(-50%);
115 color: var(--text-muted);
116 pointer-events: none;
117 }
118 .nl-search-input {
119 width: 100%;
120 box-sizing: border-box;
121 padding: 12px 14px 12px 40px;
122 font: inherit;
123 font-size: 14.5px;
124 color: var(--text);
125 background: rgba(255,255,255,0.03);
126 border: 1px solid var(--border-strong);
127 border-radius: 12px;
128 outline: none;
129 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
130 }
131 .nl-search-input:focus {
132 border-color: rgba(245,158,11,0.55);
133 background: rgba(255,255,255,0.05);
134 box-shadow: 0 0 0 3px rgba(245,158,11,0.20);
135 }
136 .nl-btn {
137 display: inline-flex;
138 align-items: center;
139 justify-content: center;
140 gap: 6px;
141 padding: 0 18px;
142 border-radius: 12px;
143 font-size: 14px;
144 font-weight: 600;
145 text-decoration: none;
146 border: 1px solid transparent;
147 cursor: pointer;
148 font: inherit;
149 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease;
150 line-height: 1;
151 white-space: nowrap;
152 }
153 .nl-btn-primary {
154 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
155 color: #ffffff;
156 box-shadow: 0 6px 18px -6px rgba(245,158,11,0.55), inset 0 1px 0 rgba(255,255,255,0.16);
157 }
158 .nl-btn-primary:hover {
159 transform: translateY(-1px);
160 box-shadow: 0 10px 24px -8px rgba(245,158,11,0.65), inset 0 1px 0 rgba(255,255,255,0.20);
161 text-decoration: none;
162 color: #ffffff;
163 }
164
165 /* ─── Examples ─── */
166 .nl-examples {
167 margin-bottom: var(--space-3);
168 display: flex;
169 flex-wrap: wrap;
170 gap: 8px;
171 align-items: center;
172 }
173 .nl-examples-label {
174 font-size: 12px;
175 color: var(--text-muted);
176 font-family: var(--font-mono);
177 }
178 .nl-example-chip {
179 display: inline-flex;
180 align-items: center;
181 padding: 4px 10px;
182 border-radius: 9999px;
183 font-size: 12px;
184 color: var(--text-muted);
185 background: rgba(255,255,255,0.03);
186 border: 1px solid var(--border);
187 cursor: pointer;
188 text-decoration: none;
189 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
190 }
191 .nl-example-chip:hover {
192 border-color: rgba(245,158,11,0.45);
193 color: var(--text-strong);
194 background: rgba(245,158,11,0.06);
195 text-decoration: none;
196 }
197
198 /* ─── Status bar ─── */
199 .nl-status {
200 margin-bottom: var(--space-3);
201 padding: 9px 14px;
202 border-radius: 10px;
203 background: rgba(255,255,255,0.025);
204 border: 1px solid var(--border);
205 font-size: 12px;
206 color: var(--text-muted);
207 display: flex;
208 align-items: center;
209 justify-content: space-between;
210 gap: var(--space-3);
211 flex-wrap: wrap;
212 font-variant-numeric: tabular-nums;
213 }
214 .nl-status .num { color: var(--text-strong); font-weight: 600; }
215
216 /* ─── Result cards ─── */
217 .nl-results { display: flex; flex-direction: column; gap: 10px; }
218 .nl-result {
219 padding: 14px;
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 transition: border-color 120ms ease, background 120ms ease;
224 }
225 .nl-result:hover {
226 border-color: var(--border-strong);
227 background: rgba(255,255,255,0.025);
228 }
229 .nl-result-head {
230 display: flex;
231 align-items: center;
232 justify-content: space-between;
233 gap: 10px;
234 flex-wrap: wrap;
235 margin-bottom: 8px;
236 }
237 .nl-result-path {
238 font-family: var(--font-mono);
239 font-size: 13px;
240 font-weight: 600;
241 color: var(--text-strong);
242 text-decoration: none;
243 word-break: break-all;
244 letter-spacing: -0.005em;
245 }
246 .nl-result-path .lines { color: var(--text-muted); font-weight: 500; }
247 .nl-result-path:hover { color: #fcd34d; text-decoration: none; }
248 .nl-confidence {
249 display: inline-flex;
250 align-items: center;
251 gap: 5px;
252 padding: 2px 9px;
253 border-radius: 9999px;
254 font-family: var(--font-mono);
255 font-size: 11px;
256 font-weight: 600;
257 flex-shrink: 0;
258 }
259 .nl-confidence-high {
260 background: rgba(52,211,153,0.12);
261 color: #6ee7b7;
262 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
263 }
264 .nl-confidence-medium {
265 background: rgba(251,191,36,0.12);
266 color: #fcd34d;
267 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
268 }
269 .nl-confidence-low {
270 background: rgba(156,163,175,0.10);
271 color: var(--text-muted);
272 box-shadow: inset 0 0 0 1px rgba(156,163,175,0.25);
273 }
274 .nl-explanation {
275 font-size: 13px;
276 color: var(--text-muted);
277 margin: 0 0 8px;
278 line-height: 1.5;
279 }
280 .nl-snippet {
281 margin: 0;
282 padding: 10px 12px;
283 background: rgba(0,0,0,0.25);
284 border: 1px solid var(--border);
285 border-radius: 8px;
286 font-family: var(--font-mono);
287 font-size: 12px;
288 line-height: 1.55;
289 color: var(--text);
290 overflow-x: auto;
291 white-space: pre-wrap;
292 word-break: break-word;
293 }
294
295 /* ─── Banner (no AI key) ─── */
296 .nl-banner {
297 margin-bottom: var(--space-4);
298 padding: 12px 16px;
299 border-radius: 10px;
300 font-size: 13.5px;
301 border: 1px solid rgba(245,158,11,0.35);
302 background: rgba(245,158,11,0.08);
303 color: #fcd34d;
304 display: flex;
305 align-items: center;
306 gap: 10px;
307 }
308 .nl-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
309
310 /* ─── Empty state ─── */
311 .nl-empty {
312 position: relative;
313 overflow: hidden;
314 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
315 text-align: center;
316 background: var(--bg-elevated);
317 border: 1px dashed var(--border-strong);
318 border-radius: 16px;
319 }
320 .nl-empty-orb {
321 position: absolute;
322 inset: -40% 25% auto 25%;
323 height: 300px;
324 background: radial-gradient(circle, rgba(245,158,11,0.15), rgba(239,68,68,0.08) 45%, transparent 70%);
325 filter: blur(72px);
326 opacity: 0.7;
327 pointer-events: none;
328 z-index: 0;
329 }
330 .nl-empty-inner { position: relative; z-index: 1; }
331 .nl-empty-icon {
332 width: 56px; height: 56px;
333 margin: 0 auto 14px;
334 border-radius: 9999px;
335 background: linear-gradient(135deg, rgba(245,158,11,0.25), rgba(239,68,68,0.20));
336 box-shadow: inset 0 0 0 1px rgba(245,158,11,0.40);
337 display: inline-flex;
338 align-items: center;
339 justify-content: center;
340 color: #fcd34d;
341 }
342 .nl-empty-title {
343 font-family: var(--font-display);
344 font-size: 18px;
345 font-weight: 700;
346 margin: 0 0 6px;
347 color: var(--text-strong);
348 }
349 .nl-empty-sub {
350 margin: 0 auto;
351 font-size: 13.5px;
352 color: var(--text-muted);
353 max-width: 480px;
354 line-height: 1.5;
355 }
356
357 /* ─── Footer ─── */
358 .nl-footer {
359 margin-top: var(--space-5);
360 font-size: 12px;
361 color: var(--text-muted);
362 text-align: center;
363 font-family: var(--font-mono);
364 }
365 .nl-footer a { color: inherit; text-decoration: underline; }
366 .nl-footer a:hover { color: var(--text); }
367`;
368
369// ─── Icons ────────────────────────────────────────────────────────────────────
370
371function IconSearch() {
372 return (
373 <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">
374 <circle cx="11" cy="11" r="7" />
375 <line x1="21" y1="21" x2="16.65" y2="16.65" />
376 </svg>
377 );
378}
379
380function IconIntent() {
381 return (
382 <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">
383 <path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
384 </svg>
385 );
386}
387
388// ─── Example queries ──────────────────────────────────────────────────────────
389
390const EXAMPLE_QUERIES = [
391 "find all places that write to the DB without error handling",
392 "where do we check authentication?",
393 "where do we validate user input?",
394 "find all async functions that don't await their result",
395 "where do we catch errors but ignore them?",
396];
397
398// ─── Resolve repo helper ──────────────────────────────────────────────────────
399
400async function resolveRepo(ownerName: string, repoName: string) {
401 try {
402 const [owner] = await db
403 .select()
404 .from(users)
405 .where(eq(users.username, ownerName))
406 .limit(1);
407 if (!owner) return null;
408 const [repo] = await db
409 .select()
410 .from(repositories)
411 .where(
412 and(
413 eq(repositories.ownerId, owner.id),
414 eq(repositories.name, repoName)
415 )
416 )
417 .limit(1);
418 if (!repo) return null;
419 return { owner, repo };
420 } catch {
421 return null;
422 }
423}
424
425function NotFound({ user }: { user: typeof users.$inferSelect | null | undefined }) {
426 return (
427 <Layout title="Not Found" user={user}>
428 <div class="empty-state">
429 <h2>Repository not found</h2>
430 <p>No such repository, or you don't have access.</p>
431 </div>
432 </Layout>
433 );
434}
435
436// ─── Confidence pill ─────────────────────────────────────────────────────────
437
438function ConfidencePill({ level }: { level: "high" | "medium" | "low" }) {
439 const cls =
440 level === "high"
441 ? "nl-confidence nl-confidence-high"
442 : level === "medium"
443 ? "nl-confidence nl-confidence-medium"
444 : "nl-confidence nl-confidence-low";
445 return <span class={cls}>{level}</span>;
446}
447
448// ─── GET /:owner/:repo/search/nl ─────────────────────────────────────────────
449
450nlSearchRoutes.get("/:owner/:repo/search/nl", async (c) => {
451 const { owner: ownerName, repo: repoName } = c.req.param();
452 const user = c.get("user");
453 const q = (c.req.query("q") || "").trim();
454
455 const resolved = await resolveRepo(ownerName, repoName);
456 if (!resolved) {
457 return c.html(<NotFound user={user} />, 404);
458 }
459 const { repo } = resolved;
460
461 const aiAvailable = isAiAvailable();
462
463 // Run NL search if a query was provided and AI is available
464 let searchResult: Awaited<ReturnType<typeof nlSearch>> | null = null;
465 if (q && aiAvailable) {
466 searchResult = await nlSearch(ownerName, repoName, repo.id, q);
467 }
468
469 const results = searchResult?.results ?? [];
470 const totalFilesScanned = searchResult?.totalFilesScanned ?? 0;
471 const cached = searchResult?.cached ?? false;
472
473 return c.html(
474 <Layout title={`NL Search — ${ownerName}/${repoName}`} user={user}>
475 <RepoHeader owner={ownerName} repo={repoName} />
476 <IssueNav owner={ownerName} repo={repoName} active="code" />
477
478 <div class="nl-wrap">
479 {/* ─── Header ─── */}
480 <header class="nl-head">
481 <div class="nl-head-text">
482 <div class="nl-eyebrow">
483 <span class="nl-eyebrow-dot" aria-hidden="true" />
484 Repository · Natural Language Search
485 </div>
486 <h1 class="nl-title">
487 <span class="nl-title-grad">Search by intent, not keywords.</span>
488 </h1>
489 <p class="nl-sub">
490 Describe what you're looking for in plain English — Claude reads
491 the actual code and finds the matching places.
492 </p>
493 </div>
494 <div class="nl-provider" title="Powered by Claude Sonnet">
495 <span class="dot" aria-hidden="true" />
496 Claude Sonnet
497 </div>
498 </header>
499
500 {/* ─── No AI key warning ─── */}
501 {!aiAvailable && (
502 <div class="nl-banner" role="alert">
503 <span class="nl-banner-dot" aria-hidden="true" />
504 Natural language search requires an Anthropic API key (
505 <code>ANTHROPIC_API_KEY</code>). Set the key and restart the server
506 to enable this feature.
507 </div>
508 )}
509
510 {/* ─── Search form ─── */}
511 <form
512 method="get"
513 action={`/${ownerName}/${repoName}/search/nl`}
514 class="nl-search"
515 >
516 <div class="nl-search-input-wrap">
517 <span class="nl-search-icon" aria-hidden="true">
518 <IconSearch />
519 </span>
520 <input
521 type="search"
522 name="q"
523 value={q}
524 placeholder='e.g. "find all places that write to the DB without error handling"'
525 aria-label="Natural language search query"
526 class="nl-search-input"
527 autofocus
528 disabled={!aiAvailable}
529 />
530 </div>
531 <button type="submit" class="nl-btn nl-btn-primary" disabled={!aiAvailable}>
532 <IconIntent />
533 Search
534 </button>
535 </form>
536
537 {/* ─── Example chips ─── */}
538 {!q && aiAvailable && (
539 <div class="nl-examples">
540 <span class="nl-examples-label">Try:</span>
541 {EXAMPLE_QUERIES.map((ex) => (
542 <a
543 href={`/${ownerName}/${repoName}/search/nl?q=${encodeURIComponent(ex)}`}
544 class="nl-example-chip"
545 >
546 {ex}
547 </a>
548 ))}
549 </div>
550 )}
551
552 {/* ─── Results area ─── */}
553 {!q ? (
554 /* Empty prompt state */
555 <div class="nl-empty">
556 <div class="nl-empty-orb" aria-hidden="true" />
557 <div class="nl-empty-inner">
558 <div class="nl-empty-icon" aria-hidden="true">
559 <IconIntent />
560 </div>
561 <h3 class="nl-empty-title">Ask anything about the codebase</h3>
562 <p class="nl-empty-sub">
563 Type a natural language question above — Claude will scan the
564 repository files and return the matching code locations with
565 explanations.
566 </p>
567 </div>
568 </div>
569 ) : !aiAvailable ? (
570 /* AI unavailable */
571 <div class="nl-empty">
572 <div class="nl-empty-orb" aria-hidden="true" />
573 <div class="nl-empty-inner">
574 <div class="nl-empty-icon" aria-hidden="true">
575 <IconIntent />
576 </div>
577 <h3 class="nl-empty-title">AI not configured</h3>
578 <p class="nl-empty-sub">
579 Set <code>ANTHROPIC_API_KEY</code> to enable Claude-powered
580 natural language search.
581 </p>
582 </div>
583 </div>
584 ) : results.length === 0 ? (
585 /* No results */
586 <div class="nl-empty">
587 <div class="nl-empty-orb" aria-hidden="true" />
588 <div class="nl-empty-inner">
589 <div class="nl-empty-icon" aria-hidden="true">
590 <IconSearch />
591 </div>
592 <h3 class="nl-empty-title">No results for "{q}"</h3>
593 <p class="nl-empty-sub">
594 Claude scanned {totalFilesScanned} file
595 {totalFilesScanned === 1 ? "" : "s"} and found no matches.
596 Try rephrasing your query or using different terminology.
597 </p>
598 </div>
599 </div>
600 ) : (
601 /* Results */
602 <>
603 <div class="nl-status">
604 <span>
605 <span class="num">{results.length}</span> result
606 {results.length === 1 ? "" : "s"}
607 {" · scanned "}
608 <span class="num">{totalFilesScanned}</span> file
609 {totalFilesScanned === 1 ? "" : "s"}
610 {cached && " · cached"}
611 </span>
612 <span>powered by Claude</span>
613 </div>
614 <div class="nl-results">
615 {results.map((r) => {
616 const href = `/${ownerName}/${repoName}/blob/HEAD/${r.filePath}#L${r.lineStart}`;
617 const snippetPreview =
618 r.snippet.length > 800
619 ? r.snippet.slice(0, 800) + "\n…"
620 : r.snippet;
621 return (
622 <div class="nl-result">
623 <div class="nl-result-head">
624 <a href={href} class="nl-result-path">
625 {r.filePath}
626 <span class="lines">
627 :{r.lineStart}–{r.lineEnd}
628 </span>
629 </a>
630 <ConfidencePill level={r.confidence} />
631 </div>
632 {r.explanation && (
633 <p class="nl-explanation">{r.explanation}</p>
634 )}
635 <pre class="nl-snippet">{snippetPreview}</pre>
636 </div>
637 );
638 })}
639 </div>
640 </>
641 )}
642
643 {/* ─── Footer ─── */}
644 <div class="nl-footer">
645 Natural language search powered by{" "}
646 <a
647 href={`/${ownerName}/${repoName}/search/semantic`}
648 title="Switch to embedding-based semantic search"
649 >
650 semantic search
651 </a>{" "}
652 also available · Gluecron
653 </div>
654 </div>
655
656 <style dangerouslySetInnerHTML={{ __html: nlStyles }} />
657 </Layout>
658 );
659});
660
661export { nlSearchRoutes };
662export default nlSearchRoutes;