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

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

symbols.tsxBlame911 lines · 1 contributor
4c8f666Claude1/**
2 * Block I8 — Symbol / xref navigation.
3 *
4 * GET /:owner/:repo/symbols — overview + "Reindex" button
5 * GET /:owner/:repo/symbols/search — search by name (prefix match)
6 * GET /:owner/:repo/symbols/:name — definitions list
7 * POST /:owner/:repo/symbols/reindex — owner-only, runs indexer
0a50474Claude8 *
9 * 2026 polish:
10 * - Scoped `.sym-*` CSS — no bleed into RepoHeader/RepoNav above.
11 * - Eyebrow + display headline + 1-line subtitle.
12 * - Each symbol is a card with a kind-specific icon (fn / class / var /
13 * interface / type / const), mono name pill, file:line, signature.
14 * - Search input gets a focus ring + gradient submit.
15 * - Dashed empty-state with orb + CTA when nothing's indexed.
16 *
17 * All query strings + POST handlers preserved verbatim.
4c8f666Claude18 */
19
20import { Hono } from "hono";
21import { and, asc, eq, ilike, sql } from "drizzle-orm";
22import { db } from "../db";
23import { codeSymbols, repositories, users } from "../db/schema";
24import { Layout } from "../views/layout";
25import { RepoHeader, RepoNav } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { indexRepositorySymbols, findDefinitions } from "../lib/symbols";
29
30const symbols = new Hono<AuthEnv>();
31symbols.use("*", softAuth);
32
0a50474Claude33// ─── Scoped CSS (.sym-*) ─────────────────────────────────────────────────
34const symStyles = `
eed4684Claude35 .sym-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
0a50474Claude36
37 .sym-head {
38 margin-bottom: var(--space-5);
39 display: flex;
40 align-items: flex-end;
41 justify-content: space-between;
42 gap: var(--space-4);
43 flex-wrap: wrap;
44 }
45 .sym-head-text { flex: 1; min-width: 280px; }
46 .sym-eyebrow {
47 display: inline-flex;
48 align-items: center;
49 gap: 8px;
50 text-transform: uppercase;
51 font-family: var(--font-mono);
52 font-size: 11px;
53 letter-spacing: 0.16em;
54 color: var(--text-muted);
55 font-weight: 600;
56 margin-bottom: 10px;
57 }
58 .sym-eyebrow-dot {
59 width: 8px; height: 8px;
60 border-radius: 9999px;
6fd5915Claude61 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
62 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
0a50474Claude63 }
64 .sym-title {
65 font-family: var(--font-display);
66 font-size: clamp(24px, 3.4vw, 36px);
67 font-weight: 800;
68 letter-spacing: -0.028em;
69 line-height: 1.1;
70 margin: 0 0 6px;
71 color: var(--text-strong);
72 }
73 .sym-title-grad {
6fd5915Claude74 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
0a50474Claude75 -webkit-background-clip: text;
76 background-clip: text;
77 -webkit-text-fill-color: transparent;
78 color: transparent;
79 }
80 .sym-title-mono {
81 font-family: var(--font-mono);
82 letter-spacing: -0.018em;
83 }
84 .sym-sub {
85 margin: 0;
86 font-size: 14px;
87 color: var(--text-muted);
88 line-height: 1.5;
89 max-width: 720px;
90 }
91
92 .sym-btn {
93 display: inline-flex;
94 align-items: center;
95 justify-content: center;
96 gap: 6px;
97 padding: 9px 16px;
98 border-radius: 10px;
99 font-size: 13px;
100 font-weight: 600;
101 text-decoration: none;
102 border: 1px solid transparent;
103 cursor: pointer;
104 font: inherit;
105 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
106 line-height: 1;
107 white-space: nowrap;
108 }
109 .sym-btn-primary {
6fd5915Claude110 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
0a50474Claude111 color: #ffffff;
6fd5915Claude112 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
0a50474Claude113 }
114 .sym-btn-primary:hover {
115 transform: translateY(-1px);
6fd5915Claude116 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
0a50474Claude117 text-decoration: none;
118 color: #ffffff;
119 }
120 .sym-btn-ghost {
121 background: transparent;
122 color: var(--text);
123 border-color: var(--border-strong);
124 }
125 .sym-btn-ghost:hover {
6fd5915Claude126 background: rgba(91,110,232,0.06);
127 border-color: rgba(91,110,232,0.45);
0a50474Claude128 color: var(--text-strong);
129 text-decoration: none;
130 }
131
132 /* ─── Banners ─── */
133 .sym-banner {
134 margin-bottom: var(--space-4);
135 padding: 10px 14px;
136 border-radius: 10px;
137 font-size: 13.5px;
138 border: 1px solid var(--border);
139 background: rgba(255,255,255,0.025);
140 color: var(--text);
141 display: flex;
142 align-items: center;
143 gap: 10px;
144 }
145 .sym-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
146 .sym-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
147
148 /* ─── Search bar ─── */
149 .sym-search {
150 display: flex;
151 gap: 10px;
152 align-items: stretch;
153 margin-bottom: var(--space-4);
154 }
155 .sym-search-input-wrap { position: relative; flex: 1; }
156 .sym-search-icon {
157 position: absolute;
158 top: 50%;
159 left: 14px;
160 transform: translateY(-50%);
161 color: var(--text-muted);
162 pointer-events: none;
163 }
164 .sym-search-input {
165 width: 100%;
166 box-sizing: border-box;
167 padding: 11px 14px 11px 40px;
168 font: inherit;
169 font-size: 14px;
170 color: var(--text);
171 background: rgba(255,255,255,0.03);
172 border: 1px solid var(--border-strong);
173 border-radius: 12px;
174 outline: none;
175 font-family: var(--font-mono);
176 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
177 }
178 .sym-search-input:focus {
6fd5915Claude179 border-color: rgba(91,110,232,0.55);
0a50474Claude180 background: rgba(255,255,255,0.05);
6fd5915Claude181 box-shadow: 0 0 0 3px rgba(91,110,232,0.20);
0a50474Claude182 }
183
184 /* ─── Summary tiles ─── */
185 .sym-stats {
186 display: grid;
187 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
188 gap: 10px;
189 margin-bottom: var(--space-5);
190 }
191 .sym-stat {
192 position: relative;
193 padding: 14px 16px;
194 background: var(--bg-elevated);
195 border: 1px solid var(--border);
196 border-radius: 12px;
197 overflow: hidden;
198 }
199 .sym-stat::before {
200 content: '';
201 position: absolute;
202 top: 0; left: 0; right: 0;
203 height: 1.5px;
6fd5915Claude204 background: linear-gradient(90deg, transparent 0%, #5b6ee8 50%, #5f8fa0 100%);
0a50474Claude205 opacity: 0.40;
206 pointer-events: none;
207 }
208 .sym-stat-num {
209 font-family: var(--font-display);
210 font-size: 22px;
211 font-weight: 800;
212 letter-spacing: -0.02em;
213 color: var(--text-strong);
214 font-variant-numeric: tabular-nums;
215 }
216 .sym-stat-label {
217 font-size: 11px;
218 color: var(--text-muted);
219 text-transform: uppercase;
220 letter-spacing: 0.06em;
221 margin-top: 4px;
222 font-weight: 600;
223 }
224
225 /* ─── Section header ─── */
226 .sym-section-title {
227 margin: 0 0 var(--space-3);
228 font-family: var(--font-display);
229 font-size: 17px;
230 font-weight: 700;
231 letter-spacing: -0.018em;
232 color: var(--text-strong);
233 }
234
235 /* ─── Symbol cards ─── */
236 .sym-list { display: flex; flex-direction: column; gap: 8px; }
237 .sym-card {
238 display: flex;
239 align-items: flex-start;
240 gap: 12px;
241 padding: 12px 14px;
242 background: var(--bg-elevated);
243 border: 1px solid var(--border);
244 border-radius: 12px;
245 transition: border-color 120ms ease, background 120ms ease;
246 }
247 .sym-card:hover {
248 border-color: var(--border-strong);
249 background: rgba(255,255,255,0.025);
250 }
251 .sym-kind {
252 display: inline-flex;
253 align-items: center;
254 justify-content: center;
255 width: 30px; height: 30px;
256 border-radius: 8px;
257 flex-shrink: 0;
258 font-family: var(--font-mono);
259 font-size: 11px;
260 font-weight: 700;
261 }
262 .sym-kind.is-function {
6fd5915Claude263 background: rgba(91,110,232,0.14);
0a50474Claude264 color: #c4b5fd;
6fd5915Claude265 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
0a50474Claude266 }
267 .sym-kind.is-class {
6fd5915Claude268 background: rgba(95,143,160,0.14);
0a50474Claude269 color: #67e8f9;
6fd5915Claude270 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32);
0a50474Claude271 }
272 .sym-kind.is-interface {
273 background: rgba(34,211,238,0.10);
274 color: #a5f3fc;
275 box-shadow: inset 0 0 0 1px rgba(34,211,238,0.32);
276 }
277 .sym-kind.is-type {
278 background: rgba(251,191,36,0.12);
279 color: #fde68a;
280 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
281 }
282 .sym-kind.is-const,
283 .sym-kind.is-var {
284 background: rgba(52,211,153,0.12);
285 color: #6ee7b7;
286 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
287 }
288 .sym-kind.is-other {
289 background: rgba(148,163,184,0.14);
290 color: #cbd5e1;
291 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
292 }
293
294 .sym-card-body { flex: 1; min-width: 0; }
295 .sym-card-row {
296 display: flex;
297 align-items: center;
298 justify-content: space-between;
299 gap: 10px;
300 flex-wrap: wrap;
301 margin-bottom: 4px;
302 }
303 .sym-card-name {
304 display: inline-flex;
305 align-items: baseline;
306 gap: 8px;
307 flex-wrap: wrap;
308 }
309 .sym-name {
310 font-family: var(--font-mono);
311 font-size: 14px;
312 font-weight: 700;
313 color: var(--text-strong);
314 text-decoration: none;
315 letter-spacing: -0.005em;
316 word-break: break-all;
317 }
318 .sym-name:hover { color: #c4b5fd; text-decoration: none; }
319 .sym-kind-label {
320 font-size: 10.5px;
321 text-transform: uppercase;
322 letter-spacing: 0.06em;
323 color: var(--text-muted);
324 font-weight: 600;
325 }
326 .sym-card-loc {
327 font-family: var(--font-mono);
328 font-size: 11.5px;
329 color: var(--text-muted);
330 text-decoration: none;
331 word-break: break-all;
332 font-variant-numeric: tabular-nums;
333 }
334 .sym-card-loc:hover { color: var(--text-strong); text-decoration: underline; }
335 .sym-sig {
336 margin: 6px 0 0;
337 padding: 8px 10px;
338 background: rgba(0,0,0,0.25);
339 border: 1px solid var(--border);
340 border-radius: 8px;
341 font-family: var(--font-mono);
342 font-size: 11.5px;
343 line-height: 1.5;
344 color: var(--text);
345 overflow-x: auto;
346 white-space: pre;
347 }
348
349 /* ─── Empty state ─── */
350 .sym-empty {
351 position: relative;
352 overflow: hidden;
353 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
354 text-align: center;
355 background: var(--bg-elevated);
356 border: 1px dashed var(--border-strong);
357 border-radius: 16px;
358 }
359 .sym-empty-orb {
360 position: absolute;
361 inset: -40% 25% auto 25%;
362 height: 300px;
6fd5915Claude363 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
0a50474Claude364 filter: blur(72px);
365 opacity: 0.7;
366 pointer-events: none;
367 z-index: 0;
368 }
369 .sym-empty-inner { position: relative; z-index: 1; }
370 .sym-empty-icon {
371 width: 56px; height: 56px;
372 margin: 0 auto 14px;
373 border-radius: 9999px;
6fd5915Claude374 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
375 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
0a50474Claude376 display: inline-flex;
377 align-items: center;
378 justify-content: center;
379 color: #c4b5fd;
380 }
381 .sym-empty-title {
382 font-family: var(--font-display);
383 font-size: 18px;
384 font-weight: 700;
385 margin: 0 0 6px;
386 color: var(--text-strong);
387 }
388 .sym-empty-sub {
389 margin: 0 auto 16px;
390 font-size: 13.5px;
391 color: var(--text-muted);
392 max-width: 460px;
393 line-height: 1.5;
394 }
395
396 .sym-crumbs {
397 margin-bottom: var(--space-4);
398 font-size: 12.5px;
399 }
400 .sym-crumbs a {
401 display: inline-flex;
402 align-items: center;
403 gap: 5px;
404 padding: 6px 11px;
405 background: rgba(255,255,255,0.025);
406 border: 1px solid var(--border);
407 border-radius: 8px;
408 color: var(--text-muted);
409 text-decoration: none;
410 font-weight: 500;
411 }
412 .sym-crumbs a:hover { border-color: var(--border-strong); color: var(--text-strong); text-decoration: none; }
413`;
414
415function IconRefresh() {
416 return (
417 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
418 <polyline points="23 4 23 10 17 10" />
419 <polyline points="1 20 1 14 7 14" />
420 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
421 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
422 </svg>
423 );
424}
425function IconSearch() {
426 return (
427 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
428 <circle cx="11" cy="11" r="7" />
429 <line x1="21" y1="21" x2="16.65" y2="16.65" />
430 </svg>
431 );
432}
433function IconCode() {
434 return (
435 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
436 <polyline points="16 18 22 12 16 6" />
437 <polyline points="8 6 2 12 8 18" />
438 </svg>
439 );
440}
441
442/** Map a code-symbol kind to a short glyph + className suffix. */
443function kindBadge(kind: string): { cls: string; glyph: string; label: string } {
444 const k = (kind || "").toLowerCase();
445 if (k === "function" || k === "fn" || k === "method")
446 return { cls: "is-function", glyph: "fn", label: "function" };
447 if (k === "class") return { cls: "is-class", glyph: "Cl", label: "class" };
448 if (k === "interface") return { cls: "is-interface", glyph: "If", label: "interface" };
449 if (k === "type") return { cls: "is-type", glyph: "T", label: "type" };
450 if (k === "const") return { cls: "is-const", glyph: "K", label: "const" };
451 if (k === "var" || k === "variable" || k === "let")
452 return { cls: "is-var", glyph: "V", label: "var" };
453 return { cls: "is-other", glyph: "·", label: kind || "symbol" };
454}
455
4c8f666Claude456async function loadRepo(ownerName: string, repoName: string) {
457 const [owner] = await db
458 .select()
459 .from(users)
460 .where(eq(users.username, ownerName))
461 .limit(1);
462 if (!owner) return null;
463 const [repo] = await db
464 .select()
465 .from(repositories)
466 .where(
467 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
468 )
469 .limit(1);
470 if (!repo) return null;
471 return { owner, repo };
472}
473
0a50474Claude474function SymbolCard(props: {
475 ownerName: string;
476 repoName: string;
477 name: string;
478 kind: string;
479 path: string;
480 line: number;
481 signature?: string | null;
482}) {
483 const { ownerName, repoName, name, kind, path, line, signature } = props;
484 const badge = kindBadge(kind);
485 return (
486 <div class="sym-card">
487 <span
488 class={"sym-kind " + badge.cls}
489 title={badge.label}
490 aria-label={badge.label}
491 >
492 {badge.glyph}
493 </span>
494 <div class="sym-card-body">
495 <div class="sym-card-row">
496 <div class="sym-card-name">
497 <a
498 href={`/${ownerName}/${repoName}/symbols/${encodeURIComponent(name)}`}
499 class="sym-name"
500 >
501 {name}
502 </a>
503 <span class="sym-kind-label">{badge.label}</span>
504 </div>
505 <a
506 href={`/${ownerName}/${repoName}/blob/HEAD/${path}#L${line}`}
507 class="sym-card-loc"
508 >
509 {path}:{line}
510 </a>
511 </div>
512 {signature && <pre class="sym-sig">{signature}</pre>}
513 </div>
514 </div>
515 );
516}
517
4c8f666Claude518// ---------- Overview ----------
519
520symbols.get("/:owner/:repo/symbols", async (c) => {
521 const user = c.get("user");
522 const { owner: ownerName, repo: repoName } = c.req.param();
523 const ctx = await loadRepo(ownerName, repoName);
524 if (!ctx) return c.notFound();
0a50474Claude525 const { repo } = ctx;
4c8f666Claude526 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
527 return c.notFound();
528 }
529
530 const [countRow] = await db
531 .select({ n: sql<number>`count(*)::int` })
532 .from(codeSymbols)
533 .where(eq(codeSymbols.repositoryId, repo.id));
534 const total = Number(countRow?.n || 0);
535
536 const byKindRaw = await db
537 .select({
538 kind: codeSymbols.kind,
539 n: sql<number>`count(*)::int`,
540 })
541 .from(codeSymbols)
542 .where(eq(codeSymbols.repositoryId, repo.id))
543 .groupBy(codeSymbols.kind);
544
545 const latest = await db
546 .select({
547 name: codeSymbols.name,
548 kind: codeSymbols.kind,
549 path: codeSymbols.path,
550 line: codeSymbols.line,
551 })
552 .from(codeSymbols)
553 .where(eq(codeSymbols.repositoryId, repo.id))
554 .orderBy(asc(codeSymbols.name))
555 .limit(50);
556
557 const isOwner = user && user.id === repo.ownerId;
558 const message = c.req.query("message");
559
560 return c.html(
561 <Layout title={`Symbols — ${ownerName}/${repoName}`} user={user}>
562 <RepoHeader owner={ownerName} repo={repoName} />
563 <RepoNav owner={ownerName} repo={repoName} active="code" />
0a50474Claude564 <div class="sym-wrap">
565 <header class="sym-head">
566 <div class="sym-head-text">
567 <div class="sym-eyebrow">
568 <span class="sym-eyebrow-dot" aria-hidden="true" />
569 Repository · Symbols
570 </div>
571 <h1 class="sym-title">
572 <span class="sym-title-grad">Top-level definitions, indexed.</span>
573 </h1>
574 <p class="sym-sub">
575 Jump straight to the file and line where each function, class,
576 interface, type, or constant is defined on the default branch.
577 </p>
578 </div>
4c8f666Claude579 {isOwner && (
001af43Claude580 <form method="post" action={`/${ownerName}/${repoName}/symbols/reindex`}>
0a50474Claude581 <button type="submit" class="sym-btn sym-btn-primary">
582 <IconRefresh />
4c8f666Claude583 Reindex
584 </button>
585 </form>
586 )}
0a50474Claude587 </header>
588
4c8f666Claude589 {message && (
0a50474Claude590 <div class="sym-banner is-ok" role="status">
591 <span class="sym-banner-dot" aria-hidden="true" />
4c8f666Claude592 {decodeURIComponent(message)}
593 </div>
594 )}
595
596 <form
001af43Claude597 method="get"
4c8f666Claude598 action={`/${ownerName}/${repoName}/symbols/search`}
0a50474Claude599 class="sym-search"
4c8f666Claude600 >
0a50474Claude601 <div class="sym-search-input-wrap">
602 <span class="sym-search-icon" aria-hidden="true">
603 <IconSearch />
604 </span>
605 <input
606 type="text"
607 name="q"
608 placeholder="Search symbol name…"
609 required
610 aria-label="Search symbol name"
611 class="sym-search-input"
612 />
613 </div>
614 <button type="submit" class="sym-btn sym-btn-primary">
4c8f666Claude615 Search
616 </button>
617 </form>
618
0a50474Claude619 <div class="sym-stats">
620 <div class="sym-stat">
621 <div class="sym-stat-num">{total}</div>
622 <div class="sym-stat-label">Symbols</div>
4c8f666Claude623 </div>
624 {byKindRaw.map((r) => (
0a50474Claude625 <div class="sym-stat">
626 <div class="sym-stat-num">{Number(r.n)}</div>
627 <div class="sym-stat-label">{r.kind}</div>
4c8f666Claude628 </div>
629 ))}
630 </div>
631
0a50474Claude632 <h3 class="sym-section-title">A–Z</h3>
4c8f666Claude633 {total === 0 ? (
0a50474Claude634 <div class="sym-empty">
635 <div class="sym-empty-orb" aria-hidden="true" />
636 <div class="sym-empty-inner">
637 <div class="sym-empty-icon" aria-hidden="true">
638 <IconCode />
639 </div>
640 <h3 class="sym-empty-title">No symbols indexed yet</h3>
641 <p class="sym-empty-sub">
642 {isOwner
643 ? "Click Reindex to scan the default branch for top-level functions, classes, interfaces, types, and constants."
644 : "The owner hasn't indexed this repository's symbols yet."}
645 </p>
646 {isOwner && (
647 <form method="post" action={`/${ownerName}/${repoName}/symbols/reindex`}>
648 <button type="submit" class="sym-btn sym-btn-primary">
649 <IconRefresh />
650 Reindex now
651 </button>
652 </form>
653 )}
654 </div>
4c8f666Claude655 </div>
656 ) : (
0a50474Claude657 <div class="sym-list">
4c8f666Claude658 {latest.map((s) => (
0a50474Claude659 <SymbolCard
660 ownerName={ownerName}
661 repoName={repoName}
662 name={s.name}
663 kind={s.kind}
664 path={s.path}
665 line={s.line}
666 />
4c8f666Claude667 ))}
668 </div>
669 )}
670 </div>
0a50474Claude671 <style dangerouslySetInnerHTML={{ __html: symStyles }} />
4c8f666Claude672 </Layout>
673 );
674});
675
676// ---------- Search ----------
677
678symbols.get("/:owner/:repo/symbols/search", async (c) => {
679 const user = c.get("user");
680 const { owner: ownerName, repo: repoName } = c.req.param();
681 const ctx = await loadRepo(ownerName, repoName);
682 if (!ctx) return c.notFound();
683 const { repo } = ctx;
684 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
685 return c.notFound();
686 }
687
688 const q = (c.req.query("q") || "").trim();
689 const results = q
690 ? await db
691 .select({
692 name: codeSymbols.name,
693 kind: codeSymbols.kind,
694 path: codeSymbols.path,
695 line: codeSymbols.line,
696 })
697 .from(codeSymbols)
698 .where(
699 and(
700 eq(codeSymbols.repositoryId, repo.id),
701 ilike(codeSymbols.name, `${q}%`)
702 )
703 )
704 .orderBy(asc(codeSymbols.name))
705 .limit(200)
706 : [];
707
708 return c.html(
709 <Layout title={`Symbol search — ${ownerName}/${repoName}`} user={user}>
710 <RepoHeader owner={ownerName} repo={repoName} />
711 <RepoNav owner={ownerName} repo={repoName} active="code" />
0a50474Claude712 <div class="sym-wrap">
713 <div class="sym-crumbs">
714 <a href={`/${ownerName}/${repoName}/symbols`}>← Back to symbols</a>
715 </div>
716 <header class="sym-head">
717 <div class="sym-head-text">
718 <div class="sym-eyebrow">
719 <span class="sym-eyebrow-dot" aria-hidden="true" />
720 Repository · Symbol search
721 </div>
722 <h1 class="sym-title">
723 <span class="sym-title-grad">Find a symbol by name.</span>
724 </h1>
725 <p class="sym-sub">
726 Prefix match across every indexed symbol — capped at 200
727 results.
728 </p>
729 </div>
730 </header>
731
4c8f666Claude732 <form
001af43Claude733 method="get"
4c8f666Claude734 action={`/${ownerName}/${repoName}/symbols/search`}
0a50474Claude735 class="sym-search"
4c8f666Claude736 >
0a50474Claude737 <div class="sym-search-input-wrap">
738 <span class="sym-search-icon" aria-hidden="true">
739 <IconSearch />
740 </span>
741 <input
742 type="text"
743 name="q"
744 value={q}
745 placeholder="Search symbol name…"
746 required
747 aria-label="Search symbol name"
748 class="sym-search-input"
749 />
750 </div>
751 <button type="submit" class="sym-btn sym-btn-primary">
4c8f666Claude752 Search
753 </button>
754 </form>
0a50474Claude755
4c8f666Claude756 {q === "" ? (
0a50474Claude757 <div class="sym-empty">
758 <div class="sym-empty-orb" aria-hidden="true" />
759 <div class="sym-empty-inner">
760 <div class="sym-empty-icon" aria-hidden="true">
761 <IconSearch />
762 </div>
763 <h3 class="sym-empty-title">Type a prefix to search</h3>
764 <p class="sym-empty-sub">
765 Start typing a symbol name — matches appear as you submit.
766 </p>
767 </div>
768 </div>
4c8f666Claude769 ) : results.length === 0 ? (
0a50474Claude770 <div class="sym-empty">
771 <div class="sym-empty-orb" aria-hidden="true" />
772 <div class="sym-empty-inner">
773 <div class="sym-empty-icon" aria-hidden="true">
774 <IconSearch />
775 </div>
776 <h3 class="sym-empty-title">No symbols match "{q}"</h3>
777 <p class="sym-empty-sub">
778 Try a shorter prefix, or reindex if you've made changes.
779 </p>
780 </div>
781 </div>
4c8f666Claude782 ) : (
0a50474Claude783 <div class="sym-list">
4c8f666Claude784 {results.map((s) => (
0a50474Claude785 <SymbolCard
786 ownerName={ownerName}
787 repoName={repoName}
788 name={s.name}
789 kind={s.kind}
790 path={s.path}
791 line={s.line}
792 />
4c8f666Claude793 ))}
794 </div>
795 )}
796 </div>
0a50474Claude797 <style dangerouslySetInnerHTML={{ __html: symStyles }} />
4c8f666Claude798 </Layout>
799 );
800});
801
802// ---------- Symbol detail ----------
803
804symbols.get("/:owner/:repo/symbols/:name", async (c) => {
805 const user = c.get("user");
806 const { owner: ownerName, repo: repoName, name } = c.req.param();
807 const ctx = await loadRepo(ownerName, repoName);
808 if (!ctx) return c.notFound();
809 const { repo } = ctx;
810 if (repo.isPrivate && (!user || user.id !== repo.ownerId)) {
811 return c.notFound();
812 }
813
0a50474Claude814 const decodedName = decodeURIComponent(name);
815 const defs = await findDefinitions(repo.id, decodedName);
4c8f666Claude816
817 return c.html(
818 <Layout title={`${name} — ${ownerName}/${repoName}`} user={user}>
819 <RepoHeader owner={ownerName} repo={repoName} />
820 <RepoNav owner={ownerName} repo={repoName} active="code" />
0a50474Claude821 <div class="sym-wrap">
822 <div class="sym-crumbs">
823 <a href={`/${ownerName}/${repoName}/symbols`}>← Back to symbols</a>
824 </div>
825 <header class="sym-head">
826 <div class="sym-head-text">
827 <div class="sym-eyebrow">
828 <span class="sym-eyebrow-dot" aria-hidden="true" />
829 Repository · Symbol
830 </div>
831 <h1 class="sym-title sym-title-mono">
832 <span class="sym-title-grad">{decodedName}</span>
833 </h1>
834 <p class="sym-sub">
835 {defs.length} definition{defs.length === 1 ? "" : "s"} found
836 across the indexed code.
837 </p>
838 </div>
839 </header>
840
4c8f666Claude841 {defs.length === 0 ? (
0a50474Claude842 <div class="sym-empty">
843 <div class="sym-empty-orb" aria-hidden="true" />
844 <div class="sym-empty-inner">
845 <div class="sym-empty-icon" aria-hidden="true">
846 <IconCode />
847 </div>
848 <h3 class="sym-empty-title">No definitions found</h3>
849 <p class="sym-empty-sub">
850 This symbol isn't currently indexed. It may have been removed,
851 or you may need to reindex.
852 </p>
853 <a
854 href={`/${ownerName}/${repoName}/symbols`}
855 class="sym-btn sym-btn-ghost"
856 >
857 Back to symbols
858 </a>
859 </div>
4c8f666Claude860 </div>
861 ) : (
0a50474Claude862 <div class="sym-list">
4c8f666Claude863 {defs.map((d) => (
0a50474Claude864 <SymbolCard
865 ownerName={ownerName}
866 repoName={repoName}
867 name={decodedName}
868 kind={d.kind}
869 path={d.path}
870 line={d.line}
871 signature={d.signature}
872 />
4c8f666Claude873 ))}
874 </div>
875 )}
876 </div>
0a50474Claude877 <style dangerouslySetInnerHTML={{ __html: symStyles }} />
4c8f666Claude878 </Layout>
879 );
880});
881
882// ---------- Reindex ----------
883
884symbols.post("/:owner/:repo/symbols/reindex", requireAuth, async (c) => {
885 const user = c.get("user")!;
886 const { owner: ownerName, repo: repoName } = c.req.param();
887 const ctx = await loadRepo(ownerName, repoName);
888 if (!ctx) return c.notFound();
889 const { repo } = ctx;
890 if (user.id !== repo.ownerId) {
891 return c.html(
892 <Layout title="Forbidden" user={user}>
893 <div class="empty-state">
894 <h2>403</h2>
895 <p>Only the repository owner can reindex symbols.</p>
896 </div>
897 </Layout>,
898 403
899 );
900 }
901
902 const result = await indexRepositorySymbols(repo.id);
903 const msg = result
904 ? `Indexed ${result.indexed} symbols across ${result.files} files.`
905 : "Indexing failed — see server logs.";
906 return c.redirect(
907 `/${ownerName}/${repoName}/symbols?message=${encodeURIComponent(msg)}`
908 );
909});
910
911export default symbols;