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

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

web.tsxBlame6149 lines · 3 contributors
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
79136bbClaude7import { html } from "hono/html";
ebaae0fClaude8import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
06d5ffeClaude9import { db } from "../db";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
8c790e0Claude16 activityFeed,
ebaae0fClaude17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
641aa42Claude22 repoOnboardingData,
3951454Claude23} from "../db/schema";
fc1817aClaude24import { Layout } from "../views/layout";
cb5a796Claude25import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude26import {
27 RepoHeader,
28 RepoNav,
29 Breadcrumb,
30 FileTable,
06d5ffeClaude31 RepoCard,
32 BranchSwitcher,
33 HighlightedCode,
34 PlainCode,
8c790e0Claude35 type RecentPush,
fc1817aClaude36} from "../views/components";
ea9ed4cClaude37import { DiffView } from "../views/diff-view";
fc1817aClaude38import {
39 getTree,
40 getBlob,
41 listCommits,
42 getCommit,
43 getCommitFullMessage,
44 getDiff,
45 getReadme,
46 getDefaultBranch,
47 listBranches,
398a10cClaude48 listTags,
fc1817aClaude49 repoExists,
06d5ffeClaude50 initBareRepo,
79136bbClaude51 getBlame,
52 getRawBlob,
53 searchCode,
398a10cClaude54 getRepoPath,
fc1817aClaude55} from "../git/repository";
79136bbClaude56import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude57import { highlightCode } from "../lib/highlight";
91a0204Claude58import { computeHealthScore } from "../lib/health-score";
59import type { HealthScore } from "../lib/health-score";
06d5ffeClaude60import { softAuth, requireAuth } from "../middleware/auth";
61import type { AuthEnv } from "../middleware/auth";
53c9249Claude62import { claudeSemanticSearch } from "../lib/claude-semantic-search";
63import { isAiAvailable } from "../lib/ai-client";
8f50ed0Claude64import { trackByName } from "../lib/traffic";
534f04aClaude65import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude66import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude67import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude68import {
69 listQueuedAiBuildIssues,
70 listRecentAutoMerges,
71 listRecentAiReviews,
72 countAiReviewsSince,
73 listDemoActivityFeed,
74} from "../lib/demo-activity";
fc1817aClaude75
06d5ffeClaude76const web = new Hono<AuthEnv>();
77
78// Soft auth on all web routes — c.get("user") available but may be null
79web.use("*", softAuth);
fc1817aClaude80
ebaae0fClaude81// ---------------------------------------------------------------------------
82// Repo AI stats — computed once per repo home page load, best-effort.
83// Fast because every WHERE clause is bounded to a 7-day window.
84// ---------------------------------------------------------------------------
85interface RepoAiStats {
86 mergedCount: number;
87 reviewCount: number;
88 securityAlertCount: number;
89 hoursSaved: string;
90}
91
92async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
93 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
94
95 // 1. AI-merged PRs this week: activity_feed entries with action =
96 // 'auto_merge.merged' in the last 7 days for this repo.
97 // This is cheaper than a JOIN on pull_requests and covers both the
98 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
99 const [mergedRow] = await db
100 .select({ n: count() })
101 .from(activityFeed)
102 .where(
103 and(
104 eq(activityFeed.repositoryId, repoId),
105 eq(activityFeed.action, "auto_merge.merged"),
106 gte(activityFeed.createdAt, since)
107 )
108 );
109 const mergedCount = Number(mergedRow?.n ?? 0);
110
111 // 2. AI reviews this week: pr_comments where is_ai_review = true in
112 // the last 7 days, joined through pull_requests to scope to this repo.
113 const [reviewRow] = await db
114 .select({ n: count() })
115 .from(prComments)
116 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
117 .where(
118 and(
119 eq(pullRequests.repositoryId, repoId),
120 eq(prComments.isAiReview, true),
121 gte(prComments.createdAt, since)
122 )
123 );
124 const reviewCount = Number(reviewRow?.n ?? 0);
125
126 // 3. Open security alerts: open issues that carry a label whose name
127 // contains 'security' (case-insensitive) for this repo.
128 const [secRow] = await db
129 .select({ n: count() })
130 .from(issues)
131 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
132 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
133 .where(
134 and(
135 eq(issues.repositoryId, repoId),
136 eq(issues.state, "open"),
137 sql`lower(${labels.name}) like '%security%'`
138 )
139 );
140 const securityAlertCount = Number(secRow?.n ?? 0);
141
142 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
143 const hours = mergedCount * 1.5 + reviewCount * 0.5;
144 const hoursSaved = hours.toFixed(1);
145
146 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
147}
148
8c790e0Claude149/**
150 * Query the most recent push to a repo from the activity_feed table.
151 * Returns a RecentPush if there's been a push in the last 24 hours,
152 * or null otherwise. Used by the RepoHeader live/watch indicator.
153 *
154 * Queries activity_feed where action='push' and targetId holds the commit SHA.
155 */
156async function getRecentPush(repoId: string): Promise<RecentPush | null> {
157 try {
158 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
159 const [row] = await db
160 .select({
161 targetId: activityFeed.targetId,
162 createdAt: activityFeed.createdAt,
163 })
164 .from(activityFeed)
165 .where(
166 and(
167 eq(activityFeed.repositoryId, repoId),
168 eq(activityFeed.action, "push"),
169 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
170 )
171 )
172 .orderBy(desc(activityFeed.createdAt))
173 .limit(1);
174 if (!row || !row.targetId) return null;
175 return {
176 sha: row.targetId,
177 ageMs: Date.now() - new Date(row.createdAt).getTime(),
178 };
179 } catch {
180 // DB unavailable or schema mismatch — degrade gracefully.
181 return null;
182 }
183}
184
efb11c5Claude185/**
186 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
187 *
188 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
189 * scope is route-local and `layout.tsx` is locked. Each polished handler
190 * injects this via a `<style>` tag; the rules are namespaced by surface
191 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
192 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
193 * `.repo-home-*` styling Agent A already shipped.
194 */
195const codeBrowseCss = `
196 /* ───────── shared primitives ───────── */
197 .cb-hairline::before,
198 .new-repo-hero::before,
199 .profile-hero::before,
200 .commits-hero::before,
201 .commit-detail-card::before,
202 .search-hero::before {
203 content: '';
204 position: absolute;
205 top: 0; left: 0; right: 0;
206 height: 2px;
207 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
208 opacity: 0.7;
209 pointer-events: none;
210 }
211 @keyframes cbHeroOrb {
212 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
213 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
214 }
215 @media (prefers-reduced-motion: reduce) {
216 .new-repo-hero-orb,
217 .profile-hero-orb,
218 .commits-hero-orb { animation: none; }
219 }
220
221 /* ───────── new-repo ───────── */
222 .new-repo-hero {
223 position: relative;
224 margin-bottom: var(--space-5);
225 padding: var(--space-5) var(--space-6);
226 background: var(--bg-elevated);
227 border: 1px solid var(--border);
228 border-radius: 16px;
229 overflow: hidden;
230 }
231 .new-repo-hero-orb-wrap {
232 position: absolute;
233 inset: -25% -10% auto auto;
234 width: 360px;
235 height: 360px;
236 pointer-events: none;
237 z-index: 0;
238 }
239 .new-repo-hero-orb {
240 position: absolute;
241 inset: 0;
242 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
243 filter: blur(80px);
244 opacity: 0.7;
245 animation: cbHeroOrb 14s ease-in-out infinite;
246 }
247 .new-repo-hero-inner { position: relative; z-index: 1; }
248 .new-repo-eyebrow {
249 font-size: 12px;
250 font-family: var(--font-mono);
251 color: var(--text-muted);
252 letter-spacing: 0.1em;
253 text-transform: uppercase;
254 margin-bottom: var(--space-2);
255 }
256 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
257 .new-repo-title {
258 font-family: var(--font-display);
259 font-weight: 800;
260 letter-spacing: -0.028em;
261 font-size: clamp(28px, 4vw, 40px);
262 line-height: 1.05;
263 margin: 0 0 var(--space-2);
264 color: var(--text-strong);
265 }
266 .new-repo-sub {
267 font-size: 15px;
268 color: var(--text-muted);
269 margin: 0;
270 line-height: 1.55;
271 max-width: 620px;
272 }
273 .new-repo-form {
274 max-width: 680px;
275 }
276 .new-repo-error {
277 background: rgba(218, 54, 51, 0.12);
278 border: 1px solid rgba(218, 54, 51, 0.35);
279 color: #ffb3b3;
280 padding: 10px 14px;
281 border-radius: 10px;
282 margin-bottom: var(--space-4);
283 font-size: 14px;
284 }
285 .new-repo-form-grid {
286 display: flex;
287 flex-direction: column;
288 gap: var(--space-4);
289 }
290 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
291 .new-repo-label {
292 font-size: 13px;
293 color: var(--text-strong);
294 font-weight: 600;
295 }
296 .new-repo-label-optional {
297 color: var(--text-muted);
298 font-weight: 400;
299 font-size: 12px;
300 }
301 .new-repo-input {
302 appearance: none;
303 width: 100%;
304 background: var(--bg);
305 border: 1px solid var(--border);
306 color: var(--text-strong);
307 border-radius: 10px;
308 padding: 10px 12px;
309 font-size: 14px;
310 font-family: inherit;
311 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
312 }
313 .new-repo-input:focus {
314 outline: none;
315 border-color: var(--accent);
316 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
317 }
318 .new-repo-input-disabled {
319 color: var(--text-muted);
320 background: var(--bg-secondary);
321 cursor: not-allowed;
322 }
323 .new-repo-hint {
324 font-size: 12px;
325 color: var(--text-muted);
326 margin: 4px 0 0;
327 }
328 .new-repo-hint code {
329 font-family: var(--font-mono);
330 font-size: 11.5px;
331 background: var(--bg-secondary);
332 border: 1px solid var(--border);
333 border-radius: 5px;
334 padding: 1px 5px;
335 color: var(--text-strong);
336 }
337 .new-repo-visibility {
338 display: grid;
339 grid-template-columns: 1fr 1fr;
340 gap: var(--space-2);
341 }
342 @media (max-width: 600px) {
343 .new-repo-visibility { grid-template-columns: 1fr; }
344 }
345 .new-repo-vis-card {
346 display: flex;
347 gap: 10px;
348 padding: 14px;
349 background: var(--bg);
350 border: 1px solid var(--border);
351 border-radius: 12px;
352 cursor: pointer;
353 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
354 }
355 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
356 .new-repo-vis-card:has(input:checked) {
357 border-color: rgba(140,109,255,0.55);
358 background: rgba(140,109,255,0.06);
359 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
360 }
361 .new-repo-vis-radio {
362 margin-top: 3px;
363 accent-color: var(--accent);
364 }
365 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
366 .new-repo-vis-label {
367 font-size: 14px;
368 font-weight: 600;
369 color: var(--text-strong);
370 }
371 .new-repo-vis-desc {
372 font-size: 12.5px;
373 color: var(--text-muted);
374 line-height: 1.45;
375 }
376 .new-repo-callout {
377 margin-top: var(--space-2);
378 padding: var(--space-3) var(--space-4);
379 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
380 border: 1px solid rgba(140,109,255,0.2);
381 border-radius: 12px;
382 }
383 .new-repo-callout-eyebrow {
384 font-size: 11px;
385 font-family: var(--font-mono);
386 text-transform: uppercase;
387 letter-spacing: 0.12em;
388 color: var(--accent);
389 font-weight: 700;
390 margin-bottom: 4px;
391 }
392 .new-repo-callout-body {
393 font-size: 13px;
394 color: var(--text);
395 line-height: 1.5;
396 margin: 0;
397 }
398 .new-repo-callout-body code {
399 font-family: var(--font-mono);
400 font-size: 12px;
401 color: var(--accent);
402 background: rgba(140,109,255,0.1);
403 border-radius: 4px;
404 padding: 1px 5px;
405 }
398a10cClaude406 .new-repo-templates {
407 display: flex;
408 flex-wrap: wrap;
409 gap: 8px;
410 margin-top: 4px;
411 }
412 .new-repo-template-chip {
413 position: relative;
414 display: inline-flex;
415 align-items: center;
416 gap: 6px;
417 padding: 8px 14px;
418 background: var(--bg);
419 border: 1px solid var(--border);
420 border-radius: 999px;
421 font-size: 13px;
422 color: var(--text);
423 cursor: pointer;
424 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
425 }
426 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
427 .new-repo-template-chip:hover {
428 border-color: var(--border-strong, var(--border));
429 color: var(--text-strong);
430 }
431 .new-repo-template-chip:has(input:checked) {
432 border-color: rgba(140,109,255,0.55);
433 background: rgba(140,109,255,0.10);
434 color: var(--text-strong);
435 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
436 }
437 .new-repo-template-chip-dot {
438 width: 8px; height: 8px;
439 border-radius: 999px;
440 background: var(--text-faint, var(--text-muted));
441 transition: background 140ms ease, box-shadow 140ms ease;
442 }
443 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
444 background: linear-gradient(135deg, #8c6dff, #36c5d6);
445 box-shadow: 0 0 8px rgba(140,109,255,0.6);
446 }
efb11c5Claude447 .new-repo-actions {
448 display: flex;
449 gap: var(--space-2);
450 margin-top: var(--space-2);
398a10cClaude451 align-items: center;
452 }
453 .new-repo-submit {
454 min-width: 180px;
455 border: 1px solid rgba(140,109,255,0.45);
456 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
457 color: #fff;
458 font-weight: 700;
459 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
460 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
461 }
462 .new-repo-submit:hover {
463 transform: translateY(-1px);
464 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
465 filter: brightness(1.06);
466 }
467 .new-repo-submit:focus-visible {
468 outline: 3px solid rgba(140,109,255,0.45);
469 outline-offset: 2px;
efb11c5Claude470 }
471
472 /* ───────── profile ───────── */
473 .profile-hero {
474 position: relative;
475 margin-bottom: var(--space-5);
476 padding: var(--space-5) var(--space-6);
477 background: var(--bg-elevated);
478 border: 1px solid var(--border);
479 border-radius: 16px;
480 overflow: hidden;
481 }
482 .profile-hero-orb-wrap {
483 position: absolute;
484 inset: -25% -10% auto auto;
485 width: 360px;
486 height: 360px;
487 pointer-events: none;
488 z-index: 0;
489 }
490 .profile-hero-orb {
491 position: absolute;
492 inset: 0;
493 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
494 filter: blur(80px);
495 opacity: 0.7;
496 animation: cbHeroOrb 14s ease-in-out infinite;
497 }
498 .profile-hero-inner {
499 position: relative;
500 z-index: 1;
501 display: flex;
502 align-items: flex-start;
503 gap: var(--space-5);
504 }
505 .profile-hero-avatar {
506 flex: 0 0 auto;
507 width: 88px;
508 height: 88px;
509 border-radius: 50%;
510 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
511 color: #fff;
512 display: flex;
513 align-items: center;
514 justify-content: center;
515 font-size: 38px;
516 font-weight: 700;
517 font-family: var(--font-display);
518 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
519 }
520 .profile-hero-text { flex: 1; min-width: 0; }
521 .profile-eyebrow {
522 font-size: 12px;
523 font-family: var(--font-mono);
524 color: var(--text-muted);
525 letter-spacing: 0.1em;
526 text-transform: uppercase;
527 margin-bottom: var(--space-2);
528 }
529 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
530 .profile-name {
531 font-family: var(--font-display);
532 font-weight: 800;
533 letter-spacing: -0.028em;
534 font-size: clamp(28px, 3.6vw, 36px);
535 line-height: 1.05;
536 margin: 0 0 4px;
537 color: var(--text-strong);
538 }
539 .profile-handle {
540 font-family: var(--font-mono);
541 font-size: 13px;
542 color: var(--text-muted);
543 margin-bottom: var(--space-2);
544 }
545 .profile-bio {
546 font-size: 14.5px;
547 color: var(--text);
548 line-height: 1.55;
549 margin: 0 0 var(--space-3);
550 max-width: 640px;
551 }
552 .profile-meta {
553 display: flex;
554 align-items: center;
555 gap: var(--space-4);
556 flex-wrap: wrap;
557 font-size: 13px;
558 }
559 .profile-meta-link {
560 color: var(--text-muted);
561 transition: color var(--t-fast, 0.15s) ease;
562 }
563 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
564 .profile-meta-link strong {
565 color: var(--text-strong);
566 font-weight: 600;
567 font-variant-numeric: tabular-nums;
568 }
569 .profile-follow-form { margin: 0; }
570 @media (max-width: 600px) {
571 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
572 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
573 }
574 .profile-readme {
575 margin-bottom: var(--space-6);
576 background: var(--bg-elevated);
577 border: 1px solid var(--border);
578 border-radius: 12px;
579 overflow: hidden;
580 }
581 .profile-readme-head {
582 display: flex;
583 align-items: center;
584 gap: 8px;
585 padding: 10px 16px;
586 background: var(--bg-secondary);
587 border-bottom: 1px solid var(--border);
588 font-size: 13px;
589 color: var(--text-muted);
590 }
591 .profile-readme-icon { color: var(--accent); font-size: 14px; }
592 .profile-readme-body { padding: var(--space-5) var(--space-6); }
593 .profile-section-head {
594 display: flex;
595 align-items: baseline;
596 gap: var(--space-2);
597 margin-bottom: var(--space-3);
598 }
599 .profile-section-title {
600 font-family: var(--font-display);
601 font-weight: 700;
602 font-size: 20px;
603 letter-spacing: -0.015em;
604 margin: 0;
605 color: var(--text-strong);
606 }
607 .profile-section-count {
608 font-family: var(--font-mono);
609 font-size: 12px;
610 color: var(--text-muted);
611 background: var(--bg-secondary);
612 border: 1px solid var(--border);
613 border-radius: 999px;
614 padding: 2px 10px;
615 font-variant-numeric: tabular-nums;
616 }
617 .profile-empty {
618 display: flex;
619 align-items: center;
620 justify-content: space-between;
621 gap: var(--space-3);
622 padding: var(--space-4) var(--space-5);
623 background: var(--bg-elevated);
624 border: 1px dashed var(--border);
625 border-radius: 12px;
626 }
627 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
628
629 /* ───────── tree (file browser) ───────── */
630 .tree-header {
631 margin-bottom: var(--space-3);
632 display: flex;
633 flex-direction: column;
634 gap: var(--space-2);
635 }
636 .tree-header-row {
637 display: flex;
638 align-items: center;
639 justify-content: space-between;
640 gap: var(--space-3);
641 flex-wrap: wrap;
642 }
643 .tree-header-stats {
644 display: flex;
645 align-items: center;
646 gap: var(--space-3);
647 font-size: 12px;
648 color: var(--text-muted);
649 }
650 .tree-stat strong {
651 color: var(--text-strong);
652 font-weight: 600;
653 font-variant-numeric: tabular-nums;
654 }
655 .tree-stat-link {
656 color: var(--text-muted);
657 padding: 4px 10px;
658 border-radius: 999px;
659 border: 1px solid var(--border);
660 background: var(--bg-elevated);
661 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
662 }
663 .tree-stat-link:hover {
664 color: var(--accent);
665 border-color: rgba(140,109,255,0.45);
666 text-decoration: none;
667 }
668 .tree-breadcrumb-row {
669 font-size: 13px;
670 }
671
672 /* ───────── blob (file viewer) ───────── */
673 .blob-toolbar {
674 margin-bottom: var(--space-3);
675 display: flex;
676 flex-direction: column;
677 gap: var(--space-2);
678 }
679 .blob-breadcrumb { font-size: 13px; }
680 .blob-card {
681 background: var(--bg-elevated);
682 border: 1px solid var(--border);
683 border-radius: 12px;
684 overflow: hidden;
685 }
686 .blob-header-polished {
687 display: flex;
688 align-items: center;
689 justify-content: space-between;
690 gap: var(--space-3);
691 padding: 10px 14px;
692 background: var(--bg-secondary);
693 border-bottom: 1px solid var(--border);
694 }
695 .blob-header-meta {
696 display: flex;
697 align-items: center;
698 gap: var(--space-2);
699 min-width: 0;
700 flex: 1;
701 }
702 .blob-header-icon { font-size: 14px; opacity: 0.85; }
703 .blob-header-name {
704 font-family: var(--font-mono);
705 font-size: 13px;
706 color: var(--text-strong);
707 font-weight: 600;
708 overflow: hidden;
709 text-overflow: ellipsis;
710 white-space: nowrap;
711 }
712 .blob-header-size {
713 font-family: var(--font-mono);
714 font-size: 11.5px;
715 color: var(--text-muted);
716 font-variant-numeric: tabular-nums;
717 border-left: 1px solid var(--border);
718 padding-left: var(--space-2);
719 margin-left: 2px;
720 }
721 .blob-header-actions {
722 display: flex;
723 gap: 6px;
724 flex-shrink: 0;
725 }
726 .blob-pill {
727 display: inline-flex;
728 align-items: center;
729 padding: 4px 12px;
730 font-size: 12px;
731 font-weight: 500;
732 color: var(--text);
733 background: var(--bg-elevated);
734 border: 1px solid var(--border);
735 border-radius: 999px;
736 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
737 }
738 .blob-pill:hover {
739 color: var(--accent);
740 border-color: rgba(140,109,255,0.45);
741 text-decoration: none;
742 background: rgba(140,109,255,0.06);
743 }
744 .blob-pill-accent {
745 color: var(--accent);
746 border-color: rgba(140,109,255,0.35);
747 background: rgba(140,109,255,0.08);
748 }
749 .blob-pill-accent:hover {
750 background: rgba(140,109,255,0.14);
751 }
752 .blob-binary {
753 padding: var(--space-5);
754 color: var(--text-muted);
755 text-align: center;
756 font-size: 13px;
757 background: var(--bg);
758 }
759
760 /* ───────── commits list ───────── */
761 .commits-hero {
762 position: relative;
763 margin-bottom: var(--space-4);
764 padding: var(--space-5) var(--space-6);
765 background: var(--bg-elevated);
766 border: 1px solid var(--border);
767 border-radius: 16px;
768 overflow: hidden;
769 }
770 .commits-hero-orb-wrap {
771 position: absolute;
772 inset: -25% -10% auto auto;
773 width: 320px;
774 height: 320px;
775 pointer-events: none;
776 z-index: 0;
777 }
778 .commits-hero-orb {
779 position: absolute;
780 inset: 0;
781 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
782 filter: blur(80px);
783 opacity: 0.7;
784 animation: cbHeroOrb 14s ease-in-out infinite;
785 }
786 .commits-hero-inner { position: relative; z-index: 1; }
787 .commits-eyebrow {
788 font-size: 12px;
789 font-family: var(--font-mono);
790 color: var(--text-muted);
791 letter-spacing: 0.1em;
792 text-transform: uppercase;
793 margin-bottom: var(--space-2);
794 }
795 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
796 .commits-title {
797 font-family: var(--font-display);
798 font-weight: 800;
799 letter-spacing: -0.025em;
800 font-size: clamp(22px, 3vw, 30px);
801 line-height: 1.15;
802 margin: 0 0 var(--space-2);
803 color: var(--text-strong);
804 }
805 .commits-branch {
806 font-family: var(--font-mono);
807 font-size: 0.7em;
808 color: var(--text);
809 background: var(--bg-secondary);
810 border: 1px solid var(--border);
811 border-radius: 6px;
812 padding: 2px 8px;
813 font-weight: 500;
814 vertical-align: middle;
815 }
816 .commits-sub {
817 font-size: 14px;
818 color: var(--text-muted);
819 margin: 0;
820 line-height: 1.55;
821 max-width: 640px;
822 }
398a10cClaude823 .commits-toolbar {
824 display: flex;
825 justify-content: space-between;
826 align-items: center;
827 flex-wrap: wrap;
828 gap: var(--space-2);
829 margin-bottom: var(--space-3);
830 }
831 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
832 .commits-toolbar-link {
833 display: inline-flex;
834 align-items: center;
835 gap: 6px;
836 padding: 6px 12px;
837 font-size: 12.5px;
838 font-weight: 500;
839 color: var(--text-muted);
840 background: rgba(255,255,255,0.025);
841 border: 1px solid var(--border);
842 border-radius: 8px;
843 text-decoration: none;
844 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
845 }
846 .commits-toolbar-link:hover {
847 border-color: var(--border-strong);
848 color: var(--text-strong);
849 background: rgba(255,255,255,0.04);
850 text-decoration: none;
851 }
efb11c5Claude852 .commits-list-wrap {
853 background: var(--bg-elevated);
854 border: 1px solid var(--border);
398a10cClaude855 border-radius: 14px;
efb11c5Claude856 overflow: hidden;
398a10cClaude857 position: relative;
858 }
859 .commits-list-wrap::before {
860 content: '';
861 position: absolute;
862 top: 0; left: 0; right: 0;
863 height: 2px;
864 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
865 opacity: 0.55;
866 pointer-events: none;
867 }
868 .commits-day-head {
869 display: flex;
870 align-items: center;
871 gap: 10px;
872 padding: 10px 18px;
873 font-size: 11.5px;
874 font-family: var(--font-mono);
875 text-transform: uppercase;
876 letter-spacing: 0.08em;
877 color: var(--text-muted);
878 background: var(--bg-secondary);
879 border-bottom: 1px solid var(--border);
880 }
881 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
882 .commits-day-head-dot {
883 width: 6px; height: 6px;
884 border-radius: 9999px;
885 background: linear-gradient(135deg, #8c6dff, #36c5d6);
886 }
887 .commits-row {
888 display: flex;
889 align-items: flex-start;
890 gap: 14px;
891 padding: 14px 18px;
892 border-bottom: 1px solid var(--border-subtle);
893 transition: background 120ms ease;
894 }
895 .commits-row:last-child { border-bottom: none; }
896 .commits-row:hover { background: rgba(255,255,255,0.022); }
897 .commits-avatar {
898 width: 34px; height: 34px;
899 border-radius: 9999px;
900 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
901 color: #fff;
902 display: inline-flex;
903 align-items: center;
904 justify-content: center;
905 font-family: var(--font-display);
906 font-weight: 700;
907 font-size: 13.5px;
908 flex-shrink: 0;
909 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
910 }
911 .commits-row-body { flex: 1; min-width: 0; }
912 .commits-row-msg {
913 display: flex;
914 align-items: center;
915 gap: 8px;
916 flex-wrap: wrap;
917 }
918 .commits-row-msg a {
919 font-family: var(--font-display);
920 font-weight: 600;
921 font-size: 14px;
922 color: var(--text-strong);
923 text-decoration: none;
924 letter-spacing: -0.005em;
925 }
926 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
927 .commits-row-verified {
928 font-size: 9.5px;
929 padding: 1px 7px;
930 border-radius: 9999px;
931 background: rgba(52,211,153,0.16);
932 color: #6ee7b7;
933 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
934 text-transform: uppercase;
935 letter-spacing: 0.06em;
936 font-weight: 700;
937 }
938 .commits-row-meta {
939 margin-top: 4px;
940 display: flex;
941 align-items: center;
942 gap: 8px;
943 flex-wrap: wrap;
944 font-size: 12.5px;
945 color: var(--text-muted);
946 }
947 .commits-row-meta strong { color: var(--text); font-weight: 600; }
948 .commits-row-meta .sep { opacity: 0.4; }
949 .commits-row-time {
950 font-variant-numeric: tabular-nums;
951 }
952 .commits-row-side {
953 display: flex;
954 align-items: center;
955 gap: 8px;
956 flex-shrink: 0;
957 }
958 .commits-row-sha {
959 display: inline-flex;
960 align-items: center;
961 padding: 4px 10px;
962 border-radius: 9999px;
963 font-family: var(--font-mono);
964 font-size: 11.5px;
965 font-weight: 600;
966 color: var(--text-strong);
967 background: rgba(255,255,255,0.04);
968 border: 1px solid var(--border);
969 text-decoration: none;
970 letter-spacing: 0.04em;
971 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
972 }
973 .commits-row-sha:hover {
974 border-color: rgba(140,109,255,0.55);
975 color: var(--accent);
976 background: rgba(140,109,255,0.08);
977 text-decoration: none;
978 }
979 .commits-row-copy {
980 display: inline-flex;
981 align-items: center;
982 justify-content: center;
983 width: 28px; height: 28px;
984 padding: 0;
985 border-radius: 8px;
986 background: transparent;
987 border: 1px solid var(--border);
988 color: var(--text-muted);
989 cursor: pointer;
990 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude991 }
398a10cClaude992 .commits-row-copy:hover {
993 border-color: var(--border-strong);
994 color: var(--text);
995 background: rgba(255,255,255,0.04);
996 }
997 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude998 /* Push Watch link — eye icon next to the SHA chip */
999 .commits-row-watch {
1000 display: inline-flex;
1001 align-items: center;
1002 justify-content: center;
1003 width: 28px;
1004 height: 28px;
1005 border-radius: 6px;
1006 border: 1px solid var(--border);
1007 color: var(--text-muted);
1008 background: transparent;
1009 cursor: pointer;
1010 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1011 text-decoration: none !important;
1012 }
1013 .commits-row-watch:hover {
1014 border-color: rgba(140,109,255,0.45);
1015 color: var(--accent);
1016 background: rgba(140,109,255,0.08);
1017 text-decoration: none !important;
1018 }
efb11c5Claude1019 .commits-empty {
398a10cClaude1020 position: relative;
1021 overflow: hidden;
1022 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1023 text-align: center;
398a10cClaude1024 background: var(--bg-elevated);
1025 border: 1px dashed var(--border-strong);
1026 border-radius: 16px;
1027 }
1028 .commits-empty-orb {
1029 position: absolute;
1030 inset: -40% 30% auto 30%;
1031 height: 280px;
1032 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
1033 filter: blur(70px);
1034 opacity: 0.7;
1035 pointer-events: none;
1036 z-index: 0;
1037 }
1038 .commits-empty-inner { position: relative; z-index: 1; }
1039 .commits-empty-icon {
1040 width: 56px; height: 56px;
1041 border-radius: 9999px;
1042 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
1043 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
1044 display: inline-flex;
1045 align-items: center;
1046 justify-content: center;
1047 color: #c4b5fd;
1048 margin: 0 auto 14px;
1049 }
1050 .commits-empty-title {
1051 font-family: var(--font-display);
1052 font-size: 18px;
1053 font-weight: 700;
1054 margin: 0 0 6px;
1055 color: var(--text-strong);
1056 }
1057 .commits-empty-sub {
1058 margin: 0 auto 0;
1059 font-size: 13.5px;
efb11c5Claude1060 color: var(--text-muted);
398a10cClaude1061 max-width: 420px;
1062 line-height: 1.5;
1063 }
1064
1065 /* ───────── branches list ───────── */
1066 .branches-list {
efb11c5Claude1067 background: var(--bg-elevated);
398a10cClaude1068 border: 1px solid var(--border);
1069 border-radius: 14px;
1070 overflow: hidden;
1071 position: relative;
1072 }
1073 .branches-list::before {
1074 content: '';
1075 position: absolute;
1076 top: 0; left: 0; right: 0;
1077 height: 2px;
1078 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1079 opacity: 0.55;
1080 pointer-events: none;
1081 }
1082 .branches-row {
1083 display: flex;
1084 align-items: center;
1085 gap: var(--space-3);
1086 padding: 14px 18px;
1087 border-bottom: 1px solid var(--border-subtle);
1088 transition: background 120ms ease;
1089 flex-wrap: wrap;
1090 }
1091 .branches-row:last-child { border-bottom: none; }
1092 .branches-row:hover { background: rgba(255,255,255,0.022); }
1093 .branches-row-icon {
1094 width: 32px; height: 32px;
1095 border-radius: 8px;
1096 background: rgba(140,109,255,0.10);
1097 color: #c4b5fd;
1098 display: inline-flex;
1099 align-items: center;
1100 justify-content: center;
1101 flex-shrink: 0;
1102 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1103 }
1104 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1105 .branches-row-name {
1106 display: inline-flex;
1107 align-items: center;
1108 gap: 8px;
1109 flex-wrap: wrap;
1110 }
1111 .branches-row-name a {
1112 font-family: var(--font-mono);
1113 font-size: 13.5px;
1114 color: var(--text-strong);
1115 font-weight: 600;
1116 text-decoration: none;
1117 }
1118 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1119 .branches-row-default {
1120 font-size: 10px;
1121 padding: 2px 8px;
1122 border-radius: 9999px;
1123 background: rgba(140,109,255,0.14);
1124 color: #c4b5fd;
1125 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1126 text-transform: uppercase;
1127 letter-spacing: 0.08em;
1128 font-weight: 700;
1129 font-family: var(--font-mono);
1130 }
1131 .branches-row-meta {
1132 display: flex;
1133 align-items: center;
1134 gap: 8px;
1135 flex-wrap: wrap;
1136 font-size: 12.5px;
1137 color: var(--text-muted);
1138 font-variant-numeric: tabular-nums;
1139 }
1140 .branches-row-meta .sep { opacity: 0.4; }
1141 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1142 .branches-row-side {
1143 display: flex;
1144 align-items: center;
1145 gap: 10px;
1146 flex-shrink: 0;
1147 flex-wrap: wrap;
1148 }
1149 .branches-row-divergence {
1150 display: inline-flex;
1151 align-items: center;
1152 gap: 8px;
1153 font-family: var(--font-mono);
1154 font-size: 11.5px;
1155 color: var(--text-muted);
1156 padding: 4px 10px;
1157 border-radius: 9999px;
1158 background: rgba(255,255,255,0.035);
1159 border: 1px solid var(--border);
1160 font-variant-numeric: tabular-nums;
1161 }
1162 .branches-row-divergence .ahead { color: #6ee7b7; }
1163 .branches-row-divergence .behind { color: #fca5a5; }
1164 .branches-row-actions {
1165 display: flex;
1166 align-items: center;
1167 gap: 6px;
1168 }
1169 .branches-btn {
1170 display: inline-flex;
1171 align-items: center;
1172 gap: 5px;
1173 padding: 6px 12px;
1174 border-radius: 9999px;
1175 font-size: 12px;
1176 font-weight: 600;
1177 text-decoration: none;
1178 border: 1px solid var(--border);
1179 background: transparent;
1180 color: var(--text-muted);
1181 cursor: pointer;
1182 font: inherit;
1183 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1184 }
1185 .branches-btn:hover {
1186 border-color: var(--border-strong);
1187 color: var(--text);
1188 background: rgba(255,255,255,0.04);
1189 text-decoration: none;
1190 }
1191 .branches-btn-danger {
1192 color: #fca5a5;
1193 border-color: rgba(248,113,113,0.30);
1194 }
1195 .branches-btn-danger:hover {
1196 border-style: dashed;
1197 border-color: rgba(248,113,113,0.65);
1198 background: rgba(248,113,113,0.06);
1199 color: #fecaca;
1200 }
1201
1202 /* ───────── tags list ───────── */
1203 .tags-list {
1204 background: var(--bg-elevated);
1205 border: 1px solid var(--border);
1206 border-radius: 14px;
1207 overflow: hidden;
1208 position: relative;
1209 }
1210 .tags-list::before {
1211 content: '';
1212 position: absolute;
1213 top: 0; left: 0; right: 0;
1214 height: 2px;
1215 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1216 opacity: 0.55;
1217 pointer-events: none;
1218 }
1219 .tags-row {
1220 display: flex;
1221 align-items: center;
1222 gap: var(--space-3);
1223 padding: 14px 18px;
1224 border-bottom: 1px solid var(--border-subtle);
1225 transition: background 120ms ease;
1226 flex-wrap: wrap;
1227 }
1228 .tags-row:last-child { border-bottom: none; }
1229 .tags-row:hover { background: rgba(255,255,255,0.022); }
1230 .tags-row-icon {
1231 width: 32px; height: 32px;
1232 border-radius: 8px;
1233 background: rgba(54,197,214,0.10);
1234 color: #67e8f9;
1235 display: inline-flex;
1236 align-items: center;
1237 justify-content: center;
1238 flex-shrink: 0;
1239 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1240 }
1241 .tags-row-main { flex: 1; min-width: 240px; }
1242 .tags-row-name {
1243 display: inline-flex;
1244 align-items: center;
1245 gap: 8px;
1246 flex-wrap: wrap;
1247 }
1248 .tags-row-version {
1249 display: inline-flex;
1250 align-items: center;
1251 padding: 3px 11px;
1252 border-radius: 9999px;
1253 font-family: var(--font-mono);
1254 font-size: 13px;
1255 font-weight: 700;
1256 color: #67e8f9;
1257 background: rgba(54,197,214,0.12);
1258 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1259 letter-spacing: 0.01em;
1260 }
1261 .tags-row-meta {
1262 margin-top: 4px;
1263 display: flex;
1264 align-items: center;
1265 gap: 8px;
1266 flex-wrap: wrap;
1267 font-size: 12.5px;
1268 color: var(--text-muted);
1269 font-variant-numeric: tabular-nums;
1270 }
1271 .tags-row-meta .sep { opacity: 0.4; }
1272 .tags-row-sha {
1273 font-family: var(--font-mono);
1274 font-size: 11.5px;
1275 color: var(--text-strong);
1276 padding: 2px 8px;
1277 border-radius: 6px;
1278 background: rgba(255,255,255,0.04);
1279 border: 1px solid var(--border);
1280 text-decoration: none;
1281 letter-spacing: 0.04em;
1282 }
1283 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1284 .tags-row-side {
1285 display: flex;
1286 align-items: center;
1287 gap: 6px;
1288 flex-shrink: 0;
1289 flex-wrap: wrap;
1290 }
1291 .tags-row-link {
1292 display: inline-flex;
1293 align-items: center;
1294 gap: 5px;
1295 padding: 6px 12px;
1296 border-radius: 9999px;
1297 font-size: 12px;
1298 font-weight: 600;
1299 text-decoration: none;
1300 color: var(--text-muted);
1301 background: rgba(255,255,255,0.025);
1302 border: 1px solid var(--border);
1303 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1304 }
1305 .tags-row-link:hover {
1306 border-color: rgba(140,109,255,0.45);
1307 color: var(--text-strong);
1308 background: rgba(140,109,255,0.06);
1309 text-decoration: none;
efb11c5Claude1310 }
1311
1312 /* ───────── commit detail ───────── */
1313 .commit-detail-card {
1314 position: relative;
1315 margin-bottom: var(--space-5);
1316 padding: var(--space-5) var(--space-5);
1317 background: var(--bg-elevated);
1318 border: 1px solid var(--border);
1319 border-radius: 14px;
1320 overflow: hidden;
1321 }
1322 .commit-detail-eyebrow {
1323 display: flex;
1324 align-items: center;
1325 gap: var(--space-2);
1326 font-size: 12px;
1327 font-family: var(--font-mono);
1328 text-transform: uppercase;
1329 letter-spacing: 0.1em;
1330 color: var(--text-muted);
1331 margin-bottom: var(--space-2);
1332 }
1333 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1334 .commit-detail-sha-pill {
1335 font-family: var(--font-mono);
1336 font-size: 11.5px;
1337 color: var(--text-strong);
1338 background: var(--bg-secondary);
1339 border: 1px solid var(--border);
1340 border-radius: 999px;
1341 padding: 2px 10px;
1342 letter-spacing: 0.04em;
1343 }
1344 .commit-detail-verify {
1345 font-size: 10px;
1346 padding: 2px 8px;
1347 border-radius: 999px;
1348 text-transform: uppercase;
1349 letter-spacing: 0.06em;
1350 font-weight: 700;
1351 color: #fff;
1352 }
1353 .commit-detail-verify-ok {
1354 background: linear-gradient(135deg, #2ea043, #34d399);
1355 }
1356 .commit-detail-verify-warn {
1357 background: linear-gradient(135deg, #d29922, #f59e0b);
1358 }
1359 .commit-detail-title {
1360 font-family: var(--font-display);
1361 font-weight: 700;
1362 letter-spacing: -0.018em;
1363 font-size: clamp(20px, 2.5vw, 26px);
1364 line-height: 1.25;
1365 margin: 0 0 var(--space-2);
1366 color: var(--text-strong);
1367 }
1368 .commit-detail-body {
1369 white-space: pre-wrap;
1370 color: var(--text-muted);
1371 font-size: 14px;
1372 line-height: 1.55;
1373 margin: 0 0 var(--space-3);
1374 font-family: var(--font-mono);
1375 background: var(--bg);
1376 border: 1px solid var(--border);
1377 border-radius: 8px;
1378 padding: var(--space-3) var(--space-4);
1379 max-height: 280px;
1380 overflow: auto;
1381 }
1382 .commit-detail-meta {
1383 display: flex;
1384 flex-wrap: wrap;
1385 gap: var(--space-3);
1386 font-size: 13px;
1387 color: var(--text-muted);
1388 margin-bottom: var(--space-3);
1389 }
1390 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1391 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1392 .commit-detail-sha-link {
1393 font-family: var(--font-mono);
1394 font-size: 12.5px;
1395 color: var(--accent);
1396 background: rgba(140,109,255,0.08);
1397 border-radius: 6px;
1398 padding: 1px 6px;
1399 margin-left: 2px;
1400 }
1401 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1402 .commit-detail-stats {
1403 display: flex;
1404 flex-wrap: wrap;
1405 align-items: center;
1406 gap: var(--space-3);
1407 padding-top: var(--space-3);
1408 border-top: 1px solid var(--border);
1409 font-size: 13px;
1410 color: var(--text-muted);
1411 }
1412 .commit-detail-stat {
1413 display: inline-flex;
1414 align-items: center;
1415 gap: 4px;
1416 font-variant-numeric: tabular-nums;
1417 }
1418 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1419 .commit-detail-stat-add strong { color: #34d399; }
1420 .commit-detail-stat-del strong { color: #f87171; }
1421 .commit-detail-stat-mark {
1422 font-family: var(--font-mono);
1423 font-weight: 700;
1424 font-size: 14px;
1425 }
1426 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1427 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1428 .commit-detail-sha-full {
1429 margin-left: auto;
1430 font-family: var(--font-mono);
1431 font-size: 11.5px;
1432 color: var(--text-faint);
1433 letter-spacing: 0.02em;
1434 overflow: hidden;
1435 text-overflow: ellipsis;
1436 white-space: nowrap;
1437 max-width: 100%;
1438 }
1439 .commit-detail-checks {
1440 margin-top: var(--space-3);
1441 padding-top: var(--space-3);
1442 border-top: 1px solid var(--border);
1443 }
1444 .commit-detail-checks-head {
1445 display: flex;
1446 align-items: center;
1447 gap: var(--space-2);
1448 font-size: 13px;
1449 color: var(--text-muted);
1450 margin-bottom: 8px;
1451 }
1452 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1453 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1454 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1455 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1456 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1457 .commit-detail-check {
1458 font-size: 11px;
1459 padding: 2px 8px;
1460 border-radius: 999px;
1461 color: #fff;
1462 font-weight: 500;
1463 }
398a10cClaude1464 .commit-detail-check a { color: inherit; text-decoration: none; }
1465 .commit-detail-check-success { background: #2ea043; }
1466 .commit-detail-check-pending { background: #d29922; }
1467 .commit-detail-check-failure { background: #da3633; }
1468
1469 /* ───────── blame ───────── */
1470 .blame-head { margin-bottom: var(--space-5); }
1471 .blame-eyebrow {
1472 display: inline-flex;
1473 align-items: center;
1474 gap: 8px;
1475 text-transform: uppercase;
1476 font-family: var(--font-mono);
1477 font-size: 11px;
1478 letter-spacing: 0.16em;
1479 color: var(--text-muted);
1480 font-weight: 600;
1481 margin-bottom: 10px;
1482 }
1483 .blame-eyebrow-dot {
1484 width: 8px; height: 8px;
1485 border-radius: 9999px;
1486 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1487 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1488 }
1489 .blame-title {
1490 font-family: var(--font-display);
1491 font-size: clamp(22px, 3vw, 30px);
1492 font-weight: 800;
1493 letter-spacing: -0.025em;
1494 line-height: 1.15;
1495 margin: 0 0 6px;
1496 color: var(--text-strong);
1497 }
1498 .blame-title code {
1499 font-family: var(--font-mono);
1500 font-size: 0.78em;
1501 color: var(--text-strong);
1502 font-weight: 700;
1503 }
1504 .blame-sub {
1505 margin: 0;
1506 font-size: 14px;
1507 color: var(--text-muted);
1508 line-height: 1.5;
1509 max-width: 700px;
1510 }
efb11c5Claude1511 .blame-toolbar {
398a10cClaude1512 display: flex;
1513 justify-content: space-between;
1514 align-items: center;
1515 gap: var(--space-3);
efb11c5Claude1516 margin-bottom: var(--space-3);
398a10cClaude1517 flex-wrap: wrap;
efb11c5Claude1518 font-size: 13px;
1519 }
398a10cClaude1520 .blame-toolbar-actions { display: flex; gap: 6px; }
1521 .blame-card {
1522 background: var(--bg-elevated);
1523 border: 1px solid var(--border);
1524 border-radius: 14px;
1525 overflow: hidden;
1526 position: relative;
1527 }
1528 .blame-card::before {
1529 content: '';
1530 position: absolute;
1531 top: 0; left: 0; right: 0;
1532 height: 2px;
1533 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1534 opacity: 0.55;
1535 pointer-events: none;
1536 }
efb11c5Claude1537 .blame-header {
1538 display: flex;
1539 align-items: center;
1540 justify-content: space-between;
1541 gap: var(--space-3);
1542 padding: 10px 14px;
1543 background: var(--bg-secondary);
1544 border-bottom: 1px solid var(--border);
1545 }
1546 .blame-header-meta {
1547 display: flex;
1548 align-items: center;
1549 gap: var(--space-2);
1550 min-width: 0;
1551 flex-wrap: wrap;
1552 }
1553 .blame-header-icon { color: var(--accent); font-size: 14px; }
1554 .blame-header-name {
1555 font-family: var(--font-mono);
1556 font-size: 13px;
1557 color: var(--text-strong);
1558 font-weight: 600;
1559 }
1560 .blame-header-tag {
1561 font-size: 10.5px;
1562 text-transform: uppercase;
1563 letter-spacing: 0.08em;
1564 font-family: var(--font-mono);
1565 background: rgba(140,109,255,0.12);
1566 color: var(--accent);
1567 border-radius: 999px;
1568 padding: 2px 8px;
1569 font-weight: 600;
1570 }
1571 .blame-header-stats {
1572 font-family: var(--font-mono);
1573 font-size: 11.5px;
1574 color: var(--text-muted);
1575 font-variant-numeric: tabular-nums;
1576 border-left: 1px solid var(--border);
1577 padding-left: var(--space-2);
1578 }
1579 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1580 .blame-table {
1581 width: 100%;
1582 border-collapse: collapse;
1583 font-family: var(--font-mono);
1584 font-size: 12.5px;
1585 line-height: 1.6;
1586 }
1587 .blame-table tr { border-bottom: 1px solid transparent; }
1588 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1589 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1590 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1591 .blame-gutter {
1592 width: 220px;
1593 min-width: 220px;
1594 padding: 0 12px;
1595 vertical-align: top;
1596 background: rgba(255,255,255,0.012);
1597 border-right: 1px solid var(--border-subtle);
1598 font-variant-numeric: tabular-nums;
1599 color: var(--text-muted);
1600 font-size: 11px;
1601 white-space: nowrap;
1602 overflow: hidden;
1603 text-overflow: ellipsis;
1604 padding-top: 2px;
1605 padding-bottom: 2px;
1606 }
1607 .blame-gutter-inner {
1608 display: inline-flex;
1609 align-items: center;
1610 gap: 7px;
1611 max-width: 100%;
1612 overflow: hidden;
1613 }
1614 .blame-gutter-sha {
1615 font-family: var(--font-mono);
1616 font-size: 10.5px;
1617 font-weight: 600;
1618 color: #c4b5fd;
1619 background: rgba(140,109,255,0.10);
1620 padding: 1px 7px;
1621 border-radius: 9999px;
1622 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1623 text-decoration: none;
1624 letter-spacing: 0.04em;
1625 flex-shrink: 0;
1626 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1627 }
1628 .blame-gutter-sha:hover {
1629 background: rgba(140,109,255,0.22);
1630 color: #ddd6fe;
1631 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1632 text-decoration: none;
1633 }
1634 .blame-gutter-author {
1635 color: var(--text);
1636 overflow: hidden;
1637 text-overflow: ellipsis;
1638 white-space: nowrap;
1639 font-size: 11px;
1640 font-family: var(--font-sans, inherit);
1641 }
1642 .blame-line-num {
1643 width: 1%;
1644 min-width: 50px;
1645 padding: 0 12px;
1646 text-align: right;
1647 color: var(--text-faint);
1648 user-select: none;
1649 border-right: 1px solid var(--border-subtle);
1650 font-variant-numeric: tabular-nums;
1651 }
1652 .blame-line-content {
1653 padding: 0 14px;
1654 white-space: pre;
1655 color: var(--text);
1656 transition: background 120ms ease;
1657 }
efb11c5Claude1658
1659 /* ───────── search ───────── */
1660 .search-hero {
1661 position: relative;
1662 margin-bottom: var(--space-4);
1663 padding: var(--space-5) var(--space-6);
1664 background: var(--bg-elevated);
1665 border: 1px solid var(--border);
1666 border-radius: 16px;
1667 overflow: hidden;
1668 }
1669 .search-eyebrow {
1670 font-size: 12px;
1671 font-family: var(--font-mono);
1672 color: var(--text-muted);
1673 letter-spacing: 0.1em;
1674 text-transform: uppercase;
1675 margin-bottom: var(--space-2);
1676 }
1677 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1678 .search-title {
1679 font-family: var(--font-display);
1680 font-weight: 800;
1681 letter-spacing: -0.025em;
1682 font-size: clamp(22px, 3vw, 30px);
1683 line-height: 1.15;
1684 margin: 0 0 var(--space-3);
1685 color: var(--text-strong);
1686 }
1687 .search-form {
1688 display: flex;
1689 gap: var(--space-2);
1690 align-items: stretch;
1691 }
1692 .search-input-wrap {
1693 position: relative;
1694 flex: 1;
1695 display: flex;
1696 align-items: center;
1697 }
1698 .search-input-icon {
1699 position: absolute;
1700 left: 12px;
1701 color: var(--text-muted);
1702 font-size: 15px;
1703 pointer-events: none;
1704 }
1705 .search-input {
1706 appearance: none;
1707 width: 100%;
1708 padding: 10px 12px 10px 34px;
1709 background: var(--bg);
1710 border: 1px solid var(--border);
1711 border-radius: 10px;
1712 color: var(--text-strong);
1713 font-size: 14px;
1714 font-family: inherit;
1715 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1716 }
1717 .search-input:focus {
1718 outline: none;
1719 border-color: var(--accent);
1720 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1721 }
1722 .search-submit { min-width: 96px; }
1723 .search-results-head {
1724 display: flex;
1725 align-items: baseline;
1726 gap: var(--space-2);
1727 margin-bottom: var(--space-3);
1728 font-size: 13px;
1729 color: var(--text-muted);
1730 }
1731 .search-results-count strong {
1732 color: var(--text-strong);
1733 font-weight: 600;
1734 font-variant-numeric: tabular-nums;
1735 }
1736 .search-results-q { color: var(--text-strong); font-weight: 600; }
1737 .search-results-head code {
1738 font-family: var(--font-mono);
1739 font-size: 12px;
1740 background: var(--bg-secondary);
1741 border: 1px solid var(--border);
1742 border-radius: 5px;
1743 padding: 1px 6px;
1744 color: var(--text);
1745 }
1746 .search-empty {
1747 padding: var(--space-5) var(--space-6);
1748 background: var(--bg-elevated);
1749 border: 1px dashed var(--border);
1750 border-radius: 12px;
1751 color: var(--text-muted);
1752 font-size: 14px;
1753 }
1754 .search-empty strong { color: var(--text-strong); }
1755 .search-results {
1756 display: flex;
1757 flex-direction: column;
1758 gap: var(--space-3);
1759 }
1760 .search-file-head {
1761 display: flex;
1762 align-items: center;
1763 justify-content: space-between;
1764 gap: var(--space-2);
1765 }
1766 .search-file-link {
1767 font-family: var(--font-mono);
1768 font-size: 13px;
1769 color: var(--text-strong);
1770 font-weight: 600;
1771 }
1772 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1773 .search-file-count {
1774 font-family: var(--font-mono);
1775 font-size: 11.5px;
1776 color: var(--text-muted);
1777 font-variant-numeric: tabular-nums;
1778 }
1779`;
1780
fc1817aClaude1781// Home page
06d5ffeClaude1782web.get("/", async (c) => {
1783 const user = c.get("user");
1784
1785 if (user) {
0316dbbClaude1786 return c.redirect("/dashboard");
06d5ffeClaude1787 }
1788
8e9f1d9Claude1789 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1790 let publicStats: PublicStats | null = null;
8e9f1d9Claude1791 try {
1792 const [repoRow] = await db
1793 .select({ n: sql<number>`count(*)::int` })
1794 .from(repositories)
1795 .where(eq(repositories.isPrivate, false));
1796 const [userRow] = await db
1797 .select({ n: sql<number>`count(*)::int` })
1798 .from(users);
1799 stats = {
1800 publicRepos: Number(repoRow?.n ?? 0),
1801 users: Number(userRow?.n ?? 0),
1802 };
1803 } catch {
1804 stats = undefined;
1805 }
1806
52ad8b1Claude1807 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1808 try {
1809 publicStats = await computePublicStats();
1810 } catch {
1811 publicStats = null;
1812 }
1813
534f04aClaude1814 // Block M1 — initial SSR snapshot for the live-now demo feed.
1815 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1816 // in try/catch so a freak module-level explosion can't take down /.
1817 let liveFeed: LandingLiveFeed | null = null;
1818 try {
1819 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1820 listQueuedAiBuildIssues(3),
1821 listRecentAutoMerges(3, 24),
1822 listRecentAiReviews(3, 24),
1823 countAiReviewsSince(24),
1824 listDemoActivityFeed(10),
1825 ]);
1826 liveFeed = {
1827 queued: queued.map((i) => ({
1828 repo: i.repo,
1829 number: i.number,
1830 title: i.title,
1831 createdAt: i.createdAt,
1832 })),
1833 merges: merges.map((m) => ({
1834 repo: m.repo,
1835 number: m.number,
1836 title: m.title,
1837 mergedAt: m.mergedAt,
1838 })),
1839 reviews: reviewList.map((r) => ({
1840 repo: r.repo,
1841 prNumber: r.prNumber,
1842 commentSnippet: r.commentSnippet,
1843 createdAt: r.createdAt,
1844 })),
1845 reviewCount,
1846 feed: feed.map((e) => ({
1847 kind: e.kind,
1848 repo: e.repo,
1849 ref: e.ref,
1850 at: e.at,
1851 })),
1852 };
1853 } catch {
1854 liveFeed = null;
1855 }
1856
29924bcClaude1857 // 2030 reboot — the public landing is a self-contained light marketing
1858 // document (its own shell + design system), rendered directly so it never
1859 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1860 // above for the legacy LandingPage and other surfaces; the new page uses the
1861 // headline counters from `stats`.
1862 void publicStats;
1863 void liveFeed;
1864 void LandingPage;
1865 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1866});
1867
06d5ffeClaude1868// New repository form
1869web.get("/new", requireAuth, (c) => {
1870 const user = c.get("user")!;
1871 const error = c.req.query("error");
1872
1873 return c.html(
1874 <Layout title="New repository" user={user}>
efb11c5Claude1875 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1876 <div class="new-repo-hero">
1877 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1878 <div class="new-repo-hero-orb" />
1879 </div>
1880 <div class="new-repo-hero-inner">
1881 <div class="new-repo-eyebrow">
1882 <strong>Create</strong> · {user.username}
1883 </div>
1884 <h1 class="new-repo-title">
1885 Spin up a <span class="gradient-text">repository</span>.
1886 </h1>
1887 <p class="new-repo-sub">
1888 Push your first commit, and Gluecron wires up gate checks, AI review,
1889 and auto-merge from the moment your branch lands.
1890 </p>
1891 </div>
1892 </div>
06d5ffeClaude1893 <div class="new-repo-form">
efb11c5Claude1894 {error && (
1895 <div class="new-repo-error" role="alert">
1896 {decodeURIComponent(error)}
1897 </div>
1898 )}
1899 <form method="post" action="/new" class="new-repo-form-grid">
1900 <div class="new-repo-row">
1901 <label class="new-repo-label">Owner</label>
1902 <input
1903 type="text"
1904 value={user.username}
1905 disabled
1906 aria-label="Owner"
1907 class="new-repo-input new-repo-input-disabled"
1908 />
06d5ffeClaude1909 </div>
efb11c5Claude1910 <div class="new-repo-row">
1911 <label class="new-repo-label" for="name">
1912 Repository name
1913 </label>
06d5ffeClaude1914 <input
1915 type="text"
1916 id="name"
1917 name="name"
1918 required
1919 pattern="^[a-zA-Z0-9._-]+$"
1920 placeholder="my-project"
1921 autocomplete="off"
efb11c5Claude1922 class="new-repo-input"
06d5ffeClaude1923 />
efb11c5Claude1924 <p class="new-repo-hint">
1925 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1926 <code>{user.username}/&lt;name&gt;</code>.
1927 </p>
06d5ffeClaude1928 </div>
efb11c5Claude1929 <div class="new-repo-row">
1930 <label class="new-repo-label" for="description">
1931 Description{" "}
1932 <span class="new-repo-label-optional">(optional)</span>
1933 </label>
06d5ffeClaude1934 <input
1935 type="text"
1936 id="description"
1937 name="description"
1938 placeholder="A short description of your repository"
efb11c5Claude1939 class="new-repo-input"
06d5ffeClaude1940 />
1941 </div>
efb11c5Claude1942 <div class="new-repo-row">
1943 <span class="new-repo-label">Visibility</span>
1944 <div class="new-repo-visibility">
1945 <label class="new-repo-vis-card">
1946 <input
1947 type="radio"
1948 name="visibility"
1949 value="public"
1950 checked
1951 class="new-repo-vis-radio"
1952 />
1953 <span class="new-repo-vis-body">
1954 <span class="new-repo-vis-label">Public</span>
1955 <span class="new-repo-vis-desc">
1956 Anyone can see this repository. You choose who can commit.
1957 </span>
1958 </span>
1959 </label>
1960 <label class="new-repo-vis-card">
1961 <input
1962 type="radio"
1963 name="visibility"
1964 value="private"
1965 class="new-repo-vis-radio"
1966 />
1967 <span class="new-repo-vis-body">
1968 <span class="new-repo-vis-label">Private</span>
1969 <span class="new-repo-vis-desc">
1970 Only you (and collaborators you invite) can see this
1971 repository.
1972 </span>
1973 </span>
1974 </label>
1975 </div>
1976 </div>
398a10cClaude1977 <div class="new-repo-row">
1978 <span class="new-repo-label">
1979 Starter content{" "}
1980 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1981 </span>
1982 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1983 <label class="new-repo-template-chip">
1984 <input type="radio" name="starter" value="empty" checked />
1985 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1986 Empty
1987 </label>
1988 <label class="new-repo-template-chip">
1989 <input type="radio" name="starter" value="readme" />
1990 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1991 README
1992 </label>
1993 <label class="new-repo-template-chip">
1994 <input type="radio" name="starter" value="readme-mit" />
1995 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1996 README + MIT
1997 </label>
1998 <label class="new-repo-template-chip">
1999 <input type="radio" name="starter" value="node" />
2000 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2001 Node + .gitignore
2002 </label>
2003 </div>
2004 <p class="new-repo-hint">
2005 Just a UI hint — push your own commits to fill the repo.
2006 </p>
2007 </div>
44f1a02Claude2008 <div class="new-repo-row">
2009 <label class="new-repo-label" for="data_region">
2010 Data region
2011 </label>
2012 <select
2013 id="data_region"
2014 name="data_region"
2015 class="new-repo-input"
2016 style="cursor: pointer;"
2017 >
2018 <option value="us" selected>US (default)</option>
2019 <option value="eu">EU (Frankfurt)</option>
2020 </select>
2021 <p class="new-repo-hint">
2022 EU data residency requires a{" "}
2023 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2024 Pro plan or higher
2025 </a>
2026 . Repositories cannot be moved between regions after creation.
2027 </p>
2028 </div>
efb11c5Claude2029 <div class="new-repo-callout">
2030 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2031 <p class="new-repo-callout-body">
2032 Every push is gate-checked and reviewed by Claude automatically.
2033 Label an issue <code>ai-build</code> and Gluecron will open the PR
2034 for you.
2035 </p>
2036 </div>
2037 <div class="new-repo-actions">
398a10cClaude2038 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2039 Create repository
2040 </button>
2041 <a href="/dashboard" class="btn new-repo-cancel">
2042 Cancel
2043 </a>
06d5ffeClaude2044 </div>
2045 </form>
2046 </div>
2047 </Layout>
2048 );
2049});
2050
2051web.post("/new", requireAuth, async (c) => {
2052 const user = c.get("user")!;
2053 const body = await c.req.parseBody();
2054 const name = String(body.name || "").trim();
2055 const description = String(body.description || "").trim();
2056 const isPrivate = body.visibility === "private";
44f1a02Claude2057 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2058
2059 if (!name) {
2060 return c.redirect("/new?error=Repository+name+is+required");
2061 }
2062
c63b860Claude2063 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2064 // outage never blocks repo creation.
2065 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2066 const gate = await checkRepoCreateAllowed(user.id);
2067 if (!gate.ok) {
2068 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2069 }
2070
06d5ffeClaude2071 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2072 return c.redirect("/new?error=Invalid+repository+name");
2073 }
2074
2075 if (await repoExists(user.username, name)) {
2076 return c.redirect("/new?error=Repository+already+exists");
2077 }
2078
2079 const diskPath = await initBareRepo(user.username, name);
2080
3ef4c9dClaude2081 const [newRepo] = await db
2082 .insert(repositories)
2083 .values({
2084 name,
2085 ownerId: user.id,
2086 description: description || null,
2087 isPrivate,
2088 diskPath,
44f1a02Claude2089 dataRegion,
3ef4c9dClaude2090 })
2091 .returning();
2092
2093 if (newRepo) {
2094 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2095 await bootstrapRepository({
2096 repositoryId: newRepo.id,
2097 ownerUserId: user.id,
2098 defaultBranch: "main",
2099 });
2100 }
06d5ffeClaude2101
2102 return c.redirect(`/${user.username}/${name}`);
2103});
2104
2105// User profile
fc1817aClaude2106web.get("/:owner", async (c) => {
06d5ffeClaude2107 const { owner: ownerName } = c.req.param();
2108 const user = c.get("user");
2109
2110 // Avoid clashing with fixed routes
2111 if (
2112 ["login", "register", "logout", "new", "settings", "api"].includes(
2113 ownerName
2114 )
2115 ) {
2116 return c.notFound();
2117 }
2118
2119 let ownerUser;
2120 try {
2121 const [found] = await db
2122 .select()
2123 .from(users)
2124 .where(eq(users.username, ownerName))
2125 .limit(1);
2126 ownerUser = found;
2127 } catch {
2128 // DB not available — check if repos exist on disk
2129 ownerUser = null;
2130 }
2131
2132 // Even without DB, show repos if they exist on disk
2133 let repos: any[] = [];
2134 if (ownerUser) {
2135 const allRepos = await db
2136 .select()
2137 .from(repositories)
2138 .where(eq(repositories.ownerId, ownerUser.id))
2139 .orderBy(desc(repositories.updatedAt));
2140
2141 // Show public repos to everyone, private only to owner
2142 repos =
2143 user?.id === ownerUser.id
2144 ? allRepos
2145 : allRepos.filter((r) => !r.isPrivate);
2146 }
2147
7aa8b99Claude2148 // Block J4 — follow counts + viewer's follow state
2149 let followState = {
2150 followers: 0,
2151 following: 0,
2152 viewerFollows: false,
2153 };
2154 if (ownerUser) {
2155 try {
2156 const { followCounts, isFollowing } = await import("../lib/follows");
2157 const counts = await followCounts(ownerUser.id);
2158 followState.followers = counts.followers;
2159 followState.following = counts.following;
2160 if (user && user.id !== ownerUser.id) {
2161 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2162 }
2163 } catch {
2164 // DB hiccup — fall back to zeros.
2165 }
2166 }
2167 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2168
d412586Claude2169 // Block J5 — profile README. Render owner/owner repo's README on the
2170 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2171 // back to "<user>/.github" for org-style profile repos.
2172 let profileReadmeHtml: string | null = null;
2173 try {
2174 const candidates = [ownerName, ".github"];
2175 for (const rname of candidates) {
2176 if (await repoExists(ownerName, rname)) {
2177 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2178 const md = await getReadme(ownerName, rname, ref);
2179 if (md) {
2180 profileReadmeHtml = renderMarkdown(md);
2181 break;
2182 }
2183 }
2184 }
2185 } catch {
2186 profileReadmeHtml = null;
2187 }
2188
efb11c5Claude2189 const displayName = ownerUser?.displayName || ownerName;
2190 const memberSince = ownerUser?.createdAt
2191 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2192 : null;
2193
fc1817aClaude2194 return c.html(
06d5ffeClaude2195 <Layout title={ownerName} user={user}>
efb11c5Claude2196 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2197 <div class="profile-hero">
2198 <div class="profile-hero-orb-wrap" aria-hidden="true">
2199 <div class="profile-hero-orb" />
06d5ffeClaude2200 </div>
efb11c5Claude2201 <div class="profile-hero-inner">
2202 <div class="profile-hero-avatar" aria-hidden="true">
2203 {displayName[0].toUpperCase()}
2204 </div>
2205 <div class="profile-hero-text">
2206 <div class="profile-eyebrow">
2207 <strong>Developer</strong>
2208 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2209 <>
2210 {" "}· Joined{" "}
2211 {memberSince.toLocaleDateString("en-US", {
2212 month: "short",
2213 year: "numeric",
2214 })}
2215 </>
2216 )}
2217 </div>
2218 <h1 class="profile-name">
2219 <span class="gradient-text">{displayName}</span>
2220 </h1>
2221 <div class="profile-handle">@{ownerName}</div>
2222 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2223 <div class="profile-meta">
2224 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2225 <strong>{followState.followers}</strong> follower
2226 {followState.followers === 1 ? "" : "s"}
2227 </a>
2228 <a href={`/${ownerName}/following`} class="profile-meta-link">
2229 <strong>{followState.following}</strong> following
2230 </a>
2231 <a href={`/${ownerName}`} class="profile-meta-link">
2232 <strong>{repos.length}</strong> repo
2233 {repos.length === 1 ? "" : "s"}
2234 </a>
2235 {canFollow && (
2236 <form
2237 method="post"
2238 action={`/${ownerName}/${
2239 followState.viewerFollows ? "unfollow" : "follow"
2240 }`}
2241 class="profile-follow-form"
7aa8b99Claude2242 >
efb11c5Claude2243 <button
2244 type="submit"
2245 class={`btn ${
2246 followState.viewerFollows ? "" : "btn-primary"
2247 } btn-sm`}
2248 >
2249 {followState.viewerFollows ? "Unfollow" : "Follow"}
2250 </button>
2251 </form>
2252 )}
2253 </div>
7aa8b99Claude2254 </div>
06d5ffeClaude2255 </div>
2256 </div>
d412586Claude2257 {profileReadmeHtml && (
efb11c5Claude2258 <div class="profile-readme">
2259 <div class="profile-readme-head">
2260 <span class="profile-readme-icon">{"☰"}</span>
2261 <span>{ownerName}/{ownerName} README.md</span>
2262 </div>
2263 <div
2264 class="markdown-body profile-readme-body"
2265 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2266 />
2267 </div>
d412586Claude2268 )}
efb11c5Claude2269 <div class="profile-section-head">
2270 <h2 class="profile-section-title">Repositories</h2>
2271 <span class="profile-section-count">{repos.length}</span>
2272 </div>
06d5ffeClaude2273 {repos.length === 0 ? (
efb11c5Claude2274 <div class="profile-empty">
2275 <p class="profile-empty-text">
2276 No repositories yet
2277 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2278 </p>
2279 {user?.id === ownerUser?.id && (
2280 <a href="/new" class="btn btn-primary btn-sm">
2281 + Create your first
2282 </a>
2283 )}
2284 </div>
06d5ffeClaude2285 ) : (
2286 <div class="card-grid">
2287 {repos.map((repo) => (
2288 <RepoCard repo={repo} ownerName={ownerName} />
2289 ))}
2290 </div>
2291 )}
fc1817aClaude2292 </Layout>
2293 );
2294});
2295
06d5ffeClaude2296// Star/unstar a repo
2297web.post("/:owner/:repo/star", requireAuth, async (c) => {
2298 const { owner: ownerName, repo: repoName } = c.req.param();
2299 const user = c.get("user")!;
2300
2301 try {
2302 const [ownerUser] = await db
2303 .select()
2304 .from(users)
2305 .where(eq(users.username, ownerName))
2306 .limit(1);
2307 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2308
2309 const [repo] = await db
2310 .select()
2311 .from(repositories)
2312 .where(
2313 and(
2314 eq(repositories.ownerId, ownerUser.id),
2315 eq(repositories.name, repoName)
2316 )
2317 )
2318 .limit(1);
2319 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2320
2321 // Toggle star
2322 const [existing] = await db
2323 .select()
2324 .from(stars)
2325 .where(
2326 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2327 )
2328 .limit(1);
2329
2330 if (existing) {
2331 await db.delete(stars).where(eq(stars.id, existing.id));
2332 await db
2333 .update(repositories)
2334 .set({ starCount: Math.max(0, repo.starCount - 1) })
2335 .where(eq(repositories.id, repo.id));
2336 } else {
2337 await db.insert(stars).values({
2338 userId: user.id,
2339 repositoryId: repo.id,
2340 });
2341 await db
2342 .update(repositories)
2343 .set({ starCount: repo.starCount + 1 })
2344 .where(eq(repositories.id, repo.id));
2345 }
2346 } catch {
2347 // DB error — ignore
2348 }
2349
2350 return c.redirect(`/${ownerName}/${repoName}`);
2351});
2352
641aa42Claude2353// ---------------------------------------------------------------------------
2354// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2355// ---------------------------------------------------------------------------
2356const obCss = `
2357 .ob-card {
2358 position: relative;
2359 margin: 14px 0 18px;
2360 background: linear-gradient(135deg, rgba(140,109,255,0.08), rgba(54,197,214,0.05));
2361 border: 1px solid rgba(140,109,255,0.30);
2362 border-radius: 14px;
2363 overflow: hidden;
2364 }
2365 .ob-card::before {
2366 content: '';
2367 position: absolute;
2368 top: 0; left: 0; right: 0;
2369 height: 2px;
2370 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2371 pointer-events: none;
2372 }
2373 .ob-header {
2374 padding: 16px 20px 10px;
2375 border-bottom: 1px solid var(--border);
2376 }
2377 .ob-header h3 {
2378 font-size: 16px;
2379 font-weight: 700;
2380 margin: 0 0 4px;
2381 color: var(--text-strong);
2382 }
2383 .ob-header p {
2384 font-size: 13px;
2385 color: var(--text-muted);
2386 margin: 0;
2387 }
2388 .ob-sections {
2389 display: grid;
2390 grid-template-columns: repeat(3, 1fr);
2391 gap: 0;
2392 }
2393 @media (max-width: 760px) {
2394 .ob-sections { grid-template-columns: 1fr; }
2395 }
2396 .ob-section {
2397 padding: 14px 20px;
2398 border-right: 1px solid var(--border);
2399 }
2400 .ob-section:last-child { border-right: none; }
2401 .ob-section h4 {
2402 font-size: 12px;
2403 font-weight: 700;
2404 text-transform: uppercase;
2405 letter-spacing: 0.08em;
2406 color: var(--accent);
2407 margin: 0 0 8px;
2408 }
2409 .ob-preview {
2410 font-family: var(--font-mono);
2411 font-size: 11.5px;
2412 background: var(--bg);
2413 border: 1px solid var(--border);
2414 border-radius: 6px;
2415 padding: 8px 10px;
2416 color: var(--text-muted);
2417 line-height: 1.55;
2418 margin-bottom: 8px;
2419 white-space: pre-wrap;
2420 overflow: hidden;
2421 max-height: 80px;
2422 position: relative;
2423 }
2424 .ob-preview::after {
2425 content: '';
2426 position: absolute;
2427 bottom: 0; left: 0; right: 0;
2428 height: 24px;
2429 background: linear-gradient(transparent, var(--bg));
2430 pointer-events: none;
2431 }
2432 .ob-labels {
2433 display: flex;
2434 flex-wrap: wrap;
2435 gap: 5px;
2436 margin-bottom: 8px;
2437 }
2438 .ob-label-chip {
2439 display: inline-flex;
2440 align-items: center;
2441 padding: 2px 8px;
2442 border-radius: 9999px;
2443 font-size: 11.5px;
2444 font-weight: 600;
2445 line-height: 1.5;
2446 border: 1px solid transparent;
2447 }
2448 .ob-section ul {
2449 margin: 0;
2450 padding: 0 0 0 16px;
2451 font-size: 13px;
2452 color: var(--text-muted);
2453 line-height: 1.7;
2454 }
2455 .ob-section ul li { margin-bottom: 2px; }
2456 .ob-footer {
2457 display: flex;
2458 align-items: center;
2459 justify-content: flex-end;
2460 padding: 10px 20px;
2461 border-top: 1px solid var(--border);
2462 gap: 10px;
2463 }
2464 .ob-dismiss {
2465 appearance: none;
2466 background: transparent;
2467 border: 1px solid var(--border);
2468 color: var(--text-muted);
2469 border-radius: 6px;
2470 padding: 5px 12px;
2471 font-size: 12.5px;
2472 font-family: inherit;
2473 cursor: pointer;
2474 transition: background var(--t-fast), color var(--t-fast);
2475 }
2476 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2477 .btn-sm {
2478 appearance: none;
2479 background: var(--bg-elevated);
2480 border: 1px solid var(--border);
2481 color: var(--text-strong);
2482 border-radius: 6px;
2483 padding: 5px 12px;
2484 font-size: 12.5px;
2485 font-weight: 600;
2486 font-family: inherit;
2487 cursor: pointer;
2488 text-decoration: none;
2489 display: inline-flex;
2490 align-items: center;
2491 transition: background var(--t-fast);
2492 }
2493 .btn-sm:hover { background: var(--bg-hover); }
2494 .btn-sm.btn-primary {
2495 background: var(--accent);
2496 color: #fff;
2497 border-color: var(--accent);
2498 }
2499 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2500`;
2501
2502// Onboarding card component — shown to repo owner until dismissed
2503function RepoOnboardingCard({
2504 owner,
2505 repo,
2506 data,
2507}: {
2508 owner: string;
2509 repo: string;
2510 data: typeof repoOnboardingData.$inferSelect;
2511}) {
2512 const labels = (data.suggestedLabels ?? []) as Array<{
2513 name: string;
2514 color: string;
2515 description: string;
2516 }>;
2517 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2518 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2519
2520 return (
2521 <>
2522 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2523 <div class="ob-card" id="repo-onboarding">
2524 <div class="ob-header">
2525 <h3>Get started with {owner}/{repo}</h3>
2526 <p>
2527 Detected: {data.detectedLanguage ?? "Unknown"}
2528 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2529 Here&rsquo;s what we suggest to hit the ground running.
2530 </p>
2531 </div>
2532 <div class="ob-sections">
2533 <div class="ob-section">
2534 <h4>Suggested README</h4>
2535 <div class="ob-preview">{readmePreview}</div>
2536 <button
2537 class="btn-sm"
2538 type="button"
2539 onclick={`(function(){var t=${JSON.stringify(data.suggestedReadme ?? "")};try{navigator.clipboard.writeText(t).then(function(){var b=document.querySelector('.ob-copy-btn');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy to clipboard';},2000);}});}catch(_){};})()`}
2540 aria-label="Copy suggested README to clipboard"
2541 >
2542 Copy to clipboard
2543 </button>
2544 </div>
2545 <div class="ob-section">
2546 <h4>Suggested labels ({labels.length})</h4>
2547 <div class="ob-labels">
2548 {labels.slice(0, 6).map((l) => (
2549 <span
2550 class="ob-label-chip"
2551 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2552 title={l.description}
2553 >
2554 {l.name}
2555 </span>
2556 ))}
2557 </div>
2558 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2559 <button class="btn-sm btn-primary" type="submit">
2560 Create all labels
2561 </button>
2562 </form>
2563 </div>
2564 <div class="ob-section">
2565 <h4>First steps</h4>
2566 <ul>
2567 {suggestions.map((s) => (
2568 <li>{s}</li>
2569 ))}
2570 </ul>
2571 </div>
2572 </div>
2573 <div class="ob-footer">
2574 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2575 <button class="ob-dismiss" type="submit">
2576 Dismiss
2577 </button>
2578 </form>
2579 </div>
2580 <script
2581 dangerouslySetInnerHTML={{
2582 __html: `
2583 (function(){
2584 var card = document.getElementById('repo-onboarding');
2585 if (!card) return;
2586 document.addEventListener('keydown', function(e){
2587 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2588 card.style.display = 'none';
2589 }
2590 });
2591 })();
2592 `,
2593 }}
2594 />
2595 </div>
2596 </>
2597 );
2598}
2599
2600// ---------------------------------------------------------------------------
2601// Setup routes — create suggested labels + dismiss onboarding
2602// ---------------------------------------------------------------------------
2603
2604// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2605// requireAuth + write access. Idempotent (label UPSERT by name).
2606web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2607 const { owner, repo } = c.req.param();
2608 const user = c.get("user")!;
2609
2610 // Resolve repo + verify write access
2611 let repoRow: { id: string; ownerId: string } | null = null;
2612 try {
2613 const [ownerUser] = await db
2614 .select({ id: users.id })
2615 .from(users)
2616 .where(eq(users.username, owner))
2617 .limit(1);
2618 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2619 const [r] = await db
2620 .select({ id: repositories.id, ownerId: repositories.ownerId })
2621 .from(repositories)
2622 .where(
2623 and(
2624 eq(repositories.ownerId, ownerUser.id),
2625 eq(repositories.name, repo)
2626 )
2627 )
2628 .limit(1);
2629 repoRow = r ?? null;
2630 } catch {
2631 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2632 }
2633 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2634 if (repoRow.ownerId !== user.id) {
2635 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2636 }
2637
2638 // Fetch the onboarding data
2639 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2640 try {
2641 const [r] = await db
2642 .select()
2643 .from(repoOnboardingData)
2644 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2645 .limit(1);
2646 obRow = r ?? null;
2647 } catch {
2648 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2649 }
2650 if (!obRow) {
2651 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2652 }
2653
2654 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2655 name: string;
2656 color: string;
2657 description: string;
2658 }>;
2659
2660 // Create labels — import the labels table which was already imported
2661 let created = 0;
2662 for (const l of suggestedLabels) {
2663 try {
2664 await db
2665 .insert(labels)
2666 .values({
2667 repositoryId: repoRow.id,
2668 name: l.name,
2669 color: l.color,
2670 description: l.description ?? "",
2671 })
2672 .onConflictDoNothing();
2673 created++;
2674 } catch {
2675 /* skip duplicates */
2676 }
2677 }
2678
2679 // Mark onboarding dismissed
2680 try {
2681 await db
2682 .update(repositories)
2683 .set({ onboardingShown: true })
2684 .where(eq(repositories.id, repoRow.id));
2685 } catch {
2686 /* ignore */
2687 }
2688
2689 return c.redirect(
2690 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2691 );
2692});
2693
2694// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2695web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2696 const { owner, repo } = c.req.param();
2697 const user = c.get("user")!;
2698
2699 try {
2700 const [ownerUser] = await db
2701 .select({ id: users.id })
2702 .from(users)
2703 .where(eq(users.username, owner))
2704 .limit(1);
2705 if (ownerUser) {
2706 const [r] = await db
2707 .select({ id: repositories.id, ownerId: repositories.ownerId })
2708 .from(repositories)
2709 .where(
2710 and(
2711 eq(repositories.ownerId, ownerUser.id),
2712 eq(repositories.name, repo)
2713 )
2714 )
2715 .limit(1);
2716 if (r && r.ownerId === user.id) {
2717 await db
2718 .update(repositories)
2719 .set({ onboardingShown: true })
2720 .where(eq(repositories.id, r.id));
2721 }
2722 }
2723 } catch {
2724 /* swallow — dismiss should never fail visibly */
2725 }
2726
2727 return c.redirect(`/${owner}/${repo}`);
2728});
2729
fc1817aClaude2730// Repository overview — file tree at HEAD
2731web.get("/:owner/:repo", async (c) => {
2732 const { owner, repo } = c.req.param();
06d5ffeClaude2733 const user = c.get("user");
fc1817aClaude2734
f1dc7c7Claude2735 // ── Loading skeleton (flag-gated) ──
2736 // Renders an SSR'd shell with file-tree + README placeholders when
2737 // `?skeleton=1` is set. Lets the user see the page structure before
2738 // git ops finish. Behind a flag for now so we never flash before the
2739 // real content lands.
2740 if (c.req.query("skeleton") === "1") {
2741 return c.html(
2742 <Layout title={`${owner}/${repo}`} user={user}>
2743 <style
2744 dangerouslySetInnerHTML={{
2745 __html: `
2746 .repo-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: repoSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
2747 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2748 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2749 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2750 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2751 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2752 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2753 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2754 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2755 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2756 .repo-skel-readme { height: 320px; border-radius: 12px; }
2757 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2758 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2759 `,
2760 }}
2761 />
2762 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2763 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2764 <div class="repo-skel-grid" aria-hidden="true">
2765 <div>
2766 <div class="repo-skel repo-skel-branch" />
2767 <div class="repo-skel-tree">
2768 {Array.from({ length: 8 }).map(() => (
2769 <div class="repo-skel repo-skel-tree-row" />
2770 ))}
2771 </div>
2772 <div class="repo-skel repo-skel-readme" />
2773 </div>
2774 <aside class="repo-skel-side">
2775 <div class="repo-skel repo-skel-side-card" />
2776 <div class="repo-skel repo-skel-side-card" />
2777 </aside>
2778 </div>
2779 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
2780 Loading {owner}/{repo}…
2781 </span>
2782 </Layout>
2783 );
2784 }
2785
8f50ed0Claude2786 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2787 trackByName(owner, repo, "view", {
2788 userId: user?.id || null,
2789 path: `/${owner}/${repo}`,
2790 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2791 userAgent: c.req.header("user-agent") || null,
2792 referer: c.req.header("referer") || null,
a28cedeClaude2793 }).catch((err) => {
2794 console.warn(
2795 `[web] view tracking failed for ${owner}/${repo}:`,
2796 err instanceof Error ? err.message : err
2797 );
2798 });
8f50ed0Claude2799
fc1817aClaude2800 if (!(await repoExists(owner, repo))) {
2801 return c.html(
06d5ffeClaude2802 <Layout title="Not Found" user={user}>
fc1817aClaude2803 <div class="empty-state">
2804 <h2>Repository not found</h2>
2805 <p>
2806 {owner}/{repo} does not exist.
2807 </p>
2808 </div>
2809 </Layout>,
2810 404
2811 );
2812 }
2813
05b973eClaude2814 // Parallelize all independent operations
2815 const [defaultBranch, branches] = await Promise.all([
2816 getDefaultBranch(owner, repo).then((b) => b || "main"),
2817 listBranches(owner, repo),
2818 ]);
2819 const [tree, starInfo] = await Promise.all([
2820 getTree(owner, repo, defaultBranch),
2821 // Star info fetched in parallel with tree
2822 (async () => {
2823 try {
2824 const [ownerUser] = await db
2825 .select()
2826 .from(users)
2827 .where(eq(users.username, owner))
2828 .limit(1);
71cd5ecClaude2829 if (!ownerUser)
2830 return {
2831 starCount: 0,
2832 starred: false,
2833 archived: false,
2834 isTemplate: false,
544d842Claude2835 forkCount: 0,
2836 description: null as string | null,
2837 pushedAt: null as Date | null,
2838 createdAt: null as Date | null,
cb5a796Claude2839 repoId: null as string | null,
2840 repoOwnerId: null as string | null,
71cd5ecClaude2841 };
05b973eClaude2842 const [repoRow] = await db
2843 .select()
2844 .from(repositories)
2845 .where(
2846 and(
2847 eq(repositories.ownerId, ownerUser.id),
2848 eq(repositories.name, repo)
2849 )
06d5ffeClaude2850 )
05b973eClaude2851 .limit(1);
71cd5ecClaude2852 if (!repoRow)
2853 return {
2854 starCount: 0,
2855 starred: false,
2856 archived: false,
2857 isTemplate: false,
544d842Claude2858 forkCount: 0,
2859 description: null as string | null,
2860 pushedAt: null as Date | null,
2861 createdAt: null as Date | null,
cb5a796Claude2862 repoId: null as string | null,
2863 repoOwnerId: null as string | null,
71cd5ecClaude2864 };
05b973eClaude2865 let starred = false;
06d5ffeClaude2866 if (user) {
2867 const [star] = await db
2868 .select()
2869 .from(stars)
2870 .where(
2871 and(
2872 eq(stars.userId, user.id),
2873 eq(stars.repositoryId, repoRow.id)
2874 )
2875 )
2876 .limit(1);
2877 starred = !!star;
2878 }
71cd5ecClaude2879 return {
2880 starCount: repoRow.starCount,
2881 starred,
2882 archived: repoRow.isArchived,
2883 isTemplate: repoRow.isTemplate,
544d842Claude2884 forkCount: repoRow.forkCount,
2885 description: repoRow.description as string | null,
2886 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2887 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2888 repoId: repoRow.id as string,
2889 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2890 };
05b973eClaude2891 } catch {
71cd5ecClaude2892 return {
2893 starCount: 0,
2894 starred: false,
2895 archived: false,
2896 isTemplate: false,
544d842Claude2897 forkCount: 0,
2898 description: null as string | null,
2899 pushedAt: null as Date | null,
2900 createdAt: null as Date | null,
cb5a796Claude2901 repoId: null as string | null,
2902 repoOwnerId: null as string | null,
71cd5ecClaude2903 };
06d5ffeClaude2904 }
05b973eClaude2905 })(),
2906 ]);
544d842Claude2907 const {
2908 starCount,
2909 starred,
2910 archived,
2911 isTemplate,
2912 forkCount,
2913 description,
2914 pushedAt,
2915 createdAt,
cb5a796Claude2916 repoId,
2917 repoOwnerId,
544d842Claude2918 } = starInfo;
2919
91a0204Claude2920 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2921 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2922 // and the badge simply doesn't render.
2923 let healthScore: HealthScore | null = null;
2924 if (repoId) {
2925 try {
2926 healthScore = await computeHealthScore(repoId);
2927 } catch {
2928 // swallow — badge is optional
2929 }
2930 }
2931
cb5a796Claude2932 // Pending-comments banner data (lazy + best-effort). Only the repo
2933 // owner sees the banner, so non-owner views skip the DB hit entirely.
2934 let repoHomePendingCount = 0;
2935 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2936 try {
2937 const { countPendingForRepo } = await import(
2938 "../lib/comment-moderation"
2939 );
2940 repoHomePendingCount = await countPendingForRepo(repoId);
2941 } catch {
2942 /* swallow */
2943 }
2944 }
2945
8c790e0Claude2946 // Push Watch discoverability — fetch the most recent push within 24 h.
2947 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2948 const recentPush: RecentPush | null = repoId
2949 ? await getRecentPush(repoId)
2950 : null;
2951
ebaae0fClaude2952 // AI stats strip — best-effort, fail-open to zero values.
2953 let aiStats: RepoAiStats = {
2954 mergedCount: 0,
2955 reviewCount: 0,
2956 securityAlertCount: 0,
2957 hoursSaved: "0.0",
2958 };
2959 if (repoId) {
2960 try {
2961 aiStats = await getRepoAiStats(repoId);
2962 } catch {
2963 /* swallow — show zero values */
2964 }
2965 }
2966
641aa42Claude2967 // Onboarding card — shown to repo owner until dismissed. Best-effort;
2968 // if the DB is down the card simply won't appear. Only the owner sees it.
2969 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2970 let showOnboarding = false;
2971 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
2972 try {
2973 // Check if onboarding_shown is still false (not yet dismissed)
2974 const [repoRow2] = await db
2975 .select({ onboardingShown: repositories.onboardingShown })
2976 .from(repositories)
2977 .where(eq(repositories.id, repoId))
2978 .limit(1);
2979 if (repoRow2 && !repoRow2.onboardingShown) {
2980 const [obRow] = await db
2981 .select()
2982 .from(repoOnboardingData)
2983 .where(eq(repoOnboardingData.repositoryId, repoId))
2984 .limit(1);
2985 onboardingRow = obRow ?? null;
2986 showOnboarding = !!onboardingRow;
2987 }
2988 } catch {
2989 /* swallow — onboarding is optional */
2990 }
2991 }
2992
544d842Claude2993 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2994 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2995 const repoHomeCss = `
2996 .repo-home-hero {
2997 position: relative;
2998 margin-bottom: var(--space-5);
2999 padding: var(--space-5) var(--space-6);
3000 background: var(--bg-elevated);
3001 border: 1px solid var(--border);
3002 border-radius: 16px;
3003 overflow: hidden;
3004 }
3005 .repo-home-hero::before {
3006 content: '';
3007 position: absolute;
3008 top: 0; left: 0; right: 0;
3009 height: 2px;
3010 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3011 opacity: 0.7;
3012 pointer-events: none;
3013 }
3014 .repo-home-hero-orb-wrap {
3015 position: absolute;
3016 inset: -25% -10% auto auto;
3017 width: 360px;
3018 height: 360px;
3019 pointer-events: none;
3020 z-index: 0;
3021 }
3022 .repo-home-hero-orb {
3023 position: absolute;
3024 inset: 0;
3025 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
3026 filter: blur(80px);
3027 opacity: 0.7;
3028 animation: repoHomeOrb 14s ease-in-out infinite;
3029 }
3030 @keyframes repoHomeOrb {
3031 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3032 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3033 }
3034 @media (prefers-reduced-motion: reduce) {
3035 .repo-home-hero-orb { animation: none; }
3036 }
3037 .repo-home-hero-inner {
3038 position: relative;
3039 z-index: 1;
3040 }
3041 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3042 .repo-home-eyebrow {
3043 font-size: 12px;
3044 font-family: var(--font-mono);
3045 color: var(--text-muted);
3046 letter-spacing: 0.1em;
3047 text-transform: uppercase;
3048 margin-bottom: var(--space-2);
3049 }
3050 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3051 .repo-home-description {
3052 font-size: 15px;
3053 line-height: 1.55;
3054 color: var(--text);
3055 margin: 0;
3056 max-width: 720px;
3057 }
3058 .repo-home-description-empty {
3059 font-size: 14px;
3060 color: var(--text-muted);
3061 font-style: italic;
3062 margin: 0;
3063 }
3064 .repo-home-stat-row {
3065 display: flex;
3066 flex-wrap: wrap;
3067 gap: var(--space-4);
3068 margin-top: var(--space-3);
3069 font-size: 13px;
3070 color: var(--text-muted);
3071 }
3072 .repo-home-stat {
3073 display: inline-flex;
3074 align-items: center;
3075 gap: 6px;
3076 }
3077 .repo-home-stat strong {
3078 color: var(--text-strong);
3079 font-weight: 600;
3080 font-variant-numeric: tabular-nums;
3081 }
3082 .repo-home-stat .repo-home-stat-icon {
3083 color: var(--text-faint);
3084 font-size: 14px;
3085 line-height: 1;
3086 }
3087 .repo-home-stat a {
3088 color: var(--text-muted);
3089 transition: color var(--t-fast) var(--ease);
3090 }
3091 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3092
3093 /* Two-column layout: file tree + sidebar */
3094 .repo-home-grid {
3095 display: grid;
3096 grid-template-columns: minmax(0, 1fr) 280px;
3097 gap: var(--space-5);
3098 align-items: start;
3099 }
3100 @media (max-width: 960px) {
3101 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3102 }
3103 .repo-home-main { min-width: 0; }
3104
3105 /* Sidebar card */
3106 .repo-home-side {
3107 display: flex;
3108 flex-direction: column;
3109 gap: var(--space-4);
3110 }
3111 .repo-home-side-card {
3112 background: var(--bg-elevated);
3113 border: 1px solid var(--border);
3114 border-radius: 12px;
3115 padding: var(--space-4);
3116 }
3117 .repo-home-side-title {
3118 font-size: 11px;
3119 font-family: var(--font-mono);
3120 letter-spacing: 0.12em;
3121 text-transform: uppercase;
3122 color: var(--text-muted);
3123 margin: 0 0 var(--space-3);
3124 font-weight: 600;
3125 }
3126 .repo-home-side-row {
3127 display: flex;
3128 justify-content: space-between;
3129 align-items: center;
3130 gap: var(--space-2);
3131 font-size: 13px;
3132 padding: 6px 0;
3133 border-top: 1px solid var(--border);
3134 }
3135 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3136 .repo-home-side-key {
3137 color: var(--text-muted);
3138 display: inline-flex;
3139 align-items: center;
3140 gap: 6px;
3141 }
3142 .repo-home-side-val {
3143 color: var(--text-strong);
3144 font-weight: 500;
3145 font-variant-numeric: tabular-nums;
3146 max-width: 60%;
3147 text-align: right;
3148 overflow: hidden;
3149 text-overflow: ellipsis;
3150 white-space: nowrap;
3151 }
3152 .repo-home-side-val a { color: var(--text-strong); }
3153 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3154
3155 /* Clone / Code tabs */
3156 .repo-home-clone {
3157 background: var(--bg-elevated);
3158 border: 1px solid var(--border);
3159 border-radius: 12px;
3160 overflow: hidden;
3161 }
3162 .repo-home-clone-tabs {
3163 display: flex;
3164 gap: 0;
3165 background: var(--bg-secondary);
3166 border-bottom: 1px solid var(--border);
3167 padding: 0 var(--space-2);
3168 }
3169 .repo-home-clone-tab {
3170 appearance: none;
3171 background: transparent;
3172 border: 0;
3173 border-bottom: 2px solid transparent;
3174 padding: 9px 12px;
3175 font-size: 12px;
3176 font-weight: 500;
3177 color: var(--text-muted);
3178 cursor: pointer;
3179 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3180 font-family: inherit;
3181 margin-bottom: -1px;
3182 }
3183 .repo-home-clone-tab:hover { color: var(--text-strong); }
3184 .repo-home-clone-tab[aria-selected="true"] {
3185 color: var(--text-strong);
3186 border-bottom-color: var(--accent);
3187 }
3188 .repo-home-clone-body {
3189 padding: var(--space-3);
3190 display: flex;
3191 align-items: center;
3192 gap: var(--space-2);
3193 }
3194 .repo-home-clone-input {
3195 flex: 1;
3196 min-width: 0;
3197 font-family: var(--font-mono);
3198 font-size: 12px;
3199 background: var(--bg);
3200 border: 1px solid var(--border);
3201 border-radius: 8px;
3202 padding: 8px 10px;
3203 color: var(--text-strong);
3204 overflow: hidden;
3205 text-overflow: ellipsis;
3206 white-space: nowrap;
3207 }
3208 .repo-home-clone-copy {
3209 appearance: none;
3210 background: var(--bg-secondary);
3211 border: 1px solid var(--border);
3212 color: var(--text-strong);
3213 border-radius: 8px;
3214 padding: 8px 12px;
3215 font-size: 12px;
3216 font-weight: 600;
3217 cursor: pointer;
3218 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3219 font-family: inherit;
3220 }
3221 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3222 .repo-home-clone-pane { display: none; }
3223 .repo-home-clone-pane[data-active="true"] { display: flex; }
3224
3225 /* README card */
3226 .repo-home-readme {
3227 margin-top: var(--space-5);
3228 background: var(--bg-elevated);
3229 border: 1px solid var(--border);
3230 border-radius: 12px;
3231 overflow: hidden;
3232 }
3233 .repo-home-readme-head {
3234 display: flex;
3235 align-items: center;
3236 gap: 8px;
3237 padding: 10px 16px;
3238 background: var(--bg-secondary);
3239 border-bottom: 1px solid var(--border);
3240 font-size: 13px;
3241 color: var(--text-muted);
3242 }
3243 .repo-home-readme-head .repo-home-readme-icon {
3244 color: var(--accent);
3245 font-size: 14px;
3246 }
3247 .repo-home-readme-body {
3248 padding: var(--space-5) var(--space-6);
3249 }
3250
3251 /* Empty-state CTA */
3252 .repo-home-empty {
3253 position: relative;
3254 margin-top: var(--space-4);
3255 background: var(--bg-elevated);
3256 border: 1px solid var(--border);
3257 border-radius: 16px;
3258 padding: var(--space-6);
3259 overflow: hidden;
3260 }
3261 .repo-home-empty::before {
3262 content: '';
3263 position: absolute;
3264 top: 0; left: 0; right: 0;
3265 height: 2px;
3266 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3267 opacity: 0.7;
3268 pointer-events: none;
3269 }
3270 .repo-home-empty-eyebrow {
3271 font-size: 12px;
3272 font-family: var(--font-mono);
3273 color: var(--accent);
3274 letter-spacing: 0.12em;
3275 text-transform: uppercase;
3276 margin-bottom: var(--space-2);
3277 font-weight: 600;
3278 }
3279 .repo-home-empty-title {
3280 font-family: var(--font-display);
3281 font-weight: 800;
3282 letter-spacing: -0.025em;
3283 font-size: clamp(22px, 3vw, 30px);
3284 line-height: 1.1;
3285 margin: 0 0 var(--space-2);
3286 color: var(--text-strong);
3287 }
3288 .repo-home-empty-sub {
3289 color: var(--text-muted);
3290 font-size: 14px;
3291 line-height: 1.55;
3292 max-width: 640px;
3293 margin: 0 0 var(--space-4);
3294 }
3295 .repo-home-empty-snippet {
3296 background: var(--bg);
3297 border: 1px solid var(--border);
3298 border-radius: 10px;
3299 padding: var(--space-3) var(--space-4);
3300 font-family: var(--font-mono);
3301 font-size: 12.5px;
3302 line-height: 1.7;
3303 color: var(--text-strong);
3304 overflow-x: auto;
3305 white-space: pre;
3306 margin: 0;
3307 }
3308 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3309 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3310
91a0204Claude3311 /* Health score badge */
3312 .repo-health-badge {
3313 display: inline-flex;
3314 align-items: center;
3315 gap: 5px;
3316 padding: 3px 10px;
3317 border-radius: 999px;
3318 font-size: 11.5px;
3319 font-weight: 700;
3320 font-family: var(--font-mono);
3321 text-decoration: none;
3322 border: 1px solid transparent;
3323 transition: filter 120ms ease, opacity 120ms ease;
3324 vertical-align: middle;
3325 }
3326 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3327 .repo-health-badge-elite {
3328 background: rgba(52,211,153,0.15);
3329 color: #6ee7b7;
3330 border-color: rgba(52,211,153,0.30);
3331 }
3332 .repo-health-badge-strong {
3333 background: rgba(96,165,250,0.15);
3334 color: #93c5fd;
3335 border-color: rgba(96,165,250,0.30);
3336 }
3337 .repo-health-badge-improving {
3338 background: rgba(251,191,36,0.15);
3339 color: #fde68a;
3340 border-color: rgba(251,191,36,0.30);
3341 }
3342 .repo-health-badge-needs-attention {
3343 background: rgba(248,113,113,0.15);
3344 color: #fca5a5;
3345 border-color: rgba(248,113,113,0.30);
3346 }
3347
3348 /* Three-option empty-state panel */
3349 .repo-empty-options {
3350 display: grid;
3351 grid-template-columns: repeat(3, 1fr);
3352 gap: var(--space-3);
3353 margin-top: var(--space-5);
3354 }
3355 @media (max-width: 760px) {
3356 .repo-empty-options { grid-template-columns: 1fr; }
3357 }
3358 .repo-empty-option {
3359 position: relative;
3360 background: var(--bg-elevated);
3361 border: 1px solid var(--border);
3362 border-radius: 14px;
3363 padding: var(--space-4) var(--space-4);
3364 display: flex;
3365 flex-direction: column;
3366 gap: var(--space-2);
3367 overflow: hidden;
3368 transition: border-color 140ms ease, background 140ms ease;
3369 }
3370 .repo-empty-option:hover {
3371 border-color: rgba(140,109,255,0.45);
3372 background: rgba(140,109,255,0.04);
3373 }
3374 .repo-empty-option::before {
3375 content: '';
3376 position: absolute;
3377 top: 0; left: 0; right: 0;
3378 height: 2px;
3379 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3380 opacity: 0.55;
3381 pointer-events: none;
3382 }
3383 .repo-empty-option-label {
3384 font-size: 10px;
3385 font-family: var(--font-mono);
3386 font-weight: 700;
3387 letter-spacing: 0.12em;
3388 text-transform: uppercase;
3389 color: var(--accent);
3390 margin-bottom: 2px;
3391 }
3392 .repo-empty-option-title {
3393 font-family: var(--font-display);
3394 font-weight: 700;
3395 font-size: 16px;
3396 letter-spacing: -0.01em;
3397 color: var(--text-strong);
3398 margin: 0;
3399 }
3400 .repo-empty-option-sub {
3401 font-size: 13px;
3402 color: var(--text-muted);
3403 line-height: 1.5;
3404 margin: 0;
3405 flex: 1;
3406 }
3407 .repo-empty-option-snippet {
3408 background: var(--bg);
3409 border: 1px solid var(--border);
3410 border-radius: 8px;
3411 padding: var(--space-2) var(--space-3);
3412 font-family: var(--font-mono);
3413 font-size: 11.5px;
3414 line-height: 1.75;
3415 color: var(--text-strong);
3416 overflow-x: auto;
3417 white-space: pre;
3418 margin: var(--space-1) 0 0;
3419 }
3420 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3421 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3422 .repo-empty-option-cta {
3423 display: inline-flex;
3424 align-items: center;
3425 gap: 6px;
3426 margin-top: auto;
3427 padding-top: var(--space-2);
3428 font-size: 13px;
3429 font-weight: 600;
3430 color: var(--accent);
3431 text-decoration: none;
3432 transition: color 120ms ease;
3433 }
3434 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3435
544d842Claude3436 @media (max-width: 720px) {
3437 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3438 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3439 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3440 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3441 .repo-home-side-val { max-width: 55%; }
3442 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3443 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3444 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3445 }
ebaae0fClaude3446
3447 /* AI stats strip */
3448 .repo-ai-stats-strip {
3449 display: flex;
3450 align-items: center;
3451 gap: 6px;
3452 margin-top: 12px;
3453 margin-bottom: 4px;
3454 font-size: 12px;
3455 color: var(--text-muted);
3456 flex-wrap: wrap;
3457 }
3458 .repo-ai-stats-strip a {
3459 color: var(--text-muted);
3460 text-decoration: underline;
3461 text-decoration-color: transparent;
3462 transition: color 120ms ease, text-decoration-color 120ms ease;
3463 }
3464 .repo-ai-stats-strip a:hover {
3465 color: var(--accent);
3466 text-decoration-color: currentColor;
3467 }
3468 .repo-ai-stats-sep {
3469 opacity: 0.4;
3470 user-select: none;
3471 }
544d842Claude3472 `;
3473 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3474 // SSH URL — port-aware:
3475 // Standard port 22 → git@host:owner/repo.git
3476 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3477 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3478 try {
3479 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3480 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3481 ? host
3482 : "localhost";
3483 const sshPort = config.sshPort;
3484 if (sshPort === 22) {
3485 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3486 } else {
3487 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3488 }
3489 } catch {
3490 // Fall through to default.
3491 }
3492 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3493 const formatRelative = (date: Date | null): string => {
3494 if (!date) return "never";
3495 const ms = Date.now() - date.getTime();
3496 const s = Math.max(0, Math.round(ms / 1000));
3497 if (s < 60) return "just now";
3498 const m = Math.round(s / 60);
3499 if (m < 60) return `${m} min ago`;
3500 const h = Math.round(m / 60);
3501 if (h < 24) return `${h}h ago`;
3502 const d = Math.round(h / 24);
3503 if (d < 30) return `${d}d ago`;
3504 const mo = Math.round(d / 30);
3505 if (mo < 12) return `${mo}mo ago`;
3506 const y = Math.round(d / 365);
3507 return `${y}y ago`;
3508 };
06d5ffeClaude3509
fc1817aClaude3510 if (tree.length === 0) {
5acce80Claude3511 const repoOgDesc = description
3512 ? `${owner}/${repo} on Gluecron — ${description}`
3513 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3514 return c.html(
5acce80Claude3515 <Layout
3516 title={`${owner}/${repo}`}
3517 user={user}
3518 description={repoOgDesc}
3519 ogTitle={`${owner}/${repo} — Gluecron`}
3520 ogDescription={repoOgDesc}
3521 twitterCard="summary"
3522 >
544d842Claude3523 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3524 <style dangerouslySetInnerHTML={{ __html: `
3525 .empty-options-grid {
3526 display: grid;
3527 grid-template-columns: repeat(3, 1fr);
3528 gap: var(--space-4);
3529 margin-top: var(--space-4);
3530 }
3531 @media (max-width: 800px) {
3532 .empty-options-grid { grid-template-columns: 1fr; }
3533 }
3534 .empty-option-card {
3535 position: relative;
3536 background: var(--bg-elevated);
3537 border: 1px solid var(--border);
3538 border-radius: 14px;
3539 padding: var(--space-5);
3540 display: flex;
3541 flex-direction: column;
3542 gap: var(--space-3);
3543 overflow: hidden;
3544 transition: border-color 140ms ease, box-shadow 140ms ease;
3545 }
3546 .empty-option-card:hover {
3547 border-color: rgba(140,109,255,0.45);
3548 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3549 }
3550 .empty-option-card::before {
3551 content: '';
3552 position: absolute;
3553 top: 0; left: 0; right: 0;
3554 height: 2px;
3555 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3556 opacity: 0.55;
3557 pointer-events: none;
3558 }
3559 .empty-option-label {
3560 font-size: 10.5px;
3561 font-family: var(--font-mono);
3562 text-transform: uppercase;
3563 letter-spacing: 0.14em;
3564 color: var(--accent);
3565 font-weight: 700;
3566 }
3567 .empty-option-title {
3568 font-family: var(--font-display);
3569 font-weight: 700;
3570 font-size: 17px;
3571 letter-spacing: -0.01em;
3572 color: var(--text-strong);
3573 margin: 0;
3574 line-height: 1.25;
3575 }
3576 .empty-option-body {
3577 font-size: 13px;
3578 color: var(--text-muted);
3579 line-height: 1.55;
3580 margin: 0;
3581 flex: 1;
3582 }
3583 .empty-option-snippet {
3584 background: var(--bg);
3585 border: 1px solid var(--border);
3586 border-radius: 8px;
3587 padding: var(--space-3) var(--space-4);
3588 font-family: var(--font-mono);
3589 font-size: 11.5px;
3590 line-height: 1.75;
3591 color: var(--text-strong);
3592 overflow-x: auto;
3593 white-space: pre;
3594 margin: 0;
3595 }
3596 .empty-option-snippet .ec { color: var(--text-faint); }
3597 .empty-option-snippet .em { color: var(--accent); }
3598 .empty-option-cta {
3599 display: inline-flex;
3600 align-items: center;
3601 gap: 6px;
3602 padding: 8px 16px;
3603 font-size: 13px;
3604 font-weight: 600;
3605 color: var(--text-strong);
3606 background: var(--bg-secondary);
3607 border: 1px solid var(--border);
3608 border-radius: 9999px;
3609 text-decoration: none;
3610 align-self: flex-start;
3611 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3612 }
3613 .empty-option-cta:hover {
3614 border-color: rgba(140,109,255,0.55);
3615 background: rgba(140,109,255,0.08);
3616 color: var(--accent);
3617 text-decoration: none;
3618 }
3619 .empty-option-cta-accent {
3620 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3621 border-color: transparent;
3622 color: #fff;
3623 }
3624 .empty-option-cta-accent:hover {
3625 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3626 border-color: transparent;
3627 color: #fff;
3628 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3629 }
3630 ` }} />
544d842Claude3631 <div class="repo-home-hero">
3632 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3633 <div class="repo-home-hero-orb" />
3634 </div>
3635 <div class="repo-home-hero-inner">
3636 <div class="repo-home-eyebrow">
3637 <strong>Repository</strong> · {owner}
3638 </div>
91a0204Claude3639 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3640 <RepoHeader
3641 owner={owner}
3642 repo={repo}
3643 starCount={starCount}
3644 starred={starred}
3645 forkCount={forkCount}
3646 currentUser={user?.username}
3647 archived={archived}
3648 isTemplate={isTemplate}
8c790e0Claude3649 recentPush={recentPush}
91a0204Claude3650 />
3651 {healthScore && (() => {
3652 const gradeLabel: Record<string, string> = {
3653 elite: "Elite", strong: "Strong",
3654 improving: "Improving", "needs-attention": "Needs Attention",
3655 };
3656 return (
3657 <a
3658 href={`/${owner}/${repo}/insights/health`}
3659 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3660 title={`Health score: ${healthScore!.total}/100`}
3661 >
3662 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3663 </a>
3664 );
3665 })()}
3666 </div>
544d842Claude3667 {description ? (
3668 <p class="repo-home-description">{description}</p>
3669 ) : (
3670 <p class="repo-home-description-empty">
3671 No description yet — push a README to tell the world what this
3672 ships.
3673 </p>
3674 )}
3675 </div>
3676 </div>
fc1817aClaude3677 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3678 <div style="margin-top:var(--space-4)">
3679 <div style="font-size:12px;font-family:var(--font-mono);color:var(--accent);letter-spacing:0.12em;text-transform:uppercase;font-weight:600;margin-bottom:var(--space-2)">
3680 Getting started
3681 </div>
544d842Claude3682 <h2 class="repo-home-empty-title">
91a0204Claude3683 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3684 </h2>
3685 <p class="repo-home-empty-sub">
91a0204Claude3686 Choose how you want to kick things off. Every push wires up gate
3687 checks and AI review automatically.
544d842Claude3688 </p>
91a0204Claude3689 <div class="empty-options-grid">
3690 <div class="empty-option-card">
3691 <div class="empty-option-label">Option A</div>
3692 <h3 class="empty-option-title">Push your first commit</h3>
3693 <p class="empty-option-body">
3694 Wire up an existing project in seconds. Run these commands from
3695 your project directory.
3696 </p>
3697 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3698<span class="em">git remote add</span> origin {cloneHttpsUrl}
3699<span class="em">git branch</span> -M main
3700<span class="em">git push</span> -u origin main</pre>
3701 </div>
3702 <div class="empty-option-card">
3703 <div class="empty-option-label">Option B</div>
3704 <h3 class="empty-option-title">Import from GitHub</h3>
3705 <p class="empty-option-body">
3706 Mirror an existing GitHub repository here in one click. Gluecron
3707 syncs the full history and branches.
3708 </p>
3709 <a href="/import" class="empty-option-cta">
3710 Import repository
3711 </a>
3712 </div>
3713 <div class="empty-option-card">
3714 <div class="empty-option-label">Option C</div>
3715 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3716 <p class="empty-option-body">
3717 Let AI write your first feature. Describe what you want to build
3718 and Gluecron opens a pull request with the code.
3719 </p>
3720 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3721 Let AI write your first feature
3722 </a>
3723 </div>
3724 </div>
fc1817aClaude3725 </div>
3726 </Layout>
3727 );
3728 }
3729
3730 const readme = await getReadme(owner, repo, defaultBranch);
3731
544d842Claude3732 // Sidebar facts — derived from data we already have.
3733 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3734 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3735
5acce80Claude3736 const repoOgDesc = description
3737 ? `${owner}/${repo} on Gluecron — ${description}`
3738 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3739
fc1817aClaude3740 return c.html(
5acce80Claude3741 <Layout
3742 title={`${owner}/${repo}`}
3743 user={user}
3744 description={repoOgDesc}
3745 ogTitle={`${owner}/${repo} — Gluecron`}
3746 ogDescription={repoOgDesc}
3747 twitterCard="summary"
3748 >
544d842Claude3749 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3750 {/* Repo-context commands for the command palette (Feature 2) */}
3751 <script
3752 id="cmdk-repo-context"
3753 dangerouslySetInnerHTML={{
3754 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3755 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3756 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3757 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3758 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3759 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3760 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3761 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3762 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3763 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3764 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3765 ])};`,
3766 }}
3767 />
544d842Claude3768 <div class="repo-home-hero">
3769 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3770 <div class="repo-home-hero-orb" />
3771 </div>
3772 <div class="repo-home-hero-inner">
3773 <div class="repo-home-eyebrow">
3774 <strong>Repository</strong> · {owner}
3775 </div>
91a0204Claude3776 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3777 <RepoHeader
3778 owner={owner}
3779 repo={repo}
3780 starCount={starCount}
3781 starred={starred}
3782 forkCount={forkCount}
3783 currentUser={user?.username}
3784 archived={archived}
3785 isTemplate={isTemplate}
8c790e0Claude3786 recentPush={recentPush}
91a0204Claude3787 />
3788 {healthScore && (() => {
3789 const gradeLabel: Record<string, string> = {
3790 elite: "Elite", strong: "Strong",
3791 improving: "Improving", "needs-attention": "Needs Attention",
3792 };
3793 return (
3794 <a
3795 href={`/${owner}/${repo}/insights/health`}
3796 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3797 title={`Health score: ${healthScore!.total}/100`}
3798 >
3799 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3800 </a>
3801 );
3802 })()}
3803 </div>
544d842Claude3804 {description ? (
3805 <p class="repo-home-description">{description}</p>
3806 ) : (
3807 <p class="repo-home-description-empty">
3808 No description yet.
3809 </p>
3810 )}
3811 <div class="repo-home-stat-row" aria-label="Repository stats">
3812 <span class="repo-home-stat" title="Default branch">
3813 <span class="repo-home-stat-icon">{"⎇"}</span>
3814 <strong>{defaultBranch}</strong>
3815 </span>
3816 <a
3817 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3818 class="repo-home-stat"
3819 title="Browse all branches"
3820 >
3821 <span class="repo-home-stat-icon">{"⊢"}</span>
3822 <strong>{branches.length}</strong>{" "}
3823 branch{branches.length === 1 ? "" : "es"}
3824 </a>
3825 <span class="repo-home-stat" title="Top-level entries">
3826 <span class="repo-home-stat-icon">{"■"}</span>
3827 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3828 {dirCount > 0 && (
3829 <>
3830 {" · "}
3831 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3832 </>
3833 )}
3834 </span>
3835 {pushedAt && (
3836 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3837 <span class="repo-home-stat-icon">{"↻"}</span>
3838 Updated <strong>{formatRelative(pushedAt)}</strong>
3839 </span>
3840 )}
3841 </div>
3842 </div>
3843 </div>
71cd5ecClaude3844 {isTemplate && user && user.username !== owner && (
3845 <div
3846 class="panel"
dc26881CC LABS App3847 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3848 >
3849 <div style="font-size:13px">
3850 <strong>Template repository.</strong> Create a new repository from
3851 this template's files.
3852 </div>
3853 <form
001af43Claude3854 method="post"
71cd5ecClaude3855 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3856 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3857 >
3858 <input
3859 type="text"
3860 name="name"
3861 placeholder="new-repo-name"
3862 required
2c3ba6ecopilot-swe-agent[bot]3863 aria-label="New repository name"
71cd5ecClaude3864 style="width:200px"
3865 />
3866 <button type="submit" class="btn btn-primary">
3867 Use this template
3868 </button>
3869 </form>
3870 </div>
3871 )}
fc1817aClaude3872 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3873 <RepoHomePendingBanner
3874 owner={owner}
3875 repo={repo}
3876 count={repoHomePendingCount}
3877 />
641aa42Claude3878 {showOnboarding && onboardingRow && (
3879 <RepoOnboardingCard
3880 owner={owner}
3881 repo={repo}
3882 data={onboardingRow}
3883 />
3884 )}
c6018a5Claude3885 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3886 row sits just below the nav as a slim CTA strip. Scoped under
3887 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3888 <style
3889 dangerouslySetInnerHTML={{
3890 __html: `
3891 .repo-ai-cta-row {
3892 display: flex;
3893 flex-wrap: wrap;
3894 gap: 8px;
3895 margin: 12px 0 18px;
3896 padding: 10px 14px;
3897 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3898 border: 1px solid var(--border);
3899 border-radius: 10px;
3900 position: relative;
3901 overflow: hidden;
3902 }
3903 .repo-ai-cta-row::before {
3904 content: '';
3905 position: absolute;
3906 top: 0; left: 0; right: 0;
3907 height: 1px;
3908 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3909 opacity: 0.7;
3910 pointer-events: none;
3911 }
3912 .repo-ai-cta-label {
3913 font-size: 11px;
3914 font-weight: 600;
3915 letter-spacing: 0.06em;
3916 text-transform: uppercase;
3917 color: var(--accent);
3918 align-self: center;
3919 padding-right: 8px;
3920 border-right: 1px solid var(--border);
3921 margin-right: 4px;
3922 }
3923 .repo-ai-cta {
3924 display: inline-flex;
3925 align-items: center;
3926 gap: 6px;
3927 padding: 5px 10px;
3928 font-size: 12.5px;
3929 font-weight: 500;
3930 color: var(--text);
3931 background: var(--bg);
3932 border: 1px solid var(--border);
3933 border-radius: 7px;
3934 text-decoration: none;
3935 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3936 }
3937 .repo-ai-cta:hover {
3938 border-color: rgba(140,109,255,0.45);
3939 color: var(--text-strong);
3940 background: rgba(140,109,255,0.06);
3941 text-decoration: none;
3942 }
3943 .repo-ai-cta-icon {
3944 opacity: 0.75;
3945 font-size: 12px;
3946 }
3947 @media (max-width: 640px) {
3948 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3949 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3950 }
3951 `,
3952 }}
3953 />
3954 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3955 <span class="repo-ai-cta-label">AI surfaces</span>
3956 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3957 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3958 Chat
3959 </a>
3960 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3961 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3962 Previews
3963 </a>
3964 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3965 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3966 Migrations
3967 </a>
53c9249Claude3968 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude3969 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude3970 AI Search
c6018a5Claude3971 </a>
3972 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3973 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3974 AI release notes
3975 </a>
3646bfeClaude3976 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3977 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3978 Dev environment
3979 </a>
c6018a5Claude3980 </nav>
544d842Claude3981 <div class="repo-home-grid">
3982 <div class="repo-home-main">
3983 <BranchSwitcher
3984 owner={owner}
3985 repo={repo}
3986 currentRef={defaultBranch}
3987 branches={branches}
3988 pathType="tree"
3989 />
3990 <FileTable
3991 entries={tree}
3992 owner={owner}
3993 repo={repo}
3994 ref={defaultBranch}
3995 path=""
3996 />
ebaae0fClaude3997 {/* AI stats strip — one-line summary of AI value for this repo */}
3998 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3999 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4000 <span>{"⚡"}</span>
4001 {aiStats.mergedCount > 0 ? (
4002 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4003 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4004 </a>
4005 ) : (
4006 <span>0 AI merges this week</span>
4007 )}
4008 <span class="repo-ai-stats-sep">{"·"}</span>
4009 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4010 <span class="repo-ai-stats-sep">{"·"}</span>
4011 {aiStats.securityAlertCount > 0 ? (
4012 <a href={`/${owner}/${repo}/issues?label=security`}>
4013 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4014 </a>
4015 ) : (
4016 <span>0 open security alerts</span>
4017 )}
4018 </div>
4019 ) : (
4020 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4021 <span>{"⚡"}</span>
4022 <span>AI is watching — no activity this week</span>
4023 </div>
4024 )}
544d842Claude4025 {readme && (() => {
4026 const readmeHtml = renderMarkdown(readme);
4027 return (
4028 <div class="repo-home-readme">
4029 <div class="repo-home-readme-head">
4030 <span class="repo-home-readme-icon">{"☰"}</span>
4031 <span>README.md</span>
4032 </div>
4033 <style>{markdownCss}</style>
4034 <div class="markdown-body repo-home-readme-body">
4035 {html([readmeHtml] as unknown as TemplateStringsArray)}
4036 </div>
4037 </div>
4038 );
4039 })()}
4040 </div>
4041 <aside class="repo-home-side" aria-label="Repository details">
4042 <div class="repo-home-clone">
4043 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4044 <button
4045 type="button"
4046 class="repo-home-clone-tab"
4047 role="tab"
4048 aria-selected="true"
4049 data-pane="https"
4050 data-repo-home-clone-tab
4051 >
4052 HTTPS
4053 </button>
4054 <button
4055 type="button"
4056 class="repo-home-clone-tab"
4057 role="tab"
4058 aria-selected="false"
4059 data-pane="ssh"
4060 data-repo-home-clone-tab
4061 >
4062 SSH
4063 </button>
4064 <button
4065 type="button"
4066 class="repo-home-clone-tab"
4067 role="tab"
4068 aria-selected="false"
4069 data-pane="cli"
4070 data-repo-home-clone-tab
4071 >
4072 CLI
4073 </button>
4074 </div>
4075 <div
4076 class="repo-home-clone-pane"
4077 data-pane="https"
4078 data-active="true"
4079 role="tabpanel"
4080 >
4081 <div class="repo-home-clone-body">
4082 <input
4083 class="repo-home-clone-input"
4084 type="text"
4085 value={cloneHttpsUrl}
4086 readonly
4087 aria-label="HTTPS clone URL"
4088 data-repo-home-clone-input
4089 />
4090 <button
4091 type="button"
4092 class="repo-home-clone-copy"
4093 data-repo-home-copy={cloneHttpsUrl}
4094 >
4095 Copy
4096 </button>
4097 </div>
4098 </div>
4099 <div
4100 class="repo-home-clone-pane"
4101 data-pane="ssh"
4102 data-active="false"
4103 role="tabpanel"
4104 >
4105 <div class="repo-home-clone-body">
4106 <input
4107 class="repo-home-clone-input"
4108 type="text"
4109 value={cloneSshUrl}
4110 readonly
4111 aria-label="SSH clone URL"
4112 data-repo-home-clone-input
4113 />
4114 <button
4115 type="button"
4116 class="repo-home-clone-copy"
4117 data-repo-home-copy={cloneSshUrl}
4118 >
4119 Copy
4120 </button>
4121 </div>
4122 </div>
4123 <div
4124 class="repo-home-clone-pane"
4125 data-pane="cli"
4126 data-active="false"
4127 role="tabpanel"
4128 >
4129 <div class="repo-home-clone-body">
4130 <input
4131 class="repo-home-clone-input"
4132 type="text"
4133 value={cloneCliCmd}
4134 readonly
4135 aria-label="Gluecron CLI clone command"
4136 data-repo-home-clone-input
4137 />
4138 <button
4139 type="button"
4140 class="repo-home-clone-copy"
4141 data-repo-home-copy={cloneCliCmd}
4142 >
4143 Copy
4144 </button>
4145 </div>
79136bbClaude4146 </div>
fc1817aClaude4147 </div>
544d842Claude4148 <div class="repo-home-side-card">
4149 <h3 class="repo-home-side-title">About</h3>
4150 <div class="repo-home-side-row">
4151 <span class="repo-home-side-key">Default branch</span>
4152 <span class="repo-home-side-val">{defaultBranch}</span>
4153 </div>
4154 <div class="repo-home-side-row">
4155 <span class="repo-home-side-key">Branches</span>
4156 <span class="repo-home-side-val">
4157 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4158 {branches.length}
4159 </a>
4160 </span>
4161 </div>
4162 <div class="repo-home-side-row">
4163 <span class="repo-home-side-key">Stars</span>
4164 <span class="repo-home-side-val">{starCount}</span>
4165 </div>
4166 <div class="repo-home-side-row">
4167 <span class="repo-home-side-key">Forks</span>
4168 <span class="repo-home-side-val">{forkCount}</span>
4169 </div>
4170 {pushedAt && (
4171 <div class="repo-home-side-row">
4172 <span class="repo-home-side-key">Last push</span>
4173 <span class="repo-home-side-val">
4174 {formatRelative(pushedAt)}
4175 </span>
4176 </div>
4177 )}
4178 {createdAt && (
4179 <div class="repo-home-side-row">
4180 <span class="repo-home-side-key">Created</span>
4181 <span class="repo-home-side-val">
4182 {formatRelative(createdAt)}
4183 </span>
4184 </div>
4185 )}
4186 {(archived || isTemplate) && (
4187 <div class="repo-home-side-row">
4188 <span class="repo-home-side-key">State</span>
4189 <span class="repo-home-side-val">
4190 {archived ? "Archived" : "Template"}
4191 </span>
4192 </div>
4193 )}
4194 </div>
f1dc38bClaude4195 {/* Claude AI — quick-access card for authenticated users */}
4196 {user && (
4197 <div class="repo-home-side-card" style="margin-top:12px">
4198 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4199 ✨ Claude AI
4200 </h3>
4201 <a
4202 href={`/${owner}/${repo}/claude`}
4203 style="display:block;background:#1f6feb;color:#fff;text-align:center;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-bottom:8px"
4204 >
4205 Claude Code sessions
4206 </a>
4207 <a
4208 href={`/${owner}/${repo}/ask`}
4209 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4210 >
4211 Ask AI about this repo
4212 </a>
4213 </div>
4214 )}
544d842Claude4215 </aside>
4216 </div>
4217 <script
4218 dangerouslySetInnerHTML={{
4219 __html: `
4220 (function(){
4221 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4222 tabs.forEach(function(tab){
4223 tab.addEventListener('click', function(){
4224 var target = tab.getAttribute('data-pane');
4225 tabs.forEach(function(t){
4226 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4227 });
4228 var panes = document.querySelectorAll('.repo-home-clone-pane');
4229 panes.forEach(function(p){
4230 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4231 });
4232 });
4233 });
4234 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4235 copyBtns.forEach(function(btn){
4236 btn.addEventListener('click', function(){
4237 var text = btn.getAttribute('data-repo-home-copy') || '';
4238 var done = function(){
4239 var prev = btn.textContent;
4240 btn.textContent = 'Copied';
4241 setTimeout(function(){ btn.textContent = prev; }, 1200);
4242 };
4243 if (navigator.clipboard && navigator.clipboard.writeText) {
4244 navigator.clipboard.writeText(text).then(done, done);
4245 } else {
4246 var ta = document.createElement('textarea');
4247 ta.value = text;
4248 document.body.appendChild(ta);
4249 ta.select();
4250 try { document.execCommand('copy'); } catch (e) {}
4251 document.body.removeChild(ta);
4252 done();
4253 }
4254 });
4255 });
4256 })();
4257 `,
4258 }}
4259 />
fc1817aClaude4260 </Layout>
4261 );
4262});
4263
4264// Browse tree at ref/path
4265web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4266 const { owner, repo } = c.req.param();
06d5ffeClaude4267 const user = c.get("user");
fc1817aClaude4268 const refAndPath = c.req.param("ref");
4269
4270 const branches = await listBranches(owner, repo);
4271 let ref = "";
4272 let treePath = "";
4273
4274 for (const branch of branches) {
4275 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4276 ref = branch;
4277 treePath = refAndPath.slice(branch.length + 1);
4278 break;
4279 }
4280 }
4281
4282 if (!ref) {
4283 const slashIdx = refAndPath.indexOf("/");
4284 if (slashIdx === -1) {
4285 ref = refAndPath;
4286 } else {
4287 ref = refAndPath.slice(0, slashIdx);
4288 treePath = refAndPath.slice(slashIdx + 1);
4289 }
4290 }
4291
4292 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4293 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4294 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4295
4296 return c.html(
06d5ffeClaude4297 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4298 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4299 <RepoHeader owner={owner} repo={repo} />
4300 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4301 <div class="tree-header">
4302 <div class="tree-header-row">
4303 <BranchSwitcher
4304 owner={owner}
4305 repo={repo}
4306 currentRef={ref}
4307 branches={branches}
4308 pathType="tree"
4309 subPath={treePath}
4310 />
4311 <div class="tree-header-stats">
4312 <span class="tree-stat" title="Entries in this directory">
4313 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4314 {dirCount > 0 && (
4315 <>
4316 {" · "}
4317 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4318 </>
4319 )}
4320 </span>
4321 <a
4322 href={`/${owner}/${repo}/search`}
4323 class="tree-stat tree-stat-link"
4324 title="Search code in this repository"
4325 >
4326 {"⌕"} Search
4327 </a>
4328 </div>
4329 </div>
4330 <div class="tree-breadcrumb-row">
4331 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4332 </div>
4333 </div>
fc1817aClaude4334 <FileTable
4335 entries={tree}
4336 owner={owner}
4337 repo={repo}
4338 ref={ref}
4339 path={treePath}
4340 />
4341 </Layout>
4342 );
4343});
4344
06d5ffeClaude4345// View file blob with syntax highlighting
fc1817aClaude4346web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4347 const { owner, repo } = c.req.param();
06d5ffeClaude4348 const user = c.get("user");
fc1817aClaude4349 const refAndPath = c.req.param("ref");
4350
4351 const branches = await listBranches(owner, repo);
4352 let ref = "";
4353 let filePath = "";
4354
4355 for (const branch of branches) {
4356 if (refAndPath.startsWith(branch + "/")) {
4357 ref = branch;
4358 filePath = refAndPath.slice(branch.length + 1);
4359 break;
4360 }
4361 }
4362
4363 if (!ref) {
4364 const slashIdx = refAndPath.indexOf("/");
4365 if (slashIdx === -1) return c.text("Not found", 404);
4366 ref = refAndPath.slice(0, slashIdx);
4367 filePath = refAndPath.slice(slashIdx + 1);
4368 }
4369
4370 const blob = await getBlob(owner, repo, ref, filePath);
4371 if (!blob) {
4372 return c.html(
06d5ffeClaude4373 <Layout title="Not Found" user={user}>
fc1817aClaude4374 <div class="empty-state">
4375 <h2>File not found</h2>
4376 </div>
4377 </Layout>,
4378 404
4379 );
4380 }
4381
06d5ffeClaude4382 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4383 const lineCount = blob.isBinary
4384 ? 0
4385 : (blob.content.endsWith("\n")
4386 ? blob.content.split("\n").length - 1
4387 : blob.content.split("\n").length);
4388 const formatBytes = (n: number): string => {
4389 if (n < 1024) return `${n} B`;
4390 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4391 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4392 };
fc1817aClaude4393
4394 return c.html(
06d5ffeClaude4395 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4396 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4397 <RepoHeader owner={owner} repo={repo} />
4398 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4399 <div class="blob-toolbar">
4400 <BranchSwitcher
4401 owner={owner}
4402 repo={repo}
4403 currentRef={ref}
4404 branches={branches}
4405 pathType="blob"
4406 subPath={filePath}
4407 />
4408 <div class="blob-breadcrumb">
4409 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4410 </div>
4411 </div>
4412 <div class="blob-view blob-card">
4413 <div class="blob-header blob-header-polished">
4414 <div class="blob-header-meta">
4415 <span class="blob-header-icon" aria-hidden="true">
4416 {"📄"}
4417 </span>
4418 <span class="blob-header-name">{fileName}</span>
4419 <span class="blob-header-size">
4420 {formatBytes(blob.size)}
4421 {!blob.isBinary && (
4422 <>
4423 {" · "}
4424 {lineCount} line{lineCount === 1 ? "" : "s"}
4425 </>
4426 )}
4427 </span>
4428 </div>
4429 <div class="blob-header-actions">
4430 <a
4431 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4432 class="blob-pill"
4433 >
79136bbClaude4434 Raw
4435 </a>
efb11c5Claude4436 <a
4437 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4438 class="blob-pill"
4439 >
79136bbClaude4440 Blame
4441 </a>
efb11c5Claude4442 <a
4443 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4444 class="blob-pill"
4445 >
16b325cClaude4446 History
4447 </a>
0074234Claude4448 {user && (
efb11c5Claude4449 <a
4450 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4451 class="blob-pill blob-pill-accent"
4452 >
0074234Claude4453 Edit
4454 </a>
4455 )}
efb11c5Claude4456 </div>
fc1817aClaude4457 </div>
4458 {blob.isBinary ? (
efb11c5Claude4459 <div class="blob-binary">
fc1817aClaude4460 Binary file not shown.
4461 </div>
06d5ffeClaude4462 ) : (() => {
4463 const { html: highlighted, language } = highlightCode(
4464 blob.content,
4465 fileName
4466 );
4467 if (language) {
4468 return (
4469 <HighlightedCode
4470 highlightedHtml={highlighted}
efb11c5Claude4471 lineCount={lineCount}
06d5ffeClaude4472 />
4473 );
4474 }
4475 const lines = blob.content.split("\n");
4476 if (lines[lines.length - 1] === "") lines.pop();
4477 return <PlainCode lines={lines} />;
4478 })()}
fc1817aClaude4479 </div>
4480 </Layout>
4481 );
4482});
4483
398a10cClaude4484// ─── Branches list ────────────────────────────────────────────────────────
4485// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4486// author + relative time + ahead/behind vs the default branch. No DB. All
4487// data comes from git plumbing; failures degrade gracefully (counts omitted).
4488web.get("/:owner/:repo/branches", async (c) => {
4489 const { owner, repo } = c.req.param();
4490 const user = c.get("user");
4491
4492 if (!(await repoExists(owner, repo))) return c.notFound();
4493
4494 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4495 const branches = await listBranches(owner, repo);
4496 const repoDir = getRepoPath(owner, repo);
4497
4498 type BranchRow = {
4499 name: string;
4500 isDefault: boolean;
4501 sha: string;
4502 subject: string;
4503 author: string;
4504 date: string;
4505 ahead: number;
4506 behind: number;
4507 };
4508
4509 const runGit = async (args: string[]): Promise<string> => {
4510 try {
4511 const proc = Bun.spawn(["git", ...args], {
4512 cwd: repoDir,
4513 stdout: "pipe",
4514 stderr: "pipe",
4515 });
4516 const out = await new Response(proc.stdout).text();
4517 await proc.exited;
4518 return out;
4519 } catch {
4520 return "";
4521 }
4522 };
4523
4524 const meta = await runGit([
4525 "for-each-ref",
4526 "--sort=-committerdate",
4527 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4528 "refs/heads/",
4529 ]);
4530 const metaByName: Record<
4531 string,
4532 { sha: string; subject: string; author: string; date: string }
4533 > = {};
4534 for (const line of meta.split("\n").filter(Boolean)) {
4535 const [name, sha, subject, author, date] = line.split("\0");
4536 metaByName[name] = { sha, subject, author, date };
4537 }
4538
4539 const branchOrder = [...branches].sort((a, b) => {
4540 if (a === defaultBranch) return -1;
4541 if (b === defaultBranch) return 1;
4542 const aDate = metaByName[a]?.date || "";
4543 const bDate = metaByName[b]?.date || "";
4544 return bDate.localeCompare(aDate);
4545 });
4546
4547 const rows: BranchRow[] = [];
4548 for (const name of branchOrder) {
4549 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4550 let ahead = 0;
4551 let behind = 0;
4552 if (name !== defaultBranch && metaByName[defaultBranch]) {
4553 const out = await runGit([
4554 "rev-list",
4555 "--left-right",
4556 "--count",
4557 `${defaultBranch}...${name}`,
4558 ]);
4559 const parts = out.trim().split(/\s+/);
4560 if (parts.length === 2) {
4561 behind = parseInt(parts[0], 10) || 0;
4562 ahead = parseInt(parts[1], 10) || 0;
4563 }
4564 }
4565 rows.push({
4566 name,
4567 isDefault: name === defaultBranch,
4568 sha: m.sha,
4569 subject: m.subject,
4570 author: m.author,
4571 date: m.date,
4572 ahead,
4573 behind,
4574 });
4575 }
4576
4577 const relative = (iso: string): string => {
4578 if (!iso) return "—";
4579 const d = new Date(iso);
4580 if (Number.isNaN(d.getTime())) return "—";
4581 const diff = Date.now() - d.getTime();
4582 const m = Math.floor(diff / 60000);
4583 if (m < 1) return "just now";
4584 if (m < 60) return `${m} min ago`;
4585 const h = Math.floor(m / 60);
4586 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4587 const dd = Math.floor(h / 24);
4588 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4589 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4590 };
4591
4592 const success = c.req.query("success");
4593 const error = c.req.query("error");
4594
4595 return c.html(
4596 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4597 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4598 <RepoHeader owner={owner} repo={repo} />
4599 <RepoNav owner={owner} repo={repo} active="code" />
4600 <div
4601 class="branches-wrap"
eed4684Claude4602 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4603 >
4604 <header class="branches-head" style="margin-bottom:var(--space-5)">
4605 <div
4606 class="branches-eyebrow"
4607 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
4608 >
4609 <span
4610 class="branches-eyebrow-dot"
4611 aria-hidden="true"
4612 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4613 />
4614 Repository · Branches
4615 </div>
4616 <h1
4617 class="branches-title"
4618 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
4619 >
4620 <span class="gradient-text">{rows.length}</span>{" "}
4621 branch{rows.length === 1 ? "" : "es"}
4622 </h1>
4623 <p
4624 class="branches-sub"
4625 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4626 >
4627 All work-in-progress lines for{" "}
4628 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4629 counts are relative to{" "}
4630 <code style="font-size:12.5px">{defaultBranch}</code>.
4631 </p>
4632 </header>
4633
4634 {success && (
4635 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(52,211,153,0.40);background:rgba(52,211,153,0.08);color:#bbf7d0">
4636 {decodeURIComponent(success)}
4637 </div>
4638 )}
4639 {error && (
4640 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(248,113,113,0.40);background:rgba(248,113,113,0.08);color:#fecaca">
4641 {decodeURIComponent(error)}
4642 </div>
4643 )}
4644
4645 {rows.length === 0 ? (
4646 <div class="commits-empty">
4647 <div class="commits-empty-orb" aria-hidden="true" />
4648 <div class="commits-empty-inner">
4649 <div class="commits-empty-icon" aria-hidden="true">
4650 <svg
4651 width="22"
4652 height="22"
4653 viewBox="0 0 24 24"
4654 fill="none"
4655 stroke="currentColor"
4656 stroke-width="2"
4657 stroke-linecap="round"
4658 stroke-linejoin="round"
4659 >
4660 <line x1="6" y1="3" x2="6" y2="15" />
4661 <circle cx="18" cy="6" r="3" />
4662 <circle cx="6" cy="18" r="3" />
4663 <path d="M18 9a9 9 0 0 1-9 9" />
4664 </svg>
4665 </div>
4666 <h3 class="commits-empty-title">No branches yet</h3>
4667 <p class="commits-empty-sub">
4668 Push your first commit to create the default branch.
4669 </p>
4670 </div>
4671 </div>
4672 ) : (
4673 <div class="branches-list">
4674 {rows.map((r) => (
4675 <div class="branches-row">
4676 <div class="branches-row-icon" aria-hidden="true">
4677 <svg
4678 width="14"
4679 height="14"
4680 viewBox="0 0 24 24"
4681 fill="none"
4682 stroke="currentColor"
4683 stroke-width="2"
4684 stroke-linecap="round"
4685 stroke-linejoin="round"
4686 >
4687 <line x1="6" y1="3" x2="6" y2="15" />
4688 <circle cx="18" cy="6" r="3" />
4689 <circle cx="6" cy="18" r="3" />
4690 <path d="M18 9a9 9 0 0 1-9 9" />
4691 </svg>
4692 </div>
4693 <div class="branches-row-main">
4694 <div class="branches-row-name">
4695 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4696 {r.isDefault && (
4697 <span
4698 class="branches-row-default"
4699 title="Default branch"
4700 >
4701 Default
4702 </span>
4703 )}
4704 </div>
4705 <div class="branches-row-meta">
4706 {r.subject ? (
4707 <>
4708 <strong>{r.author || "—"}</strong>
4709 <span class="sep">·</span>
4710 <span
4711 title={r.date ? new Date(r.date).toISOString() : ""}
4712 >
4713 updated {relative(r.date)}
4714 </span>
4715 {r.sha && (
4716 <>
4717 <span class="sep">·</span>
4718 <a
4719 href={`/${owner}/${repo}/commit/${r.sha}`}
4720 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4721 >
4722 {r.sha.slice(0, 7)}
4723 </a>
4724 </>
4725 )}
4726 </>
4727 ) : (
4728 <span>No commit metadata</span>
4729 )}
4730 </div>
4731 </div>
4732 <div class="branches-row-side">
4733 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4734 <span
4735 class="branches-row-divergence"
4736 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4737 >
4738 <span class="ahead">{r.ahead} ahead</span>
4739 <span style="opacity:0.4">|</span>
4740 <span class="behind">{r.behind} behind</span>
4741 </span>
4742 )}
4743 <div class="branches-row-actions">
4744 <a
4745 href={`/${owner}/${repo}/commits/${r.name}`}
4746 class="branches-btn"
4747 title="View commits on this branch"
4748 >
4749 Commits
4750 </a>
4751 {!r.isDefault &&
4752 user &&
4753 user.username === owner && (
4754 <form
4755 method="post"
4756 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4757 style="margin:0"
4758 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4759 >
4760 <button
4761 type="submit"
4762 class="branches-btn branches-btn-danger"
4763 >
4764 Delete
4765 </button>
4766 </form>
4767 )}
4768 </div>
4769 </div>
4770 </div>
4771 ))}
4772 </div>
4773 )}
4774 </div>
4775 </Layout>
4776 );
4777});
4778
4779// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4780// that are not merged into the default branch — matches the explicit
4781// confirmation on the row's delete button.
4782web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4783 const { owner, repo } = c.req.param();
4784 const branchName = decodeURIComponent(c.req.param("name"));
4785 const user = c.get("user")!;
4786
4787 // Owner-only check (mirrors collaborators.tsx pattern).
4788 const [ownerRow] = await db
4789 .select()
4790 .from(users)
4791 .where(eq(users.username, owner))
4792 .limit(1);
4793 if (!ownerRow || ownerRow.id !== user.id) {
4794 return c.redirect(
4795 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4796 );
4797 }
4798
4799 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4800 if (branchName === defaultBranch) {
4801 return c.redirect(
4802 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4803 );
4804 }
4805
4806 const branches = await listBranches(owner, repo);
4807 if (!branches.includes(branchName)) {
4808 return c.redirect(
4809 `/${owner}/${repo}/branches?error=Branch+not+found`
4810 );
4811 }
4812
4813 try {
4814 const repoDir = getRepoPath(owner, repo);
4815 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4816 cwd: repoDir,
4817 stdout: "pipe",
4818 stderr: "pipe",
4819 });
4820 await proc.exited;
4821 if (proc.exitCode !== 0) {
4822 return c.redirect(
4823 `/${owner}/${repo}/branches?error=Delete+failed`
4824 );
4825 }
4826 } catch {
4827 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4828 }
4829
4830 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4831});
4832
4833// ─── Tags list ────────────────────────────────────────────────────────────
4834// Pulls from `listTags` (sorted newest first). For each tag we look up an
4835// associated release row so the "View release" CTA can deep-link directly
4836// without making the user hunt for it.
4837web.get("/:owner/:repo/tags", async (c) => {
4838 const { owner, repo } = c.req.param();
4839 const user = c.get("user");
4840
4841 if (!(await repoExists(owner, repo))) return c.notFound();
4842
4843 const tags = await listTags(owner, repo);
4844
4845 // Map tags -> releases. Best-effort; releases table may not exist in
4846 // every test setup, so any error falls through with an empty set.
4847 const tagsWithReleases = new Set<string>();
4848 try {
4849 const [ownerRow] = await db
4850 .select()
4851 .from(users)
4852 .where(eq(users.username, owner))
4853 .limit(1);
4854 if (ownerRow) {
4855 const [repoRow] = await db
4856 .select()
4857 .from(repositories)
4858 .where(
4859 and(
4860 eq(repositories.ownerId, ownerRow.id),
4861 eq(repositories.name, repo)
4862 )
4863 )
4864 .limit(1);
4865 if (repoRow) {
4866 // Raw SQL so we don't need to import the releases schema here.
4867 const result = await db.execute(
4868 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4869 );
4870 const rows: any[] = (result as any).rows || (result as any) || [];
4871 for (const row of rows) {
4872 const tag = row?.tag;
4873 if (typeof tag === "string") tagsWithReleases.add(tag);
4874 }
4875 }
4876 }
4877 } catch {
4878 // No releases table or DB error — leave set empty.
4879 }
4880
4881 const relative = (iso: string): string => {
4882 if (!iso) return "—";
4883 const d = new Date(iso);
4884 if (Number.isNaN(d.getTime())) return "—";
4885 const diff = Date.now() - d.getTime();
4886 const m = Math.floor(diff / 60000);
4887 if (m < 1) return "just now";
4888 if (m < 60) return `${m} min ago`;
4889 const h = Math.floor(m / 60);
4890 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4891 const dd = Math.floor(h / 24);
4892 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4893 return d.toLocaleDateString("en-US", {
4894 month: "short",
4895 day: "numeric",
4896 year: "numeric",
4897 });
4898 };
4899
4900 return c.html(
4901 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4902 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4903 <RepoHeader owner={owner} repo={repo} />
4904 <RepoNav owner={owner} repo={repo} active="code" />
4905 <div
4906 class="tags-wrap"
eed4684Claude4907 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4908 >
4909 <header class="tags-head" style="margin-bottom:var(--space-5)">
4910 <div
4911 class="tags-eyebrow"
4912 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
4913 >
4914 <span
4915 class="tags-eyebrow-dot"
4916 aria-hidden="true"
4917 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4918 />
4919 Repository · Tags
4920 </div>
4921 <h1
4922 class="tags-title"
4923 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
4924 >
4925 <span class="gradient-text">{tags.length}</span>{" "}
4926 tag{tags.length === 1 ? "" : "s"}
4927 </h1>
4928 <p
4929 class="tags-sub"
4930 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4931 >
4932 Named points in the history — typically releases, milestones,
4933 or shipped versions. Click a tag to browse the tree at that
4934 revision.
4935 </p>
4936 </header>
4937
4938 {tags.length === 0 ? (
4939 <div class="commits-empty">
4940 <div class="commits-empty-orb" aria-hidden="true" />
4941 <div class="commits-empty-inner">
4942 <div class="commits-empty-icon" aria-hidden="true">
4943 <svg
4944 width="22"
4945 height="22"
4946 viewBox="0 0 24 24"
4947 fill="none"
4948 stroke="currentColor"
4949 stroke-width="2"
4950 stroke-linecap="round"
4951 stroke-linejoin="round"
4952 >
4953 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
4954 <line x1="7" y1="7" x2="7.01" y2="7" />
4955 </svg>
4956 </div>
4957 <h3 class="commits-empty-title">No tags yet</h3>
4958 <p class="commits-empty-sub">
4959 Tag a commit to mark a release or milestone. From the CLI:{" "}
4960 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4961 </p>
4962 </div>
4963 </div>
4964 ) : (
4965 <div class="tags-list">
4966 {tags.map((t) => {
4967 const hasRelease = tagsWithReleases.has(t.name);
4968 return (
4969 <div class="tags-row">
4970 <div class="tags-row-icon" aria-hidden="true">
4971 <svg
4972 width="14"
4973 height="14"
4974 viewBox="0 0 24 24"
4975 fill="none"
4976 stroke="currentColor"
4977 stroke-width="2"
4978 stroke-linecap="round"
4979 stroke-linejoin="round"
4980 >
4981 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
4982 <line x1="7" y1="7" x2="7.01" y2="7" />
4983 </svg>
4984 </div>
4985 <div class="tags-row-main">
4986 <div class="tags-row-name">
4987 <a
4988 href={`/${owner}/${repo}/tree/${t.name}`}
4989 style="text-decoration:none"
4990 >
4991 <span class="tags-row-version">{t.name}</span>
4992 </a>
4993 </div>
4994 <div class="tags-row-meta">
4995 <span
4996 title={t.date ? new Date(t.date).toISOString() : ""}
4997 >
4998 Tagged {relative(t.date)}
4999 </span>
5000 <span class="sep">·</span>
5001 <a
5002 href={`/${owner}/${repo}/commit/${t.sha}`}
5003 class="tags-row-sha"
5004 title={t.sha}
5005 >
5006 {t.sha.slice(0, 7)}
5007 </a>
5008 </div>
5009 </div>
5010 <div class="tags-row-side">
5011 <a
5012 href={`/${owner}/${repo}/tree/${t.name}`}
5013 class="tags-row-link"
5014 >
5015 Browse files
5016 </a>
5017 {hasRelease && (
5018 <a
5019 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5020 class="tags-row-link"
5021 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
5022 >
5023 View release
5024 </a>
5025 )}
5026 </div>
5027 </div>
5028 );
5029 })}
5030 </div>
5031 )}
5032 </div>
5033 </Layout>
5034 );
5035});
5036
fc1817aClaude5037// Commit log
5038web.get("/:owner/:repo/commits/:ref?", async (c) => {
5039 const { owner, repo } = c.req.param();
06d5ffeClaude5040 const user = c.get("user");
fc1817aClaude5041 const ref =
5042 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5043 const branches = await listBranches(owner, repo);
fc1817aClaude5044
5045 const commits = await listCommits(owner, repo, ref, 50);
5046
3951454Claude5047 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5048 // Also resolve repoId here so we can query the recent push for the
5049 // Push Watch live/watch indicator in the header.
3951454Claude5050 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5051 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5052 try {
5053 const [ownerRow] = await db
5054 .select()
5055 .from(users)
5056 .where(eq(users.username, owner))
5057 .limit(1);
5058 if (ownerRow) {
5059 const [repoRow] = await db
5060 .select()
5061 .from(repositories)
5062 .where(
5063 and(
5064 eq(repositories.ownerId, ownerRow.id),
5065 eq(repositories.name, repo)
5066 )
5067 )
5068 .limit(1);
8c790e0Claude5069 if (repoRow) {
5070 // Fetch verifications and recent push in parallel.
5071 const [verRows, rp] = await Promise.all([
5072 commits.length > 0
5073 ? db
5074 .select()
5075 .from(commitVerifications)
5076 .where(
5077 and(
5078 eq(commitVerifications.repositoryId, repoRow.id),
5079 inArray(
5080 commitVerifications.commitSha,
5081 commits.map((c) => c.sha)
5082 )
5083 )
5084 )
5085 : Promise.resolve([]),
5086 getRecentPush(repoRow.id),
5087 ]);
5088 for (const r of verRows) {
3951454Claude5089 verifications[r.commitSha] = {
5090 verified: r.verified,
5091 reason: r.reason,
5092 };
5093 }
8c790e0Claude5094 commitsPageRecentPush = rp;
3951454Claude5095 }
5096 }
5097 } catch {
5098 // DB unavailable — skip the badges gracefully.
5099 }
5100
fc1817aClaude5101 return c.html(
06d5ffeClaude5102 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5103 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5104 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5105 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5106 <div class="commits-hero">
5107 <div class="commits-hero-orb-wrap" aria-hidden="true">
5108 <div class="commits-hero-orb" />
fc1817aClaude5109 </div>
efb11c5Claude5110 <div class="commits-hero-inner">
5111 <div class="commits-eyebrow">
5112 <strong>History</strong> · {owner}/{repo}
5113 </div>
5114 <h1 class="commits-title">
5115 <span class="gradient-text">{commits.length}</span> recent commit
5116 {commits.length === 1 ? "" : "s"} on{" "}
5117 <span class="commits-branch">{ref}</span>
5118 </h1>
5119 <p class="commits-sub">
5120 Browse the project's history. Click any commit to see the full
5121 diff, AI review notes, and signature status.
5122 </p>
5123 </div>
5124 </div>
5125 <div class="commits-toolbar">
5126 <BranchSwitcher
3951454Claude5127 owner={owner}
5128 repo={repo}
efb11c5Claude5129 currentRef={ref}
5130 branches={branches}
5131 pathType="commits"
3951454Claude5132 />
398a10cClaude5133 <div class="commits-toolbar-actions">
5134 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5135 {"⊢"} Branches
5136 </a>
5137 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5138 {"#"} Tags
5139 </a>
5140 </div>
efb11c5Claude5141 </div>
5142 {commits.length === 0 ? (
5143 <div class="commits-empty">
398a10cClaude5144 <div class="commits-empty-orb" aria-hidden="true" />
5145 <div class="commits-empty-inner">
5146 <div class="commits-empty-icon" aria-hidden="true">
5147 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5148 <circle cx="12" cy="12" r="4" />
5149 <line x1="1.05" y1="12" x2="7" y2="12" />
5150 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5151 </svg>
5152 </div>
5153 <h3 class="commits-empty-title">No commits yet</h3>
5154 <p class="commits-empty-sub">
5155 This branch is empty. Push your first commit, or use the
5156 web editor to create a file.
5157 </p>
5158 </div>
efb11c5Claude5159 </div>
5160 ) : (
5161 <div class="commits-list-wrap">
398a10cClaude5162 {(() => {
5163 // Group commits by day for the section headers.
5164 const dayLabel = (iso: string): string => {
5165 const d = new Date(iso);
5166 if (Number.isNaN(d.getTime())) return "Unknown";
5167 const today = new Date();
5168 const yesterday = new Date(today);
5169 yesterday.setDate(today.getDate() - 1);
5170 const sameDay = (a: Date, b: Date) =>
5171 a.getFullYear() === b.getFullYear() &&
5172 a.getMonth() === b.getMonth() &&
5173 a.getDate() === b.getDate();
5174 if (sameDay(d, today)) return "Today";
5175 if (sameDay(d, yesterday)) return "Yesterday";
5176 return d.toLocaleDateString("en-US", {
5177 month: "long",
5178 day: "numeric",
5179 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5180 });
5181 };
5182 const relative = (iso: string): string => {
5183 const d = new Date(iso);
5184 if (Number.isNaN(d.getTime())) return "";
5185 const diff = Date.now() - d.getTime();
5186 const m = Math.floor(diff / 60000);
5187 if (m < 1) return "just now";
5188 if (m < 60) return `${m} min ago`;
5189 const h = Math.floor(m / 60);
5190 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5191 const dd = Math.floor(h / 24);
5192 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5193 return d.toLocaleDateString("en-US", {
5194 month: "short",
5195 day: "numeric",
5196 });
5197 };
5198 const initial = (name: string): string =>
5199 (name || "?").trim().charAt(0).toUpperCase() || "?";
5200 // Build groups preserving order.
5201 const groups: Array<{ label: string; items: typeof commits }> = [];
5202 let lastLabel = "";
5203 for (const cm of commits) {
5204 const label = dayLabel(cm.date);
5205 if (label !== lastLabel) {
5206 groups.push({ label, items: [] });
5207 lastLabel = label;
5208 }
5209 groups[groups.length - 1].items.push(cm);
5210 }
5211 return groups.map((g) => (
5212 <>
5213 <div class="commits-day-head">
5214 <span class="commits-day-head-dot" aria-hidden="true" />
5215 Commits on {g.label}
5216 </div>
5217 {g.items.map((cm) => {
5218 const v = verifications[cm.sha];
5219 return (
5220 <div class="commits-row">
5221 <div class="commits-avatar" aria-hidden="true">
5222 {initial(cm.author)}
5223 </div>
5224 <div class="commits-row-body">
5225 <div class="commits-row-msg">
5226 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5227 {cm.message}
5228 </a>
5229 {v?.verified && (
5230 <span
5231 class="commits-row-verified"
5232 title="Signed with a registered key"
5233 >
5234 Verified
5235 </span>
5236 )}
5237 </div>
5238 <div class="commits-row-meta">
5239 <strong>{cm.author}</strong>
5240 <span class="sep">·</span>
5241 <span
5242 class="commits-row-time"
5243 title={new Date(cm.date).toISOString()}
5244 >
5245 committed {relative(cm.date)}
5246 </span>
5247 {cm.parentShas.length > 1 && (
5248 <>
5249 <span class="sep">·</span>
5250 <span>merge of {cm.parentShas.length} parents</span>
5251 </>
5252 )}
5253 </div>
5254 </div>
5255 <div class="commits-row-side">
5256 <a
5257 href={`/${owner}/${repo}/commit/${cm.sha}`}
5258 class="commits-row-sha"
5259 title={cm.sha}
5260 >
5261 {cm.sha.slice(0, 7)}
5262 </a>
8c790e0Claude5263 <a
5264 href={`/${owner}/${repo}/push/${cm.sha}`}
5265 class="commits-row-watch"
5266 title="Watch gate + deploy results for this push"
5267 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5268 >
5269 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
5270 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5271 <circle cx="12" cy="12" r="3" />
5272 </svg>
5273 </a>
398a10cClaude5274 <button
5275 type="button"
5276 class="commits-row-copy"
5277 data-copy-sha={cm.sha}
5278 title="Copy full SHA"
5279 aria-label={`Copy SHA ${cm.sha}`}
5280 >
5281 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5282 <path fill="currentColor" d="M4 1.5h6A1.5 1.5 0 0 1 11.5 3v1h-1V3a.5.5 0 0 0-.5-.5H4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h1v1H4A1.5 1.5 0 0 1 2.5 11V3A1.5 1.5 0 0 1 4 1.5Z" />
5283 <path fill="currentColor" d="M6 5.5h6A1.5 1.5 0 0 1 13.5 7v6A1.5 1.5 0 0 1 12 14.5H6A1.5 1.5 0 0 1 4.5 13V7A1.5 1.5 0 0 1 6 5.5Zm0 1A.5.5 0 0 0 5.5 7v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V7a.5.5 0 0 0-.5-.5H6Z" />
5284 </svg>
5285 </button>
5286 </div>
5287 </div>
5288 );
5289 })}
5290 </>
5291 ));
5292 })()}
efb11c5Claude5293 </div>
fc1817aClaude5294 )}
398a10cClaude5295 <script
5296 dangerouslySetInnerHTML={{
5297 __html: `
5298 (function(){
5299 document.addEventListener('click', function(e){
5300 var t = e.target; if (!t) return;
5301 var btn = t.closest && t.closest('[data-copy-sha]');
5302 if (!btn) return;
5303 e.preventDefault();
5304 var sha = btn.getAttribute('data-copy-sha') || '';
5305 if (!navigator.clipboard) return;
5306 navigator.clipboard.writeText(sha).then(function(){
5307 btn.classList.add('is-copied');
5308 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5309 }).catch(function(){});
5310 });
5311 })();
5312 `,
5313 }}
5314 />
fc1817aClaude5315 </Layout>
5316 );
5317});
5318
5319// Single commit with diff
5320web.get("/:owner/:repo/commit/:sha", async (c) => {
5321 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5322 const user = c.get("user");
fc1817aClaude5323
05b973eClaude5324 // Fetch commit, full message, and diff in parallel
5325 const [commit, fullMessage, diffResult] = await Promise.all([
5326 getCommit(owner, repo, sha),
5327 getCommitFullMessage(owner, repo, sha),
5328 getDiff(owner, repo, sha),
5329 ]);
fc1817aClaude5330 if (!commit) {
5331 return c.html(
06d5ffeClaude5332 <Layout title="Not Found" user={user}>
fc1817aClaude5333 <div class="empty-state">
5334 <h2>Commit not found</h2>
5335 </div>
5336 </Layout>,
5337 404
5338 );
5339 }
5340
3951454Claude5341 // Block J3 — try to verify this commit's signature.
5342 let verification:
5343 | { verified: boolean; reason: string; signatureType: string | null }
5344 | null = null;
0cdfd89Claude5345 // Block J8 — external CI commit statuses rollup.
5346 let statusCombined:
5347 | {
5348 state: "pending" | "success" | "failure";
5349 total: number;
5350 contexts: Array<{
5351 context: string;
5352 state: string;
5353 description: string | null;
5354 targetUrl: string | null;
5355 }>;
5356 }
5357 | null = null;
3951454Claude5358 try {
5359 const [ownerRow] = await db
5360 .select()
5361 .from(users)
5362 .where(eq(users.username, owner))
5363 .limit(1);
5364 if (ownerRow) {
5365 const [repoRow] = await db
5366 .select()
5367 .from(repositories)
5368 .where(
5369 and(
5370 eq(repositories.ownerId, ownerRow.id),
5371 eq(repositories.name, repo)
5372 )
5373 )
5374 .limit(1);
5375 if (repoRow) {
5376 const { verifyCommit } = await import("../lib/signatures");
5377 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5378 verification = {
5379 verified: v.verified,
5380 reason: v.reason,
5381 signatureType: v.signatureType,
5382 };
0cdfd89Claude5383 try {
5384 const { combinedStatus } = await import("../lib/commit-statuses");
5385 const combined = await combinedStatus(repoRow.id, commit.sha);
5386 if (combined.total > 0) {
5387 statusCombined = {
5388 state: combined.state as any,
5389 total: combined.total,
5390 contexts: combined.contexts.map((c) => ({
5391 context: c.context,
5392 state: c.state,
5393 description: c.description,
5394 targetUrl: c.targetUrl,
5395 })),
5396 };
5397 }
5398 } catch {
5399 statusCombined = null;
5400 }
3951454Claude5401 }
5402 }
5403 } catch {
5404 verification = null;
5405 }
5406
05b973eClaude5407 const { files, raw } = diffResult;
fc1817aClaude5408
efb11c5Claude5409 // Diff stats: count additions / deletions across all files for the
5410 // header summary bar. Computed here from the parsed diff so we don't
5411 // touch the DiffView component.
5412 let additions = 0;
5413 let deletions = 0;
5414 for (const f of files) {
5415 const hunks = (f as any).hunks as Array<any> | undefined;
5416 if (Array.isArray(hunks)) {
5417 for (const h of hunks) {
5418 const lines = (h?.lines || []) as Array<any>;
5419 for (const ln of lines) {
5420 const t = ln?.type || ln?.kind;
5421 if (t === "add" || t === "added" || t === "+") additions += 1;
5422 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5423 deletions += 1;
5424 }
5425 }
5426 }
5427 }
5428 // Fall back: scan raw if file-level counting yielded zero (it's just a
5429 // header polish — never let a parsing miss break the page).
5430 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5431 for (const line of raw.split("\n")) {
5432 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5433 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5434 }
5435 }
5436 const fileCount = files.length;
5437
fc1817aClaude5438 return c.html(
06d5ffeClaude5439 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5440 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5441 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5442 <div class="commit-detail-card">
5443 <div class="commit-detail-eyebrow">
5444 <strong>Commit</strong>
5445 <span class="commit-detail-sha-pill" title={commit.sha}>
5446 {commit.sha.slice(0, 7)}
5447 </span>
3951454Claude5448 {verification && verification.reason !== "unsigned" && (
5449 <span
efb11c5Claude5450 class={`commit-detail-verify ${
3951454Claude5451 verification.verified
efb11c5Claude5452 ? "commit-detail-verify-ok"
5453 : "commit-detail-verify-warn"
3951454Claude5454 }`}
5455 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5456 >
5457 {verification.verified ? "Verified" : verification.reason}
5458 </span>
5459 )}
fc1817aClaude5460 </div>
efb11c5Claude5461 <h1 class="commit-detail-title">{commit.message}</h1>
5462 {fullMessage !== commit.message && (
5463 <pre class="commit-detail-body">{fullMessage}</pre>
5464 )}
5465 <div class="commit-detail-meta">
5466 <span class="commit-detail-author">
5467 <strong>{commit.author}</strong> committed on{" "}
5468 {new Date(commit.date).toLocaleDateString("en-US", {
5469 month: "long",
5470 day: "numeric",
5471 year: "numeric",
5472 })}
5473 </span>
fc1817aClaude5474 {commit.parentShas.length > 0 && (
efb11c5Claude5475 <span class="commit-detail-parents">
5476 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5477 {commit.parentShas.map((p, idx) => (
5478 <>
5479 {idx > 0 && " "}
5480 <a
5481 href={`/${owner}/${repo}/commit/${p}`}
5482 class="commit-detail-sha-link"
5483 >
5484 {p.slice(0, 7)}
5485 </a>
5486 </>
fc1817aClaude5487 ))}
5488 </span>
5489 )}
5490 </div>
efb11c5Claude5491 <div class="commit-detail-stats">
5492 <span class="commit-detail-stat">
5493 <strong>{fileCount}</strong>{" "}
5494 file{fileCount === 1 ? "" : "s"} changed
5495 </span>
5496 <span class="commit-detail-stat commit-detail-stat-add">
5497 <span class="commit-detail-stat-mark">+</span>
5498 <strong>{additions}</strong>
5499 </span>
5500 <span class="commit-detail-stat commit-detail-stat-del">
5501 <span class="commit-detail-stat-mark">−</span>
5502 <strong>{deletions}</strong>
5503 </span>
5504 <span class="commit-detail-sha-full" title="Full SHA">
5505 {commit.sha}
5506 </span>
5507 </div>
0cdfd89Claude5508 {statusCombined && (
efb11c5Claude5509 <div class="commit-detail-checks">
5510 <div class="commit-detail-checks-head">
5511 <strong>Checks</strong>
5512 <span class="commit-detail-checks-summary">
5513 {statusCombined.total} total ·{" "}
5514 <span
5515 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5516 >
5517 {statusCombined.state}
5518 </span>
0cdfd89Claude5519 </span>
efb11c5Claude5520 </div>
5521 <div class="commit-detail-check-row">
0cdfd89Claude5522 {statusCombined.contexts.map((cx) => (
5523 <span
efb11c5Claude5524 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5525 title={cx.description || cx.context}
5526 >
5527 {cx.targetUrl ? (
efb11c5Claude5528 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5529 {cx.context}: {cx.state}
5530 </a>
5531 ) : (
5532 <>
5533 {cx.context}: {cx.state}
5534 </>
5535 )}
5536 </span>
5537 ))}
5538 </div>
5539 </div>
5540 )}
fc1817aClaude5541 </div>
ea9ed4cClaude5542 <DiffView
5543 raw={raw}
5544 files={files}
5545 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5546 />
fc1817aClaude5547 </Layout>
5548 );
5549});
5550
79136bbClaude5551// Raw file download
5552web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5553 const { owner, repo } = c.req.param();
5554 const refAndPath = c.req.param("ref");
5555
5556 const branches = await listBranches(owner, repo);
5557 let ref = "";
5558 let filePath = "";
5559
5560 for (const branch of branches) {
5561 if (refAndPath.startsWith(branch + "/")) {
5562 ref = branch;
5563 filePath = refAndPath.slice(branch.length + 1);
5564 break;
5565 }
5566 }
5567
5568 if (!ref) {
5569 const slashIdx = refAndPath.indexOf("/");
5570 if (slashIdx === -1) return c.text("Not found", 404);
5571 ref = refAndPath.slice(0, slashIdx);
5572 filePath = refAndPath.slice(slashIdx + 1);
5573 }
5574
5575 const data = await getRawBlob(owner, repo, ref, filePath);
5576 if (!data) return c.text("Not found", 404);
5577
5578 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5579 return new Response(data as BodyInit, {
79136bbClaude5580 headers: {
5581 "Content-Type": "application/octet-stream",
5582 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5583 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5584 },
5585 });
5586});
5587
5588// Blame view
5589web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5590 const { owner, repo } = c.req.param();
5591 const user = c.get("user");
5592 const refAndPath = c.req.param("ref");
5593
5594 const branches = await listBranches(owner, repo);
5595 let ref = "";
5596 let filePath = "";
5597
5598 for (const branch of branches) {
5599 if (refAndPath.startsWith(branch + "/")) {
5600 ref = branch;
5601 filePath = refAndPath.slice(branch.length + 1);
5602 break;
5603 }
5604 }
5605
5606 if (!ref) {
5607 const slashIdx = refAndPath.indexOf("/");
5608 if (slashIdx === -1) return c.text("Not found", 404);
5609 ref = refAndPath.slice(0, slashIdx);
5610 filePath = refAndPath.slice(slashIdx + 1);
5611 }
5612
5613 const blameLines = await getBlame(owner, repo, ref, filePath);
5614 if (blameLines.length === 0) {
5615 return c.html(
5616 <Layout title="Not Found" user={user}>
5617 <div class="empty-state">
5618 <h2>File not found</h2>
5619 </div>
5620 </Layout>,
5621 404
5622 );
5623 }
5624
5625 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5626 // Unique contributors (by author) tracked once for the header chip.
5627 const blameAuthors = new Set<string>();
5628 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5629
5630 return c.html(
5631 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5632 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5633 <RepoHeader owner={owner} repo={repo} />
5634 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5635 <header class="blame-head">
5636 <div class="blame-eyebrow">
5637 <span class="blame-eyebrow-dot" aria-hidden="true" />
5638 Blame · Line-by-line history
5639 </div>
5640 <h1 class="blame-title">
5641 <code>{fileName}</code>
5642 </h1>
5643 <p class="blame-sub">
5644 Each line is annotated with the commit that last touched it.
5645 Click any SHA to jump to that commit and see the surrounding
5646 change.
5647 </p>
5648 </header>
efb11c5Claude5649 <div class="blame-toolbar">
5650 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5651 </div>
398a10cClaude5652 <div class="blame-card">
5653 <div class="blame-header">
efb11c5Claude5654 <div class="blame-header-meta">
5655 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5656 <span class="blame-header-name">{fileName}</span>
5657 <span class="blame-header-tag">Blame</span>
5658 <span class="blame-header-stats">
5659 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5660 {blameAuthors.size} contributor
5661 {blameAuthors.size === 1 ? "" : "s"}
5662 </span>
5663 </div>
5664 <div class="blame-header-actions">
5665 <a
5666 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5667 class="blob-pill"
5668 >
5669 Normal view
5670 </a>
5671 <a
5672 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5673 class="blob-pill"
5674 >
5675 Raw
5676 </a>
5677 </div>
79136bbClaude5678 </div>
398a10cClaude5679 <div style="overflow-x:auto">
5680 <table class="blame-table">
79136bbClaude5681 <tbody>
5682 {blameLines.map((line, i) => {
5683 const showInfo =
5684 i === 0 || blameLines[i - 1].sha !== line.sha;
5685 return (
398a10cClaude5686 <tr class={showInfo ? "blame-row-first" : ""}>
5687 <td class="blame-gutter">
79136bbClaude5688 {showInfo && (
398a10cClaude5689 <span class="blame-gutter-inner">
79136bbClaude5690 <a
5691 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5692 class="blame-gutter-sha"
5693 title={`Commit ${line.sha}`}
79136bbClaude5694 >
5695 {line.sha.slice(0, 7)}
398a10cClaude5696 </a>
5697 <span class="blame-gutter-author" title={line.author}>
5698 {line.author}
5699 </span>
5700 </span>
79136bbClaude5701 )}
5702 </td>
398a10cClaude5703 <td class="blame-line-num">{line.lineNum}</td>
5704 <td class="blame-line-content">{line.content}</td>
79136bbClaude5705 </tr>
5706 );
5707 })}
5708 </tbody>
5709 </table>
5710 </div>
5711 </div>
5712 </Layout>
5713 );
5714});
5715
53c9249Claude5716// Search — keyword + optional Claude semantic mode
79136bbClaude5717web.get("/:owner/:repo/search", async (c) => {
5718 const { owner, repo } = c.req.param();
5719 const user = c.get("user");
5720 const q = c.req.query("q") || "";
53c9249Claude5721 const aiAvailable = isAiAvailable();
5722 // Default to semantic when Claude is available and no explicit mode set.
5723 const modeParam = c.req.query("mode");
5724 const mode: "semantic" | "keyword" =
5725 modeParam === "keyword"
5726 ? "keyword"
5727 : modeParam === "semantic"
5728 ? "semantic"
5729 : aiAvailable
5730 ? "semantic"
5731 : "keyword";
5732 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5733
5734 if (!(await repoExists(owner, repo))) return c.notFound();
5735
5736 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5737
5738 // Keyword results (always available as fallback / when in keyword mode)
5739 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5740 // Semantic results (Claude-powered)
5741 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5742 let semanticHits: SemanticHit[] = [];
5743 let semanticMode: "semantic" | "keyword" = "semantic";
5744 let quotaExceeded = false;
79136bbClaude5745
5746 if (q.trim()) {
53c9249Claude5747 if (isSemantic) {
5748 // Resolve repo DB id for caching
5749 const [ownerRow] = await db
5750 .select({ id: users.id })
5751 .from(users)
5752 .where(eq(users.username, owner))
5753 .limit(1);
5754 const [repoRow] = ownerRow
5755 ? await db
5756 .select({ id: repositories.id })
5757 .from(repositories)
5758 .where(
5759 and(
5760 eq(repositories.ownerId, ownerRow.id),
5761 eq(repositories.name, repo)
5762 )
5763 )
5764 .limit(1)
5765 : [];
5766
5767 const rateLimitKey = user
5768 ? `user:${user.id}`
5769 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5770
5771 const searchResult = await claudeSemanticSearch(
5772 owner,
5773 repo,
5774 repoRow?.id ?? `${owner}/${repo}`,
5775 q.trim(),
5776 { branch: defaultBranch, rateLimitKey }
5777 );
5778 semanticHits = searchResult.results;
5779 semanticMode = searchResult.mode;
5780 quotaExceeded = searchResult.quotaExceeded;
5781 } else {
5782 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5783 }
79136bbClaude5784 }
5785
53c9249Claude5786 const aiSearchCss = `
5787 /* ─── Semantic search mode toggle ─── */
5788 .search-mode-bar {
5789 display: flex;
5790 align-items: center;
5791 gap: 8px;
5792 margin-bottom: 14px;
5793 flex-wrap: wrap;
5794 }
5795 .search-mode-label {
5796 font-size: 12.5px;
5797 color: var(--text-muted);
5798 font-weight: 500;
5799 }
5800 .search-mode-toggle {
5801 display: inline-flex;
5802 background: var(--bg-elevated);
5803 border: 1px solid var(--border);
5804 border-radius: 9999px;
5805 padding: 3px;
5806 gap: 2px;
5807 }
5808 .search-mode-btn {
5809 display: inline-flex;
5810 align-items: center;
5811 gap: 5px;
5812 padding: 5px 12px;
5813 border-radius: 9999px;
5814 font-size: 12.5px;
5815 font-weight: 500;
5816 color: var(--text-muted);
5817 text-decoration: none;
5818 transition: color 120ms ease, background 120ms ease;
5819 cursor: pointer;
5820 border: none;
5821 background: none;
5822 font: inherit;
5823 }
5824 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5825 .search-mode-btn.active {
5826 background: rgba(140,109,255,0.15);
5827 color: var(--text-strong);
5828 }
5829 .search-mode-ai-badge {
5830 display: inline-flex;
5831 align-items: center;
5832 gap: 4px;
5833 padding: 2px 8px;
5834 border-radius: 9999px;
5835 font-size: 11px;
5836 font-weight: 600;
5837 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.15));
5838 color: #c4b5fd;
5839 border: 1px solid rgba(140,109,255,0.30);
5840 margin-left: 2px;
5841 }
5842 .search-quota-warn {
5843 font-size: 12.5px;
5844 color: var(--text-muted);
5845 padding: 6px 12px;
5846 background: rgba(255,200,0,0.06);
5847 border: 1px solid rgba(255,200,0,0.20);
5848 border-radius: 8px;
5849 }
5850
5851 /* ─── Semantic result cards ─── */
5852 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5853 .sem-result {
5854 padding: 14px 16px;
5855 background: var(--bg-elevated);
5856 border: 1px solid var(--border);
5857 border-radius: 12px;
5858 transition: border-color 120ms ease;
5859 }
5860 .sem-result:hover { border-color: var(--border-strong); }
5861 .sem-result-head {
5862 display: flex;
5863 align-items: flex-start;
5864 justify-content: space-between;
5865 gap: 10px;
5866 flex-wrap: wrap;
5867 margin-bottom: 6px;
5868 }
5869 .sem-result-path {
5870 font-family: var(--font-mono);
5871 font-size: 13px;
5872 font-weight: 600;
5873 color: var(--text-strong);
5874 text-decoration: none;
5875 word-break: break-all;
5876 }
5877 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5878 .sem-result-conf {
5879 display: inline-flex;
5880 align-items: center;
5881 gap: 4px;
5882 padding: 2px 9px;
5883 border-radius: 9999px;
5884 font-size: 11.5px;
5885 font-weight: 600;
5886 white-space: nowrap;
5887 flex-shrink: 0;
5888 }
5889 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
5890 .sem-conf-possible { background: rgba(140,109,255,0.12); color: #b69dff; border: 1px solid rgba(140,109,255,0.30); }
5891 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5892 .sem-result-reason {
5893 font-size: 12.5px;
5894 color: var(--text-muted);
5895 margin-bottom: 8px;
5896 line-height: 1.5;
5897 }
5898 .sem-result-snippet {
5899 margin: 0;
5900 padding: 10px 12px;
5901 background: rgba(0,0,0,0.22);
5902 border: 1px solid var(--border);
5903 border-radius: 8px;
5904 font-family: var(--font-mono);
5905 font-size: 12px;
5906 line-height: 1.55;
5907 color: var(--text);
5908 overflow-x: auto;
5909 white-space: pre-wrap;
5910 word-break: break-word;
5911 max-height: 240px;
5912 overflow-y: auto;
5913 }
5914 `;
5915
5916 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5917 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5918
79136bbClaude5919 return c.html(
5920 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude5921 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude5922 <RepoHeader owner={owner} repo={repo} />
5923 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5924 <div class="search-hero">
5925 <div class="search-eyebrow">
5926 <strong>Search</strong> · {owner}/{repo}
5927 </div>
5928 <h1 class="search-title">
53c9249Claude5929 {isSemantic ? (
5930 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5931 ) : (
5932 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5933 )}
efb11c5Claude5934 </h1>
5935 <form
5936 method="get"
5937 action={`/${owner}/${repo}/search`}
5938 class="search-form"
5939 role="search"
5940 >
53c9249Claude5941 <input type="hidden" name="mode" value={mode} />
efb11c5Claude5942 <div class="search-input-wrap">
5943 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5944 <input
5945 type="text"
5946 name="q"
5947 value={q}
53c9249Claude5948 placeholder={
5949 isSemantic
5950 ? "Ask a question or describe what you're looking for…"
5951 : "Search code on the default branch…"
5952 }
efb11c5Claude5953 aria-label="Search code"
5954 class="search-input"
5955 autocomplete="off"
5956 autofocus
5957 />
5958 </div>
5959 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5960 Search
5961 </button>
efb11c5Claude5962 </form>
5963 </div>
53c9249Claude5964
5965 {/* Mode toggle */}
5966 <div class="search-mode-bar">
5967 <span class="search-mode-label">Mode:</span>
5968 <div class="search-mode-toggle" role="group" aria-label="Search mode">
5969 {aiAvailable ? (
5970 <a
5971 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
5972 class={`search-mode-btn${isSemantic ? " active" : ""}`}
5973 aria-pressed={isSemantic ? "true" : "false"}
5974 >
5975 {"✨"} Semantic AI
5976 <span class="search-mode-ai-badge">AI-powered</span>
5977 </a>
5978 ) : null}
5979 <a
5980 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
5981 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
5982 aria-pressed={!isSemantic ? "true" : "false"}
5983 >
5984 {"⌕"} Keyword
5985 </a>
5986 </div>
5987 {isSemantic && aiAvailable && (
5988 <span style="font-size:12px;color:var(--text-muted)">
5989 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude5990 </span>
53c9249Claude5991 )}
5992 {quotaExceeded && (
5993 <span class="search-quota-warn">
5994 Semantic search quota reached — showing keyword results
efb11c5Claude5995 </span>
53c9249Claude5996 )}
5997 </div>
5998
5999 {/* ─── Semantic results ─── */}
6000 {isSemantic && q && (
6001 <>
6002 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6003 <div class="search-quota-warn" style="margin-bottom:10px">
6004 Falling back to keyword search — Claude found no relevant files.
6005 </div>
6006 )}
6007 {semanticHits.length === 0 ? (
6008 <div class="search-empty">
6009 <p>
6010 No matches for <strong>"{q}"</strong>.{" "}
6011 Try a different phrasing or{" "}
6012 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6013 switch to keyword search
6014 </a>.
6015 </p>
6016 </div>
6017 ) : (
6018 <>
6019 <div class="search-results-head">
6020 <span class="search-results-count">
6021 <strong>{semanticHits.length}</strong> result
6022 {semanticHits.length !== 1 ? "s" : ""}
6023 </span>
6024 <span class="search-results-query">
6025 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6026 <span class="search-results-q">"{q}"</span>
6027 </span>
6028 </div>
6029 <div class="sem-results">
6030 {semanticHits.map((h) => {
6031 const confClass =
6032 h.confidence > 0.7
6033 ? "sem-conf-strong"
6034 : h.confidence > 0.4
6035 ? "sem-conf-possible"
6036 : "sem-conf-weak";
6037 const confLabel =
6038 h.confidence > 0.7
6039 ? "Strong match"
6040 : h.confidence > 0.4
6041 ? "Possible match"
6042 : "Weak match";
6043 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6044 const preview =
6045 h.snippet.length > 800
6046 ? h.snippet.slice(0, 800) + "\n…"
6047 : h.snippet;
6048 return (
6049 <div class="sem-result">
6050 <div class="sem-result-head">
6051 <a href={href} class="sem-result-path">
6052 {h.file}
6053 {h.lineNumber ? (
6054 <span style="color:var(--text-muted);font-weight:500">
6055 :{h.lineNumber}
6056 </span>
6057 ) : null}
6058 </a>
6059 <span class={`sem-result-conf ${confClass}`}>
6060 {confLabel}
6061 </span>
6062 </div>
6063 {h.reason && (
6064 <p class="sem-result-reason">{h.reason}</p>
6065 )}
6066 {preview && (
6067 <pre class="sem-result-snippet">{preview}</pre>
6068 )}
6069 </div>
6070 );
6071 })}
6072 </div>
6073 </>
6074 )}
6075 </>
79136bbClaude6076 )}
53c9249Claude6077
6078 {/* ─── Keyword results ─── */}
6079 {!isSemantic && q && (
6080 <>
6081 <div class="search-results-head">
6082 <span class="search-results-count">
6083 <strong>{keywordResults.length}</strong> result
6084 {keywordResults.length !== 1 ? "s" : ""}
6085 </span>
6086 <span class="search-results-query">
6087 for <span class="search-results-q">"{q}"</span> on{" "}
6088 <code>{defaultBranch}</code>
6089 </span>
6090 </div>
6091 {keywordResults.length === 0 ? (
6092 <div class="search-empty">
6093 <p>
6094 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6095 {aiAvailable && (
6096 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6097 try AI semantic search
79136bbClaude6098 </a>
53c9249Claude6099 )}.
6100 </p>
6101 </div>
6102 ) : (
6103 <div class="search-results">
6104 {(() => {
6105 const grouped: Record<
6106 string,
6107 Array<{ lineNum: number; line: string }>
6108 > = {};
6109 for (const r of keywordResults) {
6110 if (!grouped[r.file]) grouped[r.file] = [];
6111 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6112 }
6113 return Object.entries(grouped).map(([file, matches]) => (
6114 <div class="search-file diff-file">
6115 <div class="search-file-head diff-file-header">
6116 <a
6117 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6118 class="search-file-link"
6119 >
6120 {file}
6121 </a>
6122 <span class="search-file-count">
6123 {matches.length} match{matches.length === 1 ? "" : "es"}
6124 </span>
6125 </div>
6126 <div class="blob-code">
6127 <table>
6128 <tbody>
6129 {matches.map((m) => (
6130 <tr>
6131 <td class="line-num">{m.lineNum}</td>
6132 <td class="line-content">{m.line}</td>
6133 </tr>
6134 ))}
6135 </tbody>
6136 </table>
6137 </div>
6138 </div>
6139 ));
6140 })()}
6141 </div>
6142 )}
6143 </>
6144 )}
79136bbClaude6145 </Layout>
6146 );
6147});
6148
fc1817aClaude6149export default web;