Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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.tsxBlame6192 lines · 4 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";
11c3ab6ccanty labs75import { AgentWorkspace } from "../views/agent-workspace";
76import { DailyBrief } from "../views/daily-brief";
77import { TrustReport } from "../views/trust-report";
78import { ProductionLayers } from "../views/production-layers";
79import { DistributionView } from "../views/distribution";
80import { OrgMemory } from "../views/org-memory";
fc1817aClaude81
06d5ffeClaude82const web = new Hono<AuthEnv>();
83
84// Soft auth on all web routes — c.get("user") available but may be null
85web.use("*", softAuth);
fc1817aClaude86
ebaae0fClaude87// ---------------------------------------------------------------------------
88// Repo AI stats — computed once per repo home page load, best-effort.
89// Fast because every WHERE clause is bounded to a 7-day window.
90// ---------------------------------------------------------------------------
91interface RepoAiStats {
92 mergedCount: number;
93 reviewCount: number;
94 securityAlertCount: number;
95 hoursSaved: string;
96}
97
98async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
99 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
100
101 // 1. AI-merged PRs this week: activity_feed entries with action =
102 // 'auto_merge.merged' in the last 7 days for this repo.
103 // This is cheaper than a JOIN on pull_requests and covers both the
104 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
105 const [mergedRow] = await db
106 .select({ n: count() })
107 .from(activityFeed)
108 .where(
109 and(
110 eq(activityFeed.repositoryId, repoId),
111 eq(activityFeed.action, "auto_merge.merged"),
112 gte(activityFeed.createdAt, since)
113 )
114 );
115 const mergedCount = Number(mergedRow?.n ?? 0);
116
117 // 2. AI reviews this week: pr_comments where is_ai_review = true in
118 // the last 7 days, joined through pull_requests to scope to this repo.
119 const [reviewRow] = await db
120 .select({ n: count() })
121 .from(prComments)
122 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
123 .where(
124 and(
125 eq(pullRequests.repositoryId, repoId),
126 eq(prComments.isAiReview, true),
127 gte(prComments.createdAt, since)
128 )
129 );
130 const reviewCount = Number(reviewRow?.n ?? 0);
131
132 // 3. Open security alerts: open issues that carry a label whose name
133 // contains 'security' (case-insensitive) for this repo.
134 const [secRow] = await db
135 .select({ n: count() })
136 .from(issues)
137 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
138 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
139 .where(
140 and(
141 eq(issues.repositoryId, repoId),
142 eq(issues.state, "open"),
143 sql`lower(${labels.name}) like '%security%'`
144 )
145 );
146 const securityAlertCount = Number(secRow?.n ?? 0);
147
148 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
149 const hours = mergedCount * 1.5 + reviewCount * 0.5;
150 const hoursSaved = hours.toFixed(1);
151
152 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
153}
154
8c790e0Claude155/**
156 * Query the most recent push to a repo from the activity_feed table.
157 * Returns a RecentPush if there's been a push in the last 24 hours,
158 * or null otherwise. Used by the RepoHeader live/watch indicator.
159 *
160 * Queries activity_feed where action='push' and targetId holds the commit SHA.
161 */
162async function getRecentPush(repoId: string): Promise<RecentPush | null> {
163 try {
164 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
165 const [row] = await db
166 .select({
167 targetId: activityFeed.targetId,
168 createdAt: activityFeed.createdAt,
169 })
170 .from(activityFeed)
171 .where(
172 and(
173 eq(activityFeed.repositoryId, repoId),
174 eq(activityFeed.action, "push"),
175 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
176 )
177 )
178 .orderBy(desc(activityFeed.createdAt))
179 .limit(1);
180 if (!row || !row.targetId) return null;
181 return {
182 sha: row.targetId,
183 ageMs: Date.now() - new Date(row.createdAt).getTime(),
184 };
185 } catch {
186 // DB unavailable or schema mismatch — degrade gracefully.
187 return null;
188 }
189}
190
efb11c5Claude191/**
192 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
193 *
194 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
195 * scope is route-local and `layout.tsx` is locked. Each polished handler
196 * injects this via a `<style>` tag; the rules are namespaced by surface
197 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
198 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
199 * `.repo-home-*` styling Agent A already shipped.
200 */
201const codeBrowseCss = `
202 /* ───────── shared primitives ───────── */
203 .cb-hairline::before,
204 .new-repo-hero::before,
205 .profile-hero::before,
206 .commits-hero::before,
207 .commit-detail-card::before,
208 .search-hero::before {
209 content: '';
210 position: absolute;
211 top: 0; left: 0; right: 0;
212 height: 2px;
6fd5915Claude213 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude214 opacity: 0.7;
215 pointer-events: none;
216 }
217 @keyframes cbHeroOrb {
218 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
219 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
220 }
221 @media (prefers-reduced-motion: reduce) {
222 .new-repo-hero-orb,
223 .profile-hero-orb,
224 .commits-hero-orb { animation: none; }
225 }
226
227 /* ───────── new-repo ───────── */
228 .new-repo-hero {
229 position: relative;
230 margin-bottom: var(--space-5);
231 padding: var(--space-5) var(--space-6);
232 background: var(--bg-elevated);
233 border: 1px solid var(--border);
234 border-radius: 16px;
235 overflow: hidden;
236 }
237 .new-repo-hero-orb-wrap {
238 position: absolute;
239 inset: -25% -10% auto auto;
240 width: 360px;
241 height: 360px;
242 pointer-events: none;
243 z-index: 0;
244 }
245 .new-repo-hero-orb {
246 position: absolute;
247 inset: 0;
6fd5915Claude248 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude249 filter: blur(80px);
250 opacity: 0.7;
251 animation: cbHeroOrb 14s ease-in-out infinite;
252 }
253 .new-repo-hero-inner { position: relative; z-index: 1; }
254 .new-repo-eyebrow {
255 font-size: 12px;
256 font-family: var(--font-mono);
257 color: var(--text-muted);
258 letter-spacing: 0.1em;
259 text-transform: uppercase;
260 margin-bottom: var(--space-2);
261 }
262 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
263 .new-repo-title {
264 font-family: var(--font-display);
265 font-weight: 800;
266 letter-spacing: -0.028em;
267 font-size: clamp(28px, 4vw, 40px);
268 line-height: 1.05;
269 margin: 0 0 var(--space-2);
270 color: var(--text-strong);
271 }
272 .new-repo-sub {
273 font-size: 15px;
274 color: var(--text-muted);
275 margin: 0;
276 line-height: 1.55;
277 max-width: 620px;
278 }
279 .new-repo-form {
280 max-width: 680px;
281 }
282 .new-repo-error {
283 background: rgba(218, 54, 51, 0.12);
284 border: 1px solid rgba(218, 54, 51, 0.35);
285 color: #ffb3b3;
286 padding: 10px 14px;
287 border-radius: 10px;
288 margin-bottom: var(--space-4);
289 font-size: 14px;
290 }
291 .new-repo-form-grid {
292 display: flex;
293 flex-direction: column;
294 gap: var(--space-4);
295 }
296 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
297 .new-repo-label {
298 font-size: 13px;
299 color: var(--text-strong);
300 font-weight: 600;
301 }
302 .new-repo-label-optional {
303 color: var(--text-muted);
304 font-weight: 400;
305 font-size: 12px;
306 }
307 .new-repo-input {
308 appearance: none;
309 width: 100%;
310 background: var(--bg);
311 border: 1px solid var(--border);
312 color: var(--text-strong);
313 border-radius: 10px;
314 padding: 10px 12px;
315 font-size: 14px;
316 font-family: inherit;
317 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
318 }
319 .new-repo-input:focus {
320 outline: none;
321 border-color: var(--accent);
6fd5915Claude322 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude323 }
324 .new-repo-input-disabled {
325 color: var(--text-muted);
326 background: var(--bg-secondary);
327 cursor: not-allowed;
328 }
329 .new-repo-hint {
330 font-size: 12px;
331 color: var(--text-muted);
332 margin: 4px 0 0;
333 }
334 .new-repo-hint code {
335 font-family: var(--font-mono);
336 font-size: 11.5px;
337 background: var(--bg-secondary);
338 border: 1px solid var(--border);
339 border-radius: 5px;
340 padding: 1px 5px;
341 color: var(--text-strong);
342 }
343 .new-repo-visibility {
344 display: grid;
345 grid-template-columns: 1fr 1fr;
346 gap: var(--space-2);
347 }
348 @media (max-width: 600px) {
349 .new-repo-visibility { grid-template-columns: 1fr; }
350 }
351 .new-repo-vis-card {
352 display: flex;
353 gap: 10px;
354 padding: 14px;
355 background: var(--bg);
356 border: 1px solid var(--border);
357 border-radius: 12px;
358 cursor: pointer;
359 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
360 }
361 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
362 .new-repo-vis-card:has(input:checked) {
6fd5915Claude363 border-color: rgba(91,110,232,0.55);
364 background: rgba(91,110,232,0.06);
365 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude366 }
367 .new-repo-vis-radio {
368 margin-top: 3px;
369 accent-color: var(--accent);
370 }
371 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
372 .new-repo-vis-label {
373 font-size: 14px;
374 font-weight: 600;
375 color: var(--text-strong);
376 }
377 .new-repo-vis-desc {
378 font-size: 12.5px;
379 color: var(--text-muted);
380 line-height: 1.45;
381 }
382 .new-repo-callout {
383 margin-top: var(--space-2);
384 padding: var(--space-3) var(--space-4);
6fd5915Claude385 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
386 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude387 border-radius: 12px;
388 }
389 .new-repo-callout-eyebrow {
390 font-size: 11px;
391 font-family: var(--font-mono);
392 text-transform: uppercase;
393 letter-spacing: 0.12em;
394 color: var(--accent);
395 font-weight: 700;
396 margin-bottom: 4px;
397 }
398 .new-repo-callout-body {
399 font-size: 13px;
400 color: var(--text);
401 line-height: 1.5;
402 margin: 0;
403 }
404 .new-repo-callout-body code {
405 font-family: var(--font-mono);
406 font-size: 12px;
407 color: var(--accent);
6fd5915Claude408 background: rgba(91,110,232,0.1);
efb11c5Claude409 border-radius: 4px;
410 padding: 1px 5px;
411 }
398a10cClaude412 .new-repo-templates {
413 display: flex;
414 flex-wrap: wrap;
415 gap: 8px;
416 margin-top: 4px;
417 }
418 .new-repo-template-chip {
419 position: relative;
420 display: inline-flex;
421 align-items: center;
422 gap: 6px;
423 padding: 8px 14px;
424 background: var(--bg);
425 border: 1px solid var(--border);
426 border-radius: 999px;
427 font-size: 13px;
428 color: var(--text);
429 cursor: pointer;
430 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
431 }
432 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
433 .new-repo-template-chip:hover {
434 border-color: var(--border-strong, var(--border));
435 color: var(--text-strong);
436 }
437 .new-repo-template-chip:has(input:checked) {
6fd5915Claude438 border-color: rgba(91,110,232,0.55);
439 background: rgba(91,110,232,0.10);
398a10cClaude440 color: var(--text-strong);
6fd5915Claude441 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude442 }
443 .new-repo-template-chip-dot {
444 width: 8px; height: 8px;
445 border-radius: 999px;
446 background: var(--text-faint, var(--text-muted));
447 transition: background 140ms ease, box-shadow 140ms ease;
448 }
449 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude450 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
451 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude452 }
efb11c5Claude453 .new-repo-actions {
454 display: flex;
455 gap: var(--space-2);
456 margin-top: var(--space-2);
398a10cClaude457 align-items: center;
458 }
459 .new-repo-submit {
460 min-width: 180px;
6fd5915Claude461 border: 1px solid rgba(91,110,232,0.45);
462 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude463 color: #fff;
464 font-weight: 700;
6fd5915Claude465 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude466 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
467 }
468 .new-repo-submit:hover {
469 transform: translateY(-1px);
6fd5915Claude470 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude471 filter: brightness(1.06);
472 }
473 .new-repo-submit:focus-visible {
6fd5915Claude474 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude475 outline-offset: 2px;
efb11c5Claude476 }
477
478 /* ───────── profile ───────── */
479 .profile-hero {
480 position: relative;
481 margin-bottom: var(--space-5);
482 padding: var(--space-5) var(--space-6);
483 background: var(--bg-elevated);
484 border: 1px solid var(--border);
485 border-radius: 16px;
486 overflow: hidden;
487 }
488 .profile-hero-orb-wrap {
489 position: absolute;
490 inset: -25% -10% auto auto;
491 width: 360px;
492 height: 360px;
493 pointer-events: none;
494 z-index: 0;
495 }
496 .profile-hero-orb {
497 position: absolute;
498 inset: 0;
6fd5915Claude499 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude500 filter: blur(80px);
501 opacity: 0.7;
502 animation: cbHeroOrb 14s ease-in-out infinite;
503 }
504 .profile-hero-inner {
505 position: relative;
506 z-index: 1;
507 display: flex;
508 align-items: flex-start;
509 gap: var(--space-5);
510 }
511 .profile-hero-avatar {
512 flex: 0 0 auto;
513 width: 88px;
514 height: 88px;
515 border-radius: 50%;
6fd5915Claude516 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude517 color: #fff;
518 display: flex;
519 align-items: center;
520 justify-content: center;
521 font-size: 38px;
522 font-weight: 700;
523 font-family: var(--font-display);
6fd5915Claude524 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude525 }
526 .profile-hero-text { flex: 1; min-width: 0; }
527 .profile-eyebrow {
528 font-size: 12px;
529 font-family: var(--font-mono);
530 color: var(--text-muted);
531 letter-spacing: 0.1em;
532 text-transform: uppercase;
533 margin-bottom: var(--space-2);
534 }
535 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
536 .profile-name {
537 font-family: var(--font-display);
538 font-weight: 800;
539 letter-spacing: -0.028em;
540 font-size: clamp(28px, 3.6vw, 36px);
541 line-height: 1.05;
542 margin: 0 0 4px;
543 color: var(--text-strong);
544 }
545 .profile-handle {
546 font-family: var(--font-mono);
547 font-size: 13px;
548 color: var(--text-muted);
549 margin-bottom: var(--space-2);
550 }
551 .profile-bio {
552 font-size: 14.5px;
553 color: var(--text);
554 line-height: 1.55;
555 margin: 0 0 var(--space-3);
556 max-width: 640px;
557 }
558 .profile-meta {
559 display: flex;
560 align-items: center;
561 gap: var(--space-4);
562 flex-wrap: wrap;
563 font-size: 13px;
564 }
565 .profile-meta-link {
566 color: var(--text-muted);
567 transition: color var(--t-fast, 0.15s) ease;
568 }
569 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
570 .profile-meta-link strong {
571 color: var(--text-strong);
572 font-weight: 600;
573 font-variant-numeric: tabular-nums;
574 }
575 .profile-follow-form { margin: 0; }
576 @media (max-width: 600px) {
577 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
578 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
579 }
580 .profile-readme {
581 margin-bottom: var(--space-6);
582 background: var(--bg-elevated);
583 border: 1px solid var(--border);
584 border-radius: 12px;
585 overflow: hidden;
586 }
587 .profile-readme-head {
588 display: flex;
589 align-items: center;
590 gap: 8px;
591 padding: 10px 16px;
592 background: var(--bg-secondary);
593 border-bottom: 1px solid var(--border);
594 font-size: 13px;
595 color: var(--text-muted);
596 }
597 .profile-readme-icon { color: var(--accent); font-size: 14px; }
598 .profile-readme-body { padding: var(--space-5) var(--space-6); }
599 .profile-section-head {
600 display: flex;
601 align-items: baseline;
602 gap: var(--space-2);
603 margin-bottom: var(--space-3);
604 }
605 .profile-section-title {
606 font-family: var(--font-display);
607 font-weight: 700;
608 font-size: 20px;
609 letter-spacing: -0.015em;
610 margin: 0;
611 color: var(--text-strong);
612 }
613 .profile-section-count {
614 font-family: var(--font-mono);
615 font-size: 12px;
616 color: var(--text-muted);
617 background: var(--bg-secondary);
618 border: 1px solid var(--border);
619 border-radius: 999px;
620 padding: 2px 10px;
621 font-variant-numeric: tabular-nums;
622 }
623 .profile-empty {
624 display: flex;
625 align-items: center;
626 justify-content: space-between;
627 gap: var(--space-3);
628 padding: var(--space-4) var(--space-5);
629 background: var(--bg-elevated);
630 border: 1px dashed var(--border);
631 border-radius: 12px;
632 }
633 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
634
635 /* ───────── tree (file browser) ───────── */
636 .tree-header {
637 margin-bottom: var(--space-3);
638 display: flex;
639 flex-direction: column;
640 gap: var(--space-2);
641 }
642 .tree-header-row {
643 display: flex;
644 align-items: center;
645 justify-content: space-between;
646 gap: var(--space-3);
647 flex-wrap: wrap;
648 }
649 .tree-header-stats {
650 display: flex;
651 align-items: center;
652 gap: var(--space-3);
653 font-size: 12px;
654 color: var(--text-muted);
655 }
656 .tree-stat strong {
657 color: var(--text-strong);
658 font-weight: 600;
659 font-variant-numeric: tabular-nums;
660 }
661 .tree-stat-link {
662 color: var(--text-muted);
663 padding: 4px 10px;
664 border-radius: 999px;
665 border: 1px solid var(--border);
666 background: var(--bg-elevated);
667 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
668 }
669 .tree-stat-link:hover {
670 color: var(--accent);
6fd5915Claude671 border-color: rgba(91,110,232,0.45);
efb11c5Claude672 text-decoration: none;
673 }
674 .tree-breadcrumb-row {
675 font-size: 13px;
676 }
677
678 /* ───────── blob (file viewer) ───────── */
679 .blob-toolbar {
680 margin-bottom: var(--space-3);
681 display: flex;
682 flex-direction: column;
683 gap: var(--space-2);
684 }
685 .blob-breadcrumb { font-size: 13px; }
686 .blob-card {
687 background: var(--bg-elevated);
688 border: 1px solid var(--border);
689 border-radius: 12px;
690 overflow: hidden;
691 }
692 .blob-header-polished {
693 display: flex;
694 align-items: center;
695 justify-content: space-between;
696 gap: var(--space-3);
697 padding: 10px 14px;
698 background: var(--bg-secondary);
699 border-bottom: 1px solid var(--border);
700 }
701 .blob-header-meta {
702 display: flex;
703 align-items: center;
704 gap: var(--space-2);
705 min-width: 0;
706 flex: 1;
707 }
708 .blob-header-icon { font-size: 14px; opacity: 0.85; }
709 .blob-header-name {
710 font-family: var(--font-mono);
711 font-size: 13px;
712 color: var(--text-strong);
713 font-weight: 600;
714 overflow: hidden;
715 text-overflow: ellipsis;
716 white-space: nowrap;
717 }
718 .blob-header-size {
719 font-family: var(--font-mono);
720 font-size: 11.5px;
721 color: var(--text-muted);
722 font-variant-numeric: tabular-nums;
723 border-left: 1px solid var(--border);
724 padding-left: var(--space-2);
725 margin-left: 2px;
726 }
727 .blob-header-actions {
728 display: flex;
729 gap: 6px;
730 flex-shrink: 0;
731 }
732 .blob-pill {
733 display: inline-flex;
734 align-items: center;
735 padding: 4px 12px;
736 font-size: 12px;
737 font-weight: 500;
738 color: var(--text);
739 background: var(--bg-elevated);
740 border: 1px solid var(--border);
741 border-radius: 999px;
742 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
743 }
744 .blob-pill:hover {
745 color: var(--accent);
6fd5915Claude746 border-color: rgba(91,110,232,0.45);
efb11c5Claude747 text-decoration: none;
6fd5915Claude748 background: rgba(91,110,232,0.06);
efb11c5Claude749 }
750 .blob-pill-accent {
751 color: var(--accent);
6fd5915Claude752 border-color: rgba(91,110,232,0.35);
753 background: rgba(91,110,232,0.08);
efb11c5Claude754 }
755 .blob-pill-accent:hover {
6fd5915Claude756 background: rgba(91,110,232,0.14);
efb11c5Claude757 }
758 .blob-binary {
759 padding: var(--space-5);
760 color: var(--text-muted);
761 text-align: center;
762 font-size: 13px;
763 background: var(--bg);
764 }
765
766 /* ───────── commits list ───────── */
767 .commits-hero {
768 position: relative;
769 margin-bottom: var(--space-4);
770 padding: var(--space-5) var(--space-6);
771 background: var(--bg-elevated);
772 border: 1px solid var(--border);
773 border-radius: 16px;
774 overflow: hidden;
775 }
776 .commits-hero-orb-wrap {
777 position: absolute;
778 inset: -25% -10% auto auto;
779 width: 320px;
780 height: 320px;
781 pointer-events: none;
782 z-index: 0;
783 }
784 .commits-hero-orb {
785 position: absolute;
786 inset: 0;
6fd5915Claude787 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude788 filter: blur(80px);
789 opacity: 0.7;
790 animation: cbHeroOrb 14s ease-in-out infinite;
791 }
792 .commits-hero-inner { position: relative; z-index: 1; }
793 .commits-eyebrow {
794 font-size: 12px;
795 font-family: var(--font-mono);
796 color: var(--text-muted);
797 letter-spacing: 0.1em;
798 text-transform: uppercase;
799 margin-bottom: var(--space-2);
800 }
801 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
802 .commits-title {
803 font-family: var(--font-display);
804 font-weight: 800;
805 letter-spacing: -0.025em;
806 font-size: clamp(22px, 3vw, 30px);
807 line-height: 1.15;
808 margin: 0 0 var(--space-2);
809 color: var(--text-strong);
810 }
811 .commits-branch {
812 font-family: var(--font-mono);
813 font-size: 0.7em;
814 color: var(--text);
815 background: var(--bg-secondary);
816 border: 1px solid var(--border);
817 border-radius: 6px;
818 padding: 2px 8px;
819 font-weight: 500;
820 vertical-align: middle;
821 }
822 .commits-sub {
823 font-size: 14px;
824 color: var(--text-muted);
825 margin: 0;
826 line-height: 1.55;
827 max-width: 640px;
828 }
398a10cClaude829 .commits-toolbar {
830 display: flex;
831 justify-content: space-between;
832 align-items: center;
833 flex-wrap: wrap;
834 gap: var(--space-2);
835 margin-bottom: var(--space-3);
836 }
837 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
838 .commits-toolbar-link {
839 display: inline-flex;
840 align-items: center;
841 gap: 6px;
842 padding: 6px 12px;
843 font-size: 12.5px;
844 font-weight: 500;
845 color: var(--text-muted);
846 background: rgba(255,255,255,0.025);
847 border: 1px solid var(--border);
848 border-radius: 8px;
849 text-decoration: none;
850 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
851 }
852 .commits-toolbar-link:hover {
853 border-color: var(--border-strong);
854 color: var(--text-strong);
855 background: rgba(255,255,255,0.04);
856 text-decoration: none;
857 }
efb11c5Claude858 .commits-list-wrap {
859 background: var(--bg-elevated);
860 border: 1px solid var(--border);
398a10cClaude861 border-radius: 14px;
efb11c5Claude862 overflow: hidden;
398a10cClaude863 position: relative;
864 }
865 .commits-list-wrap::before {
866 content: '';
867 position: absolute;
868 top: 0; left: 0; right: 0;
869 height: 2px;
6fd5915Claude870 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude871 opacity: 0.55;
872 pointer-events: none;
873 }
874 .commits-day-head {
875 display: flex;
876 align-items: center;
877 gap: 10px;
878 padding: 10px 18px;
879 font-size: 11.5px;
880 font-family: var(--font-mono);
881 text-transform: uppercase;
882 letter-spacing: 0.08em;
883 color: var(--text-muted);
884 background: var(--bg-secondary);
885 border-bottom: 1px solid var(--border);
886 }
887 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
888 .commits-day-head-dot {
889 width: 6px; height: 6px;
890 border-radius: 9999px;
6fd5915Claude891 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude892 }
893 .commits-row {
894 display: flex;
895 align-items: flex-start;
896 gap: 14px;
897 padding: 14px 18px;
898 border-bottom: 1px solid var(--border-subtle);
899 transition: background 120ms ease;
900 }
901 .commits-row:last-child { border-bottom: none; }
902 .commits-row:hover { background: rgba(255,255,255,0.022); }
903 .commits-avatar {
904 width: 34px; height: 34px;
905 border-radius: 9999px;
6fd5915Claude906 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude907 color: #fff;
908 display: inline-flex;
909 align-items: center;
910 justify-content: center;
911 font-family: var(--font-display);
912 font-weight: 700;
913 font-size: 13.5px;
914 flex-shrink: 0;
915 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
916 }
917 .commits-row-body { flex: 1; min-width: 0; }
918 .commits-row-msg {
919 display: flex;
920 align-items: center;
921 gap: 8px;
922 flex-wrap: wrap;
923 }
924 .commits-row-msg a {
925 font-family: var(--font-display);
926 font-weight: 600;
927 font-size: 14px;
928 color: var(--text-strong);
929 text-decoration: none;
930 letter-spacing: -0.005em;
931 }
932 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
933 .commits-row-verified {
934 font-size: 9.5px;
935 padding: 1px 7px;
936 border-radius: 9999px;
937 background: rgba(52,211,153,0.16);
938 color: #6ee7b7;
939 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
940 text-transform: uppercase;
941 letter-spacing: 0.06em;
942 font-weight: 700;
943 }
944 .commits-row-meta {
945 margin-top: 4px;
946 display: flex;
947 align-items: center;
948 gap: 8px;
949 flex-wrap: wrap;
950 font-size: 12.5px;
951 color: var(--text-muted);
952 }
953 .commits-row-meta strong { color: var(--text); font-weight: 600; }
954 .commits-row-meta .sep { opacity: 0.4; }
955 .commits-row-time {
956 font-variant-numeric: tabular-nums;
957 }
958 .commits-row-side {
959 display: flex;
960 align-items: center;
961 gap: 8px;
962 flex-shrink: 0;
963 }
964 .commits-row-sha {
965 display: inline-flex;
966 align-items: center;
967 padding: 4px 10px;
968 border-radius: 9999px;
969 font-family: var(--font-mono);
970 font-size: 11.5px;
971 font-weight: 600;
972 color: var(--text-strong);
973 background: rgba(255,255,255,0.04);
974 border: 1px solid var(--border);
975 text-decoration: none;
976 letter-spacing: 0.04em;
977 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
978 }
979 .commits-row-sha:hover {
6fd5915Claude980 border-color: rgba(91,110,232,0.55);
398a10cClaude981 color: var(--accent);
6fd5915Claude982 background: rgba(91,110,232,0.08);
398a10cClaude983 text-decoration: none;
984 }
985 .commits-row-copy {
986 display: inline-flex;
987 align-items: center;
988 justify-content: center;
989 width: 28px; height: 28px;
990 padding: 0;
991 border-radius: 8px;
992 background: transparent;
993 border: 1px solid var(--border);
994 color: var(--text-muted);
995 cursor: pointer;
996 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude997 }
398a10cClaude998 .commits-row-copy:hover {
999 border-color: var(--border-strong);
1000 color: var(--text);
1001 background: rgba(255,255,255,0.04);
1002 }
1003 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude1004 /* Push Watch link — eye icon next to the SHA chip */
1005 .commits-row-watch {
1006 display: inline-flex;
1007 align-items: center;
1008 justify-content: center;
1009 width: 28px;
1010 height: 28px;
1011 border-radius: 6px;
1012 border: 1px solid var(--border);
1013 color: var(--text-muted);
1014 background: transparent;
1015 cursor: pointer;
1016 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1017 text-decoration: none !important;
1018 }
1019 .commits-row-watch:hover {
6fd5915Claude1020 border-color: rgba(91,110,232,0.45);
8c790e0Claude1021 color: var(--accent);
6fd5915Claude1022 background: rgba(91,110,232,0.08);
8c790e0Claude1023 text-decoration: none !important;
1024 }
efb11c5Claude1025 .commits-empty {
398a10cClaude1026 position: relative;
1027 overflow: hidden;
1028 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1029 text-align: center;
398a10cClaude1030 background: var(--bg-elevated);
1031 border: 1px dashed var(--border-strong);
1032 border-radius: 16px;
1033 }
1034 .commits-empty-orb {
1035 position: absolute;
1036 inset: -40% 30% auto 30%;
1037 height: 280px;
6fd5915Claude1038 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1039 filter: blur(70px);
1040 opacity: 0.7;
1041 pointer-events: none;
1042 z-index: 0;
1043 }
1044 .commits-empty-inner { position: relative; z-index: 1; }
1045 .commits-empty-icon {
1046 width: 56px; height: 56px;
1047 border-radius: 9999px;
6fd5915Claude1048 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1049 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1050 display: inline-flex;
1051 align-items: center;
1052 justify-content: center;
1053 color: #c4b5fd;
1054 margin: 0 auto 14px;
1055 }
1056 .commits-empty-title {
1057 font-family: var(--font-display);
1058 font-size: 18px;
1059 font-weight: 700;
1060 margin: 0 0 6px;
1061 color: var(--text-strong);
1062 }
1063 .commits-empty-sub {
1064 margin: 0 auto 0;
1065 font-size: 13.5px;
efb11c5Claude1066 color: var(--text-muted);
398a10cClaude1067 max-width: 420px;
1068 line-height: 1.5;
1069 }
1070
1071 /* ───────── branches list ───────── */
1072 .branches-list {
efb11c5Claude1073 background: var(--bg-elevated);
398a10cClaude1074 border: 1px solid var(--border);
1075 border-radius: 14px;
1076 overflow: hidden;
1077 position: relative;
1078 }
1079 .branches-list::before {
1080 content: '';
1081 position: absolute;
1082 top: 0; left: 0; right: 0;
1083 height: 2px;
6fd5915Claude1084 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1085 opacity: 0.55;
1086 pointer-events: none;
1087 }
1088 .branches-row {
1089 display: flex;
1090 align-items: center;
1091 gap: var(--space-3);
1092 padding: 14px 18px;
1093 border-bottom: 1px solid var(--border-subtle);
1094 transition: background 120ms ease;
1095 flex-wrap: wrap;
1096 }
1097 .branches-row:last-child { border-bottom: none; }
1098 .branches-row:hover { background: rgba(255,255,255,0.022); }
1099 .branches-row-icon {
1100 width: 32px; height: 32px;
1101 border-radius: 8px;
6fd5915Claude1102 background: rgba(91,110,232,0.10);
398a10cClaude1103 color: #c4b5fd;
1104 display: inline-flex;
1105 align-items: center;
1106 justify-content: center;
1107 flex-shrink: 0;
6fd5915Claude1108 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1109 }
1110 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1111 .branches-row-name {
1112 display: inline-flex;
1113 align-items: center;
1114 gap: 8px;
1115 flex-wrap: wrap;
1116 }
1117 .branches-row-name a {
1118 font-family: var(--font-mono);
1119 font-size: 13.5px;
1120 color: var(--text-strong);
1121 font-weight: 600;
1122 text-decoration: none;
1123 }
1124 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1125 .branches-row-default {
1126 font-size: 10px;
1127 padding: 2px 8px;
1128 border-radius: 9999px;
6fd5915Claude1129 background: rgba(91,110,232,0.14);
398a10cClaude1130 color: #c4b5fd;
6fd5915Claude1131 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1132 text-transform: uppercase;
1133 letter-spacing: 0.08em;
1134 font-weight: 700;
1135 font-family: var(--font-mono);
1136 }
1137 .branches-row-meta {
1138 display: flex;
1139 align-items: center;
1140 gap: 8px;
1141 flex-wrap: wrap;
1142 font-size: 12.5px;
1143 color: var(--text-muted);
1144 font-variant-numeric: tabular-nums;
1145 }
1146 .branches-row-meta .sep { opacity: 0.4; }
1147 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1148 .branches-row-side {
1149 display: flex;
1150 align-items: center;
1151 gap: 10px;
1152 flex-shrink: 0;
1153 flex-wrap: wrap;
1154 }
1155 .branches-row-divergence {
1156 display: inline-flex;
1157 align-items: center;
1158 gap: 8px;
1159 font-family: var(--font-mono);
1160 font-size: 11.5px;
1161 color: var(--text-muted);
1162 padding: 4px 10px;
1163 border-radius: 9999px;
1164 background: rgba(255,255,255,0.035);
1165 border: 1px solid var(--border);
1166 font-variant-numeric: tabular-nums;
1167 }
1168 .branches-row-divergence .ahead { color: #6ee7b7; }
1169 .branches-row-divergence .behind { color: #fca5a5; }
1170 .branches-row-actions {
1171 display: flex;
1172 align-items: center;
1173 gap: 6px;
1174 }
1175 .branches-btn {
1176 display: inline-flex;
1177 align-items: center;
1178 gap: 5px;
1179 padding: 6px 12px;
1180 border-radius: 9999px;
1181 font-size: 12px;
1182 font-weight: 600;
1183 text-decoration: none;
1184 border: 1px solid var(--border);
1185 background: transparent;
1186 color: var(--text-muted);
1187 cursor: pointer;
1188 font: inherit;
1189 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1190 }
1191 .branches-btn:hover {
1192 border-color: var(--border-strong);
1193 color: var(--text);
1194 background: rgba(255,255,255,0.04);
1195 text-decoration: none;
1196 }
1197 .branches-btn-danger {
1198 color: #fca5a5;
1199 border-color: rgba(248,113,113,0.30);
1200 }
1201 .branches-btn-danger:hover {
1202 border-style: dashed;
1203 border-color: rgba(248,113,113,0.65);
1204 background: rgba(248,113,113,0.06);
1205 color: #fecaca;
1206 }
1207
1208 /* ───────── tags list ───────── */
1209 .tags-list {
1210 background: var(--bg-elevated);
1211 border: 1px solid var(--border);
1212 border-radius: 14px;
1213 overflow: hidden;
1214 position: relative;
1215 }
1216 .tags-list::before {
1217 content: '';
1218 position: absolute;
1219 top: 0; left: 0; right: 0;
1220 height: 2px;
6fd5915Claude1221 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1222 opacity: 0.55;
1223 pointer-events: none;
1224 }
1225 .tags-row {
1226 display: flex;
1227 align-items: center;
1228 gap: var(--space-3);
1229 padding: 14px 18px;
1230 border-bottom: 1px solid var(--border-subtle);
1231 transition: background 120ms ease;
1232 flex-wrap: wrap;
1233 }
1234 .tags-row:last-child { border-bottom: none; }
1235 .tags-row:hover { background: rgba(255,255,255,0.022); }
1236 .tags-row-icon {
1237 width: 32px; height: 32px;
1238 border-radius: 8px;
6fd5915Claude1239 background: rgba(95,143,160,0.10);
398a10cClaude1240 color: #67e8f9;
1241 display: inline-flex;
1242 align-items: center;
1243 justify-content: center;
1244 flex-shrink: 0;
6fd5915Claude1245 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1246 }
1247 .tags-row-main { flex: 1; min-width: 240px; }
1248 .tags-row-name {
1249 display: inline-flex;
1250 align-items: center;
1251 gap: 8px;
1252 flex-wrap: wrap;
1253 }
1254 .tags-row-version {
1255 display: inline-flex;
1256 align-items: center;
1257 padding: 3px 11px;
1258 border-radius: 9999px;
1259 font-family: var(--font-mono);
1260 font-size: 13px;
1261 font-weight: 700;
1262 color: #67e8f9;
6fd5915Claude1263 background: rgba(95,143,160,0.12);
1264 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1265 letter-spacing: 0.01em;
1266 }
1267 .tags-row-meta {
1268 margin-top: 4px;
1269 display: flex;
1270 align-items: center;
1271 gap: 8px;
1272 flex-wrap: wrap;
1273 font-size: 12.5px;
1274 color: var(--text-muted);
1275 font-variant-numeric: tabular-nums;
1276 }
1277 .tags-row-meta .sep { opacity: 0.4; }
1278 .tags-row-sha {
1279 font-family: var(--font-mono);
1280 font-size: 11.5px;
1281 color: var(--text-strong);
1282 padding: 2px 8px;
1283 border-radius: 6px;
1284 background: rgba(255,255,255,0.04);
1285 border: 1px solid var(--border);
1286 text-decoration: none;
1287 letter-spacing: 0.04em;
1288 }
1289 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1290 .tags-row-side {
1291 display: flex;
1292 align-items: center;
1293 gap: 6px;
1294 flex-shrink: 0;
1295 flex-wrap: wrap;
1296 }
1297 .tags-row-link {
1298 display: inline-flex;
1299 align-items: center;
1300 gap: 5px;
1301 padding: 6px 12px;
1302 border-radius: 9999px;
1303 font-size: 12px;
1304 font-weight: 600;
1305 text-decoration: none;
1306 color: var(--text-muted);
1307 background: rgba(255,255,255,0.025);
1308 border: 1px solid var(--border);
1309 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1310 }
1311 .tags-row-link:hover {
6fd5915Claude1312 border-color: rgba(91,110,232,0.45);
398a10cClaude1313 color: var(--text-strong);
6fd5915Claude1314 background: rgba(91,110,232,0.06);
398a10cClaude1315 text-decoration: none;
efb11c5Claude1316 }
1317
1318 /* ───────── commit detail ───────── */
1319 .commit-detail-card {
1320 position: relative;
1321 margin-bottom: var(--space-5);
1322 padding: var(--space-5) var(--space-5);
1323 background: var(--bg-elevated);
1324 border: 1px solid var(--border);
1325 border-radius: 14px;
1326 overflow: hidden;
1327 }
1328 .commit-detail-eyebrow {
1329 display: flex;
1330 align-items: center;
1331 gap: var(--space-2);
1332 font-size: 12px;
1333 font-family: var(--font-mono);
1334 text-transform: uppercase;
1335 letter-spacing: 0.1em;
1336 color: var(--text-muted);
1337 margin-bottom: var(--space-2);
1338 }
1339 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1340 .commit-detail-sha-pill {
1341 font-family: var(--font-mono);
1342 font-size: 11.5px;
1343 color: var(--text-strong);
1344 background: var(--bg-secondary);
1345 border: 1px solid var(--border);
1346 border-radius: 999px;
1347 padding: 2px 10px;
1348 letter-spacing: 0.04em;
1349 }
1350 .commit-detail-verify {
1351 font-size: 10px;
1352 padding: 2px 8px;
1353 border-radius: 999px;
1354 text-transform: uppercase;
1355 letter-spacing: 0.06em;
1356 font-weight: 700;
1357 color: #fff;
1358 }
1359 .commit-detail-verify-ok {
1360 background: linear-gradient(135deg, #2ea043, #34d399);
1361 }
1362 .commit-detail-verify-warn {
1363 background: linear-gradient(135deg, #d29922, #f59e0b);
1364 }
1365 .commit-detail-title {
1366 font-family: var(--font-display);
1367 font-weight: 700;
1368 letter-spacing: -0.018em;
1369 font-size: clamp(20px, 2.5vw, 26px);
1370 line-height: 1.25;
1371 margin: 0 0 var(--space-2);
1372 color: var(--text-strong);
1373 }
1374 .commit-detail-body {
1375 white-space: pre-wrap;
1376 color: var(--text-muted);
1377 font-size: 14px;
1378 line-height: 1.55;
1379 margin: 0 0 var(--space-3);
1380 font-family: var(--font-mono);
1381 background: var(--bg);
1382 border: 1px solid var(--border);
1383 border-radius: 8px;
1384 padding: var(--space-3) var(--space-4);
1385 max-height: 280px;
1386 overflow: auto;
1387 }
1388 .commit-detail-meta {
1389 display: flex;
1390 flex-wrap: wrap;
1391 gap: var(--space-3);
1392 font-size: 13px;
1393 color: var(--text-muted);
1394 margin-bottom: var(--space-3);
1395 }
1396 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1397 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1398 .commit-detail-sha-link {
1399 font-family: var(--font-mono);
1400 font-size: 12.5px;
1401 color: var(--accent);
6fd5915Claude1402 background: rgba(91,110,232,0.08);
efb11c5Claude1403 border-radius: 6px;
1404 padding: 1px 6px;
1405 margin-left: 2px;
1406 }
6fd5915Claude1407 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1408 .commit-detail-stats {
1409 display: flex;
1410 flex-wrap: wrap;
1411 align-items: center;
1412 gap: var(--space-3);
1413 padding-top: var(--space-3);
1414 border-top: 1px solid var(--border);
1415 font-size: 13px;
1416 color: var(--text-muted);
1417 }
1418 .commit-detail-stat {
1419 display: inline-flex;
1420 align-items: center;
1421 gap: 4px;
1422 font-variant-numeric: tabular-nums;
1423 }
1424 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1425 .commit-detail-stat-add strong { color: #34d399; }
1426 .commit-detail-stat-del strong { color: #f87171; }
1427 .commit-detail-stat-mark {
1428 font-family: var(--font-mono);
1429 font-weight: 700;
1430 font-size: 14px;
1431 }
1432 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1433 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1434 .commit-detail-sha-full {
1435 margin-left: auto;
1436 font-family: var(--font-mono);
1437 font-size: 11.5px;
1438 color: var(--text-faint);
1439 letter-spacing: 0.02em;
1440 overflow: hidden;
1441 text-overflow: ellipsis;
1442 white-space: nowrap;
1443 max-width: 100%;
1444 }
1445 .commit-detail-checks {
1446 margin-top: var(--space-3);
1447 padding-top: var(--space-3);
1448 border-top: 1px solid var(--border);
1449 }
1450 .commit-detail-checks-head {
1451 display: flex;
1452 align-items: center;
1453 gap: var(--space-2);
1454 font-size: 13px;
1455 color: var(--text-muted);
1456 margin-bottom: 8px;
1457 }
1458 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1459 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1460 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1461 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1462 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1463 .commit-detail-check {
1464 font-size: 11px;
1465 padding: 2px 8px;
1466 border-radius: 999px;
1467 color: #fff;
1468 font-weight: 500;
1469 }
398a10cClaude1470 .commit-detail-check a { color: inherit; text-decoration: none; }
1471 .commit-detail-check-success { background: #2ea043; }
1472 .commit-detail-check-pending { background: #d29922; }
1473 .commit-detail-check-failure { background: #da3633; }
1474
1475 /* ───────── blame ───────── */
1476 .blame-head { margin-bottom: var(--space-5); }
1477 .blame-eyebrow {
1478 display: inline-flex;
1479 align-items: center;
1480 gap: 8px;
1481 text-transform: uppercase;
1482 font-family: var(--font-mono);
1483 font-size: 11px;
1484 letter-spacing: 0.16em;
1485 color: var(--text-muted);
1486 font-weight: 600;
1487 margin-bottom: 10px;
1488 }
1489 .blame-eyebrow-dot {
1490 width: 8px; height: 8px;
1491 border-radius: 9999px;
6fd5915Claude1492 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1493 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1494 }
1495 .blame-title {
1496 font-family: var(--font-display);
1497 font-size: clamp(22px, 3vw, 30px);
1498 font-weight: 800;
1499 letter-spacing: -0.025em;
1500 line-height: 1.15;
1501 margin: 0 0 6px;
1502 color: var(--text-strong);
1503 }
1504 .blame-title code {
1505 font-family: var(--font-mono);
1506 font-size: 0.78em;
1507 color: var(--text-strong);
1508 font-weight: 700;
1509 }
1510 .blame-sub {
1511 margin: 0;
1512 font-size: 14px;
1513 color: var(--text-muted);
1514 line-height: 1.5;
1515 max-width: 700px;
1516 }
efb11c5Claude1517 .blame-toolbar {
398a10cClaude1518 display: flex;
1519 justify-content: space-between;
1520 align-items: center;
1521 gap: var(--space-3);
efb11c5Claude1522 margin-bottom: var(--space-3);
398a10cClaude1523 flex-wrap: wrap;
efb11c5Claude1524 font-size: 13px;
1525 }
398a10cClaude1526 .blame-toolbar-actions { display: flex; gap: 6px; }
1527 .blame-card {
1528 background: var(--bg-elevated);
1529 border: 1px solid var(--border);
1530 border-radius: 14px;
1531 overflow: hidden;
1532 position: relative;
1533 }
1534 .blame-card::before {
1535 content: '';
1536 position: absolute;
1537 top: 0; left: 0; right: 0;
1538 height: 2px;
6fd5915Claude1539 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1540 opacity: 0.55;
1541 pointer-events: none;
1542 }
efb11c5Claude1543 .blame-header {
1544 display: flex;
1545 align-items: center;
1546 justify-content: space-between;
1547 gap: var(--space-3);
1548 padding: 10px 14px;
1549 background: var(--bg-secondary);
1550 border-bottom: 1px solid var(--border);
1551 }
1552 .blame-header-meta {
1553 display: flex;
1554 align-items: center;
1555 gap: var(--space-2);
1556 min-width: 0;
1557 flex-wrap: wrap;
1558 }
1559 .blame-header-icon { color: var(--accent); font-size: 14px; }
1560 .blame-header-name {
1561 font-family: var(--font-mono);
1562 font-size: 13px;
1563 color: var(--text-strong);
1564 font-weight: 600;
1565 }
1566 .blame-header-tag {
1567 font-size: 10.5px;
1568 text-transform: uppercase;
1569 letter-spacing: 0.08em;
1570 font-family: var(--font-mono);
6fd5915Claude1571 background: rgba(91,110,232,0.12);
efb11c5Claude1572 color: var(--accent);
1573 border-radius: 999px;
1574 padding: 2px 8px;
1575 font-weight: 600;
1576 }
1577 .blame-header-stats {
1578 font-family: var(--font-mono);
1579 font-size: 11.5px;
1580 color: var(--text-muted);
1581 font-variant-numeric: tabular-nums;
1582 border-left: 1px solid var(--border);
1583 padding-left: var(--space-2);
1584 }
1585 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1586 .blame-table {
1587 width: 100%;
1588 border-collapse: collapse;
1589 font-family: var(--font-mono);
1590 font-size: 12.5px;
1591 line-height: 1.6;
1592 }
1593 .blame-table tr { border-bottom: 1px solid transparent; }
1594 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1595 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1596 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1597 .blame-gutter {
1598 width: 220px;
1599 min-width: 220px;
1600 padding: 0 12px;
1601 vertical-align: top;
1602 background: rgba(255,255,255,0.012);
1603 border-right: 1px solid var(--border-subtle);
1604 font-variant-numeric: tabular-nums;
1605 color: var(--text-muted);
1606 font-size: 11px;
1607 white-space: nowrap;
1608 overflow: hidden;
1609 text-overflow: ellipsis;
1610 padding-top: 2px;
1611 padding-bottom: 2px;
1612 }
1613 .blame-gutter-inner {
1614 display: inline-flex;
1615 align-items: center;
1616 gap: 7px;
1617 max-width: 100%;
1618 overflow: hidden;
1619 }
1620 .blame-gutter-sha {
1621 font-family: var(--font-mono);
1622 font-size: 10.5px;
1623 font-weight: 600;
1624 color: #c4b5fd;
6fd5915Claude1625 background: rgba(91,110,232,0.10);
398a10cClaude1626 padding: 1px 7px;
1627 border-radius: 9999px;
6fd5915Claude1628 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1629 text-decoration: none;
1630 letter-spacing: 0.04em;
1631 flex-shrink: 0;
1632 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1633 }
1634 .blame-gutter-sha:hover {
6fd5915Claude1635 background: rgba(91,110,232,0.22);
398a10cClaude1636 color: #ddd6fe;
6fd5915Claude1637 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1638 text-decoration: none;
1639 }
1640 .blame-gutter-author {
1641 color: var(--text);
1642 overflow: hidden;
1643 text-overflow: ellipsis;
1644 white-space: nowrap;
1645 font-size: 11px;
1646 font-family: var(--font-sans, inherit);
1647 }
1648 .blame-line-num {
1649 width: 1%;
1650 min-width: 50px;
1651 padding: 0 12px;
1652 text-align: right;
1653 color: var(--text-faint);
1654 user-select: none;
1655 border-right: 1px solid var(--border-subtle);
1656 font-variant-numeric: tabular-nums;
1657 }
1658 .blame-line-content {
1659 padding: 0 14px;
1660 white-space: pre;
1661 color: var(--text);
1662 transition: background 120ms ease;
1663 }
efb11c5Claude1664
1665 /* ───────── search ───────── */
1666 .search-hero {
1667 position: relative;
1668 margin-bottom: var(--space-4);
1669 padding: var(--space-5) var(--space-6);
1670 background: var(--bg-elevated);
1671 border: 1px solid var(--border);
1672 border-radius: 16px;
1673 overflow: hidden;
1674 }
1675 .search-eyebrow {
1676 font-size: 12px;
1677 font-family: var(--font-mono);
1678 color: var(--text-muted);
1679 letter-spacing: 0.1em;
1680 text-transform: uppercase;
1681 margin-bottom: var(--space-2);
1682 }
1683 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1684 .search-title {
1685 font-family: var(--font-display);
1686 font-weight: 800;
1687 letter-spacing: -0.025em;
1688 font-size: clamp(22px, 3vw, 30px);
1689 line-height: 1.15;
1690 margin: 0 0 var(--space-3);
1691 color: var(--text-strong);
1692 }
1693 .search-form {
1694 display: flex;
1695 gap: var(--space-2);
1696 align-items: stretch;
1697 }
1698 .search-input-wrap {
1699 position: relative;
1700 flex: 1;
1701 display: flex;
1702 align-items: center;
1703 }
1704 .search-input-icon {
1705 position: absolute;
1706 left: 12px;
1707 color: var(--text-muted);
1708 font-size: 15px;
1709 pointer-events: none;
1710 }
1711 .search-input {
1712 appearance: none;
1713 width: 100%;
1714 padding: 10px 12px 10px 34px;
1715 background: var(--bg);
1716 border: 1px solid var(--border);
1717 border-radius: 10px;
1718 color: var(--text-strong);
1719 font-size: 14px;
1720 font-family: inherit;
1721 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1722 }
1723 .search-input:focus {
1724 outline: none;
1725 border-color: var(--accent);
6fd5915Claude1726 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1727 }
1728 .search-submit { min-width: 96px; }
1729 .search-results-head {
1730 display: flex;
1731 align-items: baseline;
1732 gap: var(--space-2);
1733 margin-bottom: var(--space-3);
1734 font-size: 13px;
1735 color: var(--text-muted);
1736 }
1737 .search-results-count strong {
1738 color: var(--text-strong);
1739 font-weight: 600;
1740 font-variant-numeric: tabular-nums;
1741 }
1742 .search-results-q { color: var(--text-strong); font-weight: 600; }
1743 .search-results-head code {
1744 font-family: var(--font-mono);
1745 font-size: 12px;
1746 background: var(--bg-secondary);
1747 border: 1px solid var(--border);
1748 border-radius: 5px;
1749 padding: 1px 6px;
1750 color: var(--text);
1751 }
1752 .search-empty {
1753 padding: var(--space-5) var(--space-6);
1754 background: var(--bg-elevated);
1755 border: 1px dashed var(--border);
1756 border-radius: 12px;
1757 color: var(--text-muted);
1758 font-size: 14px;
1759 }
1760 .search-empty strong { color: var(--text-strong); }
1761 .search-results {
1762 display: flex;
1763 flex-direction: column;
1764 gap: var(--space-3);
1765 }
1766 .search-file-head {
1767 display: flex;
1768 align-items: center;
1769 justify-content: space-between;
1770 gap: var(--space-2);
1771 }
1772 .search-file-link {
1773 font-family: var(--font-mono);
1774 font-size: 13px;
1775 color: var(--text-strong);
1776 font-weight: 600;
1777 }
1778 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1779 .search-file-count {
1780 font-family: var(--font-mono);
1781 font-size: 11.5px;
1782 color: var(--text-muted);
1783 font-variant-numeric: tabular-nums;
1784 }
1785`;
1786
fc1817aClaude1787// Home page
06d5ffeClaude1788web.get("/", async (c) => {
1789 const user = c.get("user");
1790
1791 if (user) {
0316dbbClaude1792 return c.redirect("/dashboard");
06d5ffeClaude1793 }
1794
8e9f1d9Claude1795 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1796 let publicStats: PublicStats | null = null;
8e9f1d9Claude1797 try {
1798 const [repoRow] = await db
1799 .select({ n: sql<number>`count(*)::int` })
1800 .from(repositories)
1801 .where(eq(repositories.isPrivate, false));
1802 const [userRow] = await db
1803 .select({ n: sql<number>`count(*)::int` })
1804 .from(users);
1805 stats = {
1806 publicRepos: Number(repoRow?.n ?? 0),
1807 users: Number(userRow?.n ?? 0),
1808 };
1809 } catch {
1810 stats = undefined;
1811 }
1812
52ad8b1Claude1813 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1814 try {
1815 publicStats = await computePublicStats();
1816 } catch {
1817 publicStats = null;
1818 }
1819
534f04aClaude1820 // Block M1 — initial SSR snapshot for the live-now demo feed.
1821 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1822 // in try/catch so a freak module-level explosion can't take down /.
1823 let liveFeed: LandingLiveFeed | null = null;
1824 try {
1825 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1826 listQueuedAiBuildIssues(3),
1827 listRecentAutoMerges(3, 24),
1828 listRecentAiReviews(3, 24),
1829 countAiReviewsSince(24),
1830 listDemoActivityFeed(10),
1831 ]);
1832 liveFeed = {
1833 queued: queued.map((i) => ({
1834 repo: i.repo,
1835 number: i.number,
1836 title: i.title,
1837 createdAt: i.createdAt,
1838 })),
1839 merges: merges.map((m) => ({
1840 repo: m.repo,
1841 number: m.number,
1842 title: m.title,
1843 mergedAt: m.mergedAt,
1844 })),
1845 reviews: reviewList.map((r) => ({
1846 repo: r.repo,
1847 prNumber: r.prNumber,
1848 commentSnippet: r.commentSnippet,
1849 createdAt: r.createdAt,
1850 })),
1851 reviewCount,
1852 feed: feed.map((e) => ({
1853 kind: e.kind,
1854 repo: e.repo,
1855 ref: e.ref,
1856 at: e.at,
1857 })),
1858 };
1859 } catch {
1860 liveFeed = null;
1861 }
1862
29924bcClaude1863 // 2030 reboot — the public landing is a self-contained light marketing
1864 // document (its own shell + design system), rendered directly so it never
1865 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1866 // above for the legacy LandingPage and other surfaces; the new page uses the
1867 // headline counters from `stats`.
1868 void publicStats;
1869 void liveFeed;
1870 void LandingPage;
1871 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1872});
1873
06d5ffeClaude1874// New repository form
1875web.get("/new", requireAuth, (c) => {
1876 const user = c.get("user")!;
1877 const error = c.req.query("error");
1878
1879 return c.html(
1880 <Layout title="New repository" user={user}>
efb11c5Claude1881 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1882 <div class="new-repo-hero">
1883 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1884 <div class="new-repo-hero-orb" />
1885 </div>
1886 <div class="new-repo-hero-inner">
1887 <div class="new-repo-eyebrow">
1888 <strong>Create</strong> · {user.username}
1889 </div>
1890 <h1 class="new-repo-title">
1891 Spin up a <span class="gradient-text">repository</span>.
1892 </h1>
1893 <p class="new-repo-sub">
1894 Push your first commit, and Gluecron wires up gate checks, AI review,
1895 and auto-merge from the moment your branch lands.
1896 </p>
1897 </div>
1898 </div>
06d5ffeClaude1899 <div class="new-repo-form">
efb11c5Claude1900 {error && (
1901 <div class="new-repo-error" role="alert">
1902 {decodeURIComponent(error)}
1903 </div>
1904 )}
1905 <form method="post" action="/new" class="new-repo-form-grid">
1906 <div class="new-repo-row">
1907 <label class="new-repo-label">Owner</label>
1908 <input
1909 type="text"
1910 value={user.username}
1911 disabled
1912 aria-label="Owner"
1913 class="new-repo-input new-repo-input-disabled"
1914 />
06d5ffeClaude1915 </div>
efb11c5Claude1916 <div class="new-repo-row">
1917 <label class="new-repo-label" for="name">
1918 Repository name
1919 </label>
06d5ffeClaude1920 <input
1921 type="text"
1922 id="name"
1923 name="name"
1924 required
1925 pattern="^[a-zA-Z0-9._-]+$"
1926 placeholder="my-project"
1927 autocomplete="off"
efb11c5Claude1928 class="new-repo-input"
06d5ffeClaude1929 />
efb11c5Claude1930 <p class="new-repo-hint">
1931 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1932 <code>{user.username}/&lt;name&gt;</code>.
1933 </p>
06d5ffeClaude1934 </div>
efb11c5Claude1935 <div class="new-repo-row">
1936 <label class="new-repo-label" for="description">
1937 Description{" "}
1938 <span class="new-repo-label-optional">(optional)</span>
1939 </label>
06d5ffeClaude1940 <input
1941 type="text"
1942 id="description"
1943 name="description"
1944 placeholder="A short description of your repository"
efb11c5Claude1945 class="new-repo-input"
06d5ffeClaude1946 />
1947 </div>
efb11c5Claude1948 <div class="new-repo-row">
1949 <span class="new-repo-label">Visibility</span>
1950 <div class="new-repo-visibility">
1951 <label class="new-repo-vis-card">
1952 <input
1953 type="radio"
1954 name="visibility"
1955 value="public"
1956 checked
1957 class="new-repo-vis-radio"
1958 />
1959 <span class="new-repo-vis-body">
1960 <span class="new-repo-vis-label">Public</span>
1961 <span class="new-repo-vis-desc">
1962 Anyone can see this repository. You choose who can commit.
1963 </span>
1964 </span>
1965 </label>
1966 <label class="new-repo-vis-card">
1967 <input
1968 type="radio"
1969 name="visibility"
1970 value="private"
1971 class="new-repo-vis-radio"
1972 />
1973 <span class="new-repo-vis-body">
1974 <span class="new-repo-vis-label">Private</span>
1975 <span class="new-repo-vis-desc">
1976 Only you (and collaborators you invite) can see this
1977 repository.
1978 </span>
1979 </span>
1980 </label>
1981 </div>
1982 </div>
398a10cClaude1983 <div class="new-repo-row">
1984 <span class="new-repo-label">
1985 Starter content{" "}
1986 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1987 </span>
1988 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1989 <label class="new-repo-template-chip">
1990 <input type="radio" name="starter" value="empty" checked />
1991 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1992 Empty
1993 </label>
1994 <label class="new-repo-template-chip">
1995 <input type="radio" name="starter" value="readme" />
1996 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1997 README
1998 </label>
1999 <label class="new-repo-template-chip">
2000 <input type="radio" name="starter" value="readme-mit" />
2001 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2002 README + MIT
2003 </label>
2004 <label class="new-repo-template-chip">
2005 <input type="radio" name="starter" value="node" />
2006 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2007 Node + .gitignore
2008 </label>
2009 </div>
2010 <p class="new-repo-hint">
2011 Just a UI hint — push your own commits to fill the repo.
2012 </p>
2013 </div>
44f1a02Claude2014 <div class="new-repo-row">
2015 <label class="new-repo-label" for="data_region">
2016 Data region
2017 </label>
2018 <select
2019 id="data_region"
2020 name="data_region"
2021 class="new-repo-input"
2022 style="cursor: pointer;"
2023 >
2024 <option value="us" selected>US (default)</option>
2025 <option value="eu">EU (Frankfurt)</option>
2026 </select>
2027 <p class="new-repo-hint">
2028 EU data residency requires a{" "}
2029 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2030 Pro plan or higher
2031 </a>
2032 . Repositories cannot be moved between regions after creation.
2033 </p>
2034 </div>
efb11c5Claude2035 <div class="new-repo-callout">
2036 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2037 <p class="new-repo-callout-body">
2038 Every push is gate-checked and reviewed by Claude automatically.
2039 Label an issue <code>ai-build</code> and Gluecron will open the PR
2040 for you.
2041 </p>
2042 </div>
2043 <div class="new-repo-actions">
398a10cClaude2044 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2045 Create repository
2046 </button>
2047 <a href="/dashboard" class="btn new-repo-cancel">
2048 Cancel
2049 </a>
06d5ffeClaude2050 </div>
2051 </form>
2052 </div>
2053 </Layout>
2054 );
2055});
2056
2057web.post("/new", requireAuth, async (c) => {
2058 const user = c.get("user")!;
2059 const body = await c.req.parseBody();
2060 const name = String(body.name || "").trim();
2061 const description = String(body.description || "").trim();
2062 const isPrivate = body.visibility === "private";
44f1a02Claude2063 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2064
2065 if (!name) {
2066 return c.redirect("/new?error=Repository+name+is+required");
2067 }
2068
c63b860Claude2069 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2070 // outage never blocks repo creation.
2071 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2072 const gate = await checkRepoCreateAllowed(user.id);
2073 if (!gate.ok) {
2074 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2075 }
2076
06d5ffeClaude2077 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2078 return c.redirect("/new?error=Invalid+repository+name");
2079 }
2080
2081 if (await repoExists(user.username, name)) {
2082 return c.redirect("/new?error=Repository+already+exists");
2083 }
2084
2085 const diskPath = await initBareRepo(user.username, name);
2086
3ef4c9dClaude2087 const [newRepo] = await db
2088 .insert(repositories)
2089 .values({
2090 name,
2091 ownerId: user.id,
2092 description: description || null,
2093 isPrivate,
2094 diskPath,
44f1a02Claude2095 dataRegion,
3ef4c9dClaude2096 })
2097 .returning();
2098
2099 if (newRepo) {
2100 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2101 await bootstrapRepository({
2102 repositoryId: newRepo.id,
2103 ownerUserId: user.id,
2104 defaultBranch: "main",
2105 });
2106 }
06d5ffeClaude2107
2108 return c.redirect(`/${user.username}/${name}`);
2109});
2110
11c3ab6ccanty labs2111// Daily brief — GET /brief
2112web.get("/brief", (c) => {
2113 const user = c.get("user");
2114 return c.html(<DailyBrief user={user} />);
2115});
2116
2117// Trust report — GET /trust (public)
2118web.get("/trust", (c) => {
2119 return c.html(<TrustReport />);
2120});
2121
2122// Production layers — GET /layers
2123web.get("/layers", (c) => {
2124 const user = c.get("user");
2125 return c.html(<ProductionLayers user={user} />);
2126});
2127
2128// Distribution — GET /distribute
2129web.get("/distribute", (c) => {
2130 const user = c.get("user");
2131 return c.html(<DistributionView user={user} />);
2132});
2133
06d5ffeClaude2134// User profile
fc1817aClaude2135web.get("/:owner", async (c) => {
06d5ffeClaude2136 const { owner: ownerName } = c.req.param();
2137 const user = c.get("user");
2138
2139 // Avoid clashing with fixed routes
2140 if (
2141 ["login", "register", "logout", "new", "settings", "api"].includes(
2142 ownerName
2143 )
2144 ) {
2145 return c.notFound();
2146 }
2147
2148 let ownerUser;
2149 try {
2150 const [found] = await db
2151 .select()
2152 .from(users)
2153 .where(eq(users.username, ownerName))
2154 .limit(1);
2155 ownerUser = found;
2156 } catch {
2157 // DB not available — check if repos exist on disk
2158 ownerUser = null;
2159 }
2160
2161 // Even without DB, show repos if they exist on disk
2162 let repos: any[] = [];
2163 if (ownerUser) {
2164 const allRepos = await db
2165 .select()
2166 .from(repositories)
2167 .where(eq(repositories.ownerId, ownerUser.id))
2168 .orderBy(desc(repositories.updatedAt));
2169
2170 // Show public repos to everyone, private only to owner
2171 repos =
2172 user?.id === ownerUser.id
2173 ? allRepos
2174 : allRepos.filter((r) => !r.isPrivate);
2175 }
2176
7aa8b99Claude2177 // Block J4 — follow counts + viewer's follow state
2178 let followState = {
2179 followers: 0,
2180 following: 0,
2181 viewerFollows: false,
2182 };
2183 if (ownerUser) {
2184 try {
2185 const { followCounts, isFollowing } = await import("../lib/follows");
2186 const counts = await followCounts(ownerUser.id);
2187 followState.followers = counts.followers;
2188 followState.following = counts.following;
2189 if (user && user.id !== ownerUser.id) {
2190 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2191 }
2192 } catch {
2193 // DB hiccup — fall back to zeros.
2194 }
2195 }
2196 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2197
d412586Claude2198 // Block J5 — profile README. Render owner/owner repo's README on the
2199 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2200 // back to "<user>/.github" for org-style profile repos.
2201 let profileReadmeHtml: string | null = null;
2202 try {
2203 const candidates = [ownerName, ".github"];
2204 for (const rname of candidates) {
2205 if (await repoExists(ownerName, rname)) {
2206 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2207 const md = await getReadme(ownerName, rname, ref);
2208 if (md) {
2209 profileReadmeHtml = renderMarkdown(md);
2210 break;
2211 }
2212 }
2213 }
2214 } catch {
2215 profileReadmeHtml = null;
2216 }
2217
efb11c5Claude2218 const displayName = ownerUser?.displayName || ownerName;
2219 const memberSince = ownerUser?.createdAt
2220 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2221 : null;
2222
fc1817aClaude2223 return c.html(
06d5ffeClaude2224 <Layout title={ownerName} user={user}>
efb11c5Claude2225 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2226 <div class="profile-hero">
2227 <div class="profile-hero-orb-wrap" aria-hidden="true">
2228 <div class="profile-hero-orb" />
06d5ffeClaude2229 </div>
efb11c5Claude2230 <div class="profile-hero-inner">
2231 <div class="profile-hero-avatar" aria-hidden="true">
2232 {displayName[0].toUpperCase()}
2233 </div>
2234 <div class="profile-hero-text">
2235 <div class="profile-eyebrow">
2236 <strong>Developer</strong>
2237 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2238 <>
2239 {" "}· Joined{" "}
2240 {memberSince.toLocaleDateString("en-US", {
2241 month: "short",
2242 year: "numeric",
2243 })}
2244 </>
2245 )}
2246 </div>
2247 <h1 class="profile-name">
2248 <span class="gradient-text">{displayName}</span>
2249 </h1>
2250 <div class="profile-handle">@{ownerName}</div>
2251 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2252 <div class="profile-meta">
2253 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2254 <strong>{followState.followers}</strong> follower
2255 {followState.followers === 1 ? "" : "s"}
2256 </a>
2257 <a href={`/${ownerName}/following`} class="profile-meta-link">
2258 <strong>{followState.following}</strong> following
2259 </a>
2260 <a href={`/${ownerName}`} class="profile-meta-link">
2261 <strong>{repos.length}</strong> repo
2262 {repos.length === 1 ? "" : "s"}
2263 </a>
2264 {canFollow && (
2265 <form
2266 method="post"
2267 action={`/${ownerName}/${
2268 followState.viewerFollows ? "unfollow" : "follow"
2269 }`}
2270 class="profile-follow-form"
7aa8b99Claude2271 >
efb11c5Claude2272 <button
2273 type="submit"
2274 class={`btn ${
2275 followState.viewerFollows ? "" : "btn-primary"
2276 } btn-sm`}
2277 >
2278 {followState.viewerFollows ? "Unfollow" : "Follow"}
2279 </button>
2280 </form>
2281 )}
2282 </div>
7aa8b99Claude2283 </div>
06d5ffeClaude2284 </div>
2285 </div>
d412586Claude2286 {profileReadmeHtml && (
efb11c5Claude2287 <div class="profile-readme">
2288 <div class="profile-readme-head">
2289 <span class="profile-readme-icon">{"☰"}</span>
2290 <span>{ownerName}/{ownerName} README.md</span>
2291 </div>
2292 <div
2293 class="markdown-body profile-readme-body"
2294 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2295 />
2296 </div>
d412586Claude2297 )}
efb11c5Claude2298 <div class="profile-section-head">
2299 <h2 class="profile-section-title">Repositories</h2>
2300 <span class="profile-section-count">{repos.length}</span>
2301 </div>
06d5ffeClaude2302 {repos.length === 0 ? (
efb11c5Claude2303 <div class="profile-empty">
2304 <p class="profile-empty-text">
2305 No repositories yet
2306 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2307 </p>
2308 {user?.id === ownerUser?.id && (
2309 <a href="/new" class="btn btn-primary btn-sm">
2310 + Create your first
2311 </a>
2312 )}
2313 </div>
06d5ffeClaude2314 ) : (
2315 <div class="card-grid">
2316 {repos.map((repo) => (
2317 <RepoCard repo={repo} ownerName={ownerName} />
2318 ))}
2319 </div>
2320 )}
fc1817aClaude2321 </Layout>
2322 );
2323});
2324
06d5ffeClaude2325// Star/unstar a repo
2326web.post("/:owner/:repo/star", requireAuth, async (c) => {
2327 const { owner: ownerName, repo: repoName } = c.req.param();
2328 const user = c.get("user")!;
2329
2330 try {
2331 const [ownerUser] = await db
2332 .select()
2333 .from(users)
2334 .where(eq(users.username, ownerName))
2335 .limit(1);
2336 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2337
2338 const [repo] = await db
2339 .select()
2340 .from(repositories)
2341 .where(
2342 and(
2343 eq(repositories.ownerId, ownerUser.id),
2344 eq(repositories.name, repoName)
2345 )
2346 )
2347 .limit(1);
2348 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2349
2350 // Toggle star
2351 const [existing] = await db
2352 .select()
2353 .from(stars)
2354 .where(
2355 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2356 )
2357 .limit(1);
2358
2359 if (existing) {
2360 await db.delete(stars).where(eq(stars.id, existing.id));
2361 await db
2362 .update(repositories)
2363 .set({ starCount: Math.max(0, repo.starCount - 1) })
2364 .where(eq(repositories.id, repo.id));
2365 } else {
2366 await db.insert(stars).values({
2367 userId: user.id,
2368 repositoryId: repo.id,
2369 });
2370 await db
2371 .update(repositories)
2372 .set({ starCount: repo.starCount + 1 })
2373 .where(eq(repositories.id, repo.id));
2374 }
2375 } catch {
2376 // DB error — ignore
2377 }
2378
2379 return c.redirect(`/${ownerName}/${repoName}`);
2380});
2381
641aa42Claude2382// ---------------------------------------------------------------------------
2383// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2384// ---------------------------------------------------------------------------
2385const obCss = `
2386 .ob-card {
2387 position: relative;
2388 margin: 14px 0 18px;
6fd5915Claude2389 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2390 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2391 border-radius: 14px;
2392 overflow: hidden;
2393 }
2394 .ob-card::before {
2395 content: '';
2396 position: absolute;
2397 top: 0; left: 0; right: 0;
2398 height: 2px;
6fd5915Claude2399 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2400 pointer-events: none;
2401 }
2402 .ob-header {
2403 padding: 16px 20px 10px;
2404 border-bottom: 1px solid var(--border);
2405 }
2406 .ob-header h3 {
2407 font-size: 16px;
2408 font-weight: 700;
2409 margin: 0 0 4px;
2410 color: var(--text-strong);
2411 }
2412 .ob-header p {
2413 font-size: 13px;
2414 color: var(--text-muted);
2415 margin: 0;
2416 }
2417 .ob-sections {
2418 display: grid;
2419 grid-template-columns: repeat(3, 1fr);
2420 gap: 0;
2421 }
2422 @media (max-width: 760px) {
2423 .ob-sections { grid-template-columns: 1fr; }
2424 }
2425 .ob-section {
2426 padding: 14px 20px;
2427 border-right: 1px solid var(--border);
2428 }
2429 .ob-section:last-child { border-right: none; }
2430 .ob-section h4 {
2431 font-size: 12px;
2432 font-weight: 700;
2433 text-transform: uppercase;
2434 letter-spacing: 0.08em;
2435 color: var(--accent);
2436 margin: 0 0 8px;
2437 }
2438 .ob-preview {
2439 font-family: var(--font-mono);
2440 font-size: 11.5px;
2441 background: var(--bg);
2442 border: 1px solid var(--border);
2443 border-radius: 6px;
2444 padding: 8px 10px;
2445 color: var(--text-muted);
2446 line-height: 1.55;
2447 margin-bottom: 8px;
2448 white-space: pre-wrap;
2449 overflow: hidden;
2450 max-height: 80px;
2451 position: relative;
2452 }
2453 .ob-preview::after {
2454 content: '';
2455 position: absolute;
2456 bottom: 0; left: 0; right: 0;
2457 height: 24px;
2458 background: linear-gradient(transparent, var(--bg));
2459 pointer-events: none;
2460 }
2461 .ob-labels {
2462 display: flex;
2463 flex-wrap: wrap;
2464 gap: 5px;
2465 margin-bottom: 8px;
2466 }
2467 .ob-label-chip {
2468 display: inline-flex;
2469 align-items: center;
2470 padding: 2px 8px;
2471 border-radius: 9999px;
2472 font-size: 11.5px;
2473 font-weight: 600;
2474 line-height: 1.5;
2475 border: 1px solid transparent;
2476 }
2477 .ob-section ul {
2478 margin: 0;
2479 padding: 0 0 0 16px;
2480 font-size: 13px;
2481 color: var(--text-muted);
2482 line-height: 1.7;
2483 }
2484 .ob-section ul li { margin-bottom: 2px; }
2485 .ob-footer {
2486 display: flex;
2487 align-items: center;
2488 justify-content: flex-end;
2489 padding: 10px 20px;
2490 border-top: 1px solid var(--border);
2491 gap: 10px;
2492 }
2493 .ob-dismiss {
2494 appearance: none;
2495 background: transparent;
2496 border: 1px solid var(--border);
2497 color: var(--text-muted);
2498 border-radius: 6px;
2499 padding: 5px 12px;
2500 font-size: 12.5px;
2501 font-family: inherit;
2502 cursor: pointer;
2503 transition: background var(--t-fast), color var(--t-fast);
2504 }
2505 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2506 .btn-sm {
2507 appearance: none;
2508 background: var(--bg-elevated);
2509 border: 1px solid var(--border);
2510 color: var(--text-strong);
2511 border-radius: 6px;
2512 padding: 5px 12px;
2513 font-size: 12.5px;
2514 font-weight: 600;
2515 font-family: inherit;
2516 cursor: pointer;
2517 text-decoration: none;
2518 display: inline-flex;
2519 align-items: center;
2520 transition: background var(--t-fast);
2521 }
2522 .btn-sm:hover { background: var(--bg-hover); }
2523 .btn-sm.btn-primary {
2524 background: var(--accent);
2525 color: #fff;
2526 border-color: var(--accent);
2527 }
2528 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2529`;
2530
2531// Onboarding card component — shown to repo owner until dismissed
2532function RepoOnboardingCard({
2533 owner,
2534 repo,
2535 data,
2536}: {
2537 owner: string;
2538 repo: string;
2539 data: typeof repoOnboardingData.$inferSelect;
2540}) {
2541 const labels = (data.suggestedLabels ?? []) as Array<{
2542 name: string;
2543 color: string;
2544 description: string;
2545 }>;
2546 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2547 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2548
2549 return (
2550 <>
2551 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2552 <div class="ob-card" id="repo-onboarding">
2553 <div class="ob-header">
2554 <h3>Get started with {owner}/{repo}</h3>
2555 <p>
2556 Detected: {data.detectedLanguage ?? "Unknown"}
2557 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2558 Here&rsquo;s what we suggest to hit the ground running.
2559 </p>
2560 </div>
2561 <div class="ob-sections">
2562 <div class="ob-section">
2563 <h4>Suggested README</h4>
2564 <div class="ob-preview">{readmePreview}</div>
2565 <button
2566 class="btn-sm"
2567 type="button"
2568 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(_){};})()`}
2569 aria-label="Copy suggested README to clipboard"
2570 >
2571 Copy to clipboard
2572 </button>
2573 </div>
2574 <div class="ob-section">
2575 <h4>Suggested labels ({labels.length})</h4>
2576 <div class="ob-labels">
2577 {labels.slice(0, 6).map((l) => (
2578 <span
2579 class="ob-label-chip"
2580 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2581 title={l.description}
2582 >
2583 {l.name}
2584 </span>
2585 ))}
2586 </div>
2587 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2588 <button class="btn-sm btn-primary" type="submit">
2589 Create all labels
2590 </button>
2591 </form>
2592 </div>
2593 <div class="ob-section">
2594 <h4>First steps</h4>
2595 <ul>
2596 {suggestions.map((s) => (
2597 <li>{s}</li>
2598 ))}
2599 </ul>
2600 </div>
2601 </div>
2602 <div class="ob-footer">
2603 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2604 <button class="ob-dismiss" type="submit">
2605 Dismiss
2606 </button>
2607 </form>
2608 </div>
2609 <script
2610 dangerouslySetInnerHTML={{
2611 __html: `
2612 (function(){
2613 var card = document.getElementById('repo-onboarding');
2614 if (!card) return;
2615 document.addEventListener('keydown', function(e){
2616 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2617 card.style.display = 'none';
2618 }
2619 });
2620 })();
2621 `,
2622 }}
2623 />
2624 </div>
2625 </>
2626 );
2627}
2628
2629// ---------------------------------------------------------------------------
2630// Setup routes — create suggested labels + dismiss onboarding
2631// ---------------------------------------------------------------------------
2632
2633// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2634// requireAuth + write access. Idempotent (label UPSERT by name).
2635web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2636 const { owner, repo } = c.req.param();
2637 const user = c.get("user")!;
2638
2639 // Resolve repo + verify write access
2640 let repoRow: { id: string; ownerId: string } | null = null;
2641 try {
2642 const [ownerUser] = await db
2643 .select({ id: users.id })
2644 .from(users)
2645 .where(eq(users.username, owner))
2646 .limit(1);
2647 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2648 const [r] = await db
2649 .select({ id: repositories.id, ownerId: repositories.ownerId })
2650 .from(repositories)
2651 .where(
2652 and(
2653 eq(repositories.ownerId, ownerUser.id),
2654 eq(repositories.name, repo)
2655 )
2656 )
2657 .limit(1);
2658 repoRow = r ?? null;
2659 } catch {
2660 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2661 }
2662 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2663 if (repoRow.ownerId !== user.id) {
2664 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2665 }
2666
2667 // Fetch the onboarding data
2668 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2669 try {
2670 const [r] = await db
2671 .select()
2672 .from(repoOnboardingData)
2673 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2674 .limit(1);
2675 obRow = r ?? null;
2676 } catch {
2677 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2678 }
2679 if (!obRow) {
2680 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2681 }
2682
2683 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2684 name: string;
2685 color: string;
2686 description: string;
2687 }>;
2688
2689 // Create labels — import the labels table which was already imported
2690 let created = 0;
2691 for (const l of suggestedLabels) {
2692 try {
2693 await db
2694 .insert(labels)
2695 .values({
2696 repositoryId: repoRow.id,
2697 name: l.name,
2698 color: l.color,
2699 description: l.description ?? "",
2700 })
2701 .onConflictDoNothing();
2702 created++;
2703 } catch {
2704 /* skip duplicates */
2705 }
2706 }
2707
2708 // Mark onboarding dismissed
2709 try {
2710 await db
2711 .update(repositories)
2712 .set({ onboardingShown: true })
2713 .where(eq(repositories.id, repoRow.id));
2714 } catch {
2715 /* ignore */
2716 }
2717
2718 return c.redirect(
2719 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2720 );
2721});
2722
2723// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2724web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2725 const { owner, repo } = c.req.param();
2726 const user = c.get("user")!;
2727
2728 try {
2729 const [ownerUser] = await db
2730 .select({ id: users.id })
2731 .from(users)
2732 .where(eq(users.username, owner))
2733 .limit(1);
2734 if (ownerUser) {
2735 const [r] = await db
2736 .select({ id: repositories.id, ownerId: repositories.ownerId })
2737 .from(repositories)
2738 .where(
2739 and(
2740 eq(repositories.ownerId, ownerUser.id),
2741 eq(repositories.name, repo)
2742 )
2743 )
2744 .limit(1);
2745 if (r && r.ownerId === user.id) {
2746 await db
2747 .update(repositories)
2748 .set({ onboardingShown: true })
2749 .where(eq(repositories.id, r.id));
2750 }
2751 }
2752 } catch {
2753 /* swallow — dismiss should never fail visibly */
2754 }
2755
2756 return c.redirect(`/${owner}/${repo}`);
2757});
2758
11c3ab6ccanty labs2759// Agent workspace — GET /:owner/:repo/workspace
2760web.get("/:owner/:repo/workspace", (c) => {
2761 const { owner, repo } = c.req.param();
2762 const user = c.get("user");
2763 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2764});
2765
2766// Org memory — GET /:owner/:repo/memory
2767web.get("/:owner/:repo/memory", (c) => {
2768 const { owner, repo } = c.req.param();
2769 const user = c.get("user");
2770 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2771});
2772
fc1817aClaude2773// Repository overview — file tree at HEAD
2774web.get("/:owner/:repo", async (c) => {
2775 const { owner, repo } = c.req.param();
06d5ffeClaude2776 const user = c.get("user");
fc1817aClaude2777
f1dc7c7Claude2778 // ── Loading skeleton (flag-gated) ──
2779 // Renders an SSR'd shell with file-tree + README placeholders when
2780 // `?skeleton=1` is set. Lets the user see the page structure before
2781 // git ops finish. Behind a flag for now so we never flash before the
2782 // real content lands.
2783 if (c.req.query("skeleton") === "1") {
2784 return c.html(
2785 <Layout title={`${owner}/${repo}`} user={user}>
2786 <style
2787 dangerouslySetInnerHTML={{
2788 __html: `
2789 .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; }
2790 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2791 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2792 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2793 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2794 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2795 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2796 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2797 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2798 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2799 .repo-skel-readme { height: 320px; border-radius: 12px; }
2800 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2801 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2802 `,
2803 }}
2804 />
2805 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2806 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2807 <div class="repo-skel-grid" aria-hidden="true">
2808 <div>
2809 <div class="repo-skel repo-skel-branch" />
2810 <div class="repo-skel-tree">
2811 {Array.from({ length: 8 }).map(() => (
2812 <div class="repo-skel repo-skel-tree-row" />
2813 ))}
2814 </div>
2815 <div class="repo-skel repo-skel-readme" />
2816 </div>
2817 <aside class="repo-skel-side">
2818 <div class="repo-skel repo-skel-side-card" />
2819 <div class="repo-skel repo-skel-side-card" />
2820 </aside>
2821 </div>
2822 <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">
2823 Loading {owner}/{repo}…
2824 </span>
2825 </Layout>
2826 );
2827 }
2828
8f50ed0Claude2829 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2830 trackByName(owner, repo, "view", {
2831 userId: user?.id || null,
2832 path: `/${owner}/${repo}`,
2833 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2834 userAgent: c.req.header("user-agent") || null,
2835 referer: c.req.header("referer") || null,
a28cedeClaude2836 }).catch((err) => {
2837 console.warn(
2838 `[web] view tracking failed for ${owner}/${repo}:`,
2839 err instanceof Error ? err.message : err
2840 );
2841 });
8f50ed0Claude2842
fc1817aClaude2843 if (!(await repoExists(owner, repo))) {
2844 return c.html(
06d5ffeClaude2845 <Layout title="Not Found" user={user}>
fc1817aClaude2846 <div class="empty-state">
2847 <h2>Repository not found</h2>
2848 <p>
2849 {owner}/{repo} does not exist.
2850 </p>
2851 </div>
2852 </Layout>,
2853 404
2854 );
2855 }
2856
05b973eClaude2857 // Parallelize all independent operations
2858 const [defaultBranch, branches] = await Promise.all([
2859 getDefaultBranch(owner, repo).then((b) => b || "main"),
2860 listBranches(owner, repo),
2861 ]);
2862 const [tree, starInfo] = await Promise.all([
2863 getTree(owner, repo, defaultBranch),
2864 // Star info fetched in parallel with tree
2865 (async () => {
2866 try {
2867 const [ownerUser] = await db
2868 .select()
2869 .from(users)
2870 .where(eq(users.username, owner))
2871 .limit(1);
71cd5ecClaude2872 if (!ownerUser)
2873 return {
2874 starCount: 0,
2875 starred: false,
2876 archived: false,
2877 isTemplate: false,
544d842Claude2878 forkCount: 0,
2879 description: null as string | null,
2880 pushedAt: null as Date | null,
2881 createdAt: null as Date | null,
cb5a796Claude2882 repoId: null as string | null,
2883 repoOwnerId: null as string | null,
71cd5ecClaude2884 };
05b973eClaude2885 const [repoRow] = await db
2886 .select()
2887 .from(repositories)
2888 .where(
2889 and(
2890 eq(repositories.ownerId, ownerUser.id),
2891 eq(repositories.name, repo)
2892 )
06d5ffeClaude2893 )
05b973eClaude2894 .limit(1);
71cd5ecClaude2895 if (!repoRow)
2896 return {
2897 starCount: 0,
2898 starred: false,
2899 archived: false,
2900 isTemplate: false,
544d842Claude2901 forkCount: 0,
2902 description: null as string | null,
2903 pushedAt: null as Date | null,
2904 createdAt: null as Date | null,
cb5a796Claude2905 repoId: null as string | null,
2906 repoOwnerId: null as string | null,
71cd5ecClaude2907 };
05b973eClaude2908 let starred = false;
06d5ffeClaude2909 if (user) {
2910 const [star] = await db
2911 .select()
2912 .from(stars)
2913 .where(
2914 and(
2915 eq(stars.userId, user.id),
2916 eq(stars.repositoryId, repoRow.id)
2917 )
2918 )
2919 .limit(1);
2920 starred = !!star;
2921 }
71cd5ecClaude2922 return {
2923 starCount: repoRow.starCount,
2924 starred,
2925 archived: repoRow.isArchived,
2926 isTemplate: repoRow.isTemplate,
544d842Claude2927 forkCount: repoRow.forkCount,
2928 description: repoRow.description as string | null,
2929 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2930 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2931 repoId: repoRow.id as string,
2932 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2933 };
05b973eClaude2934 } catch {
71cd5ecClaude2935 return {
2936 starCount: 0,
2937 starred: false,
2938 archived: false,
2939 isTemplate: false,
544d842Claude2940 forkCount: 0,
2941 description: null as string | null,
2942 pushedAt: null as Date | null,
2943 createdAt: null as Date | null,
cb5a796Claude2944 repoId: null as string | null,
2945 repoOwnerId: null as string | null,
71cd5ecClaude2946 };
06d5ffeClaude2947 }
05b973eClaude2948 })(),
2949 ]);
544d842Claude2950 const {
2951 starCount,
2952 starred,
2953 archived,
2954 isTemplate,
2955 forkCount,
2956 description,
2957 pushedAt,
2958 createdAt,
cb5a796Claude2959 repoId,
2960 repoOwnerId,
544d842Claude2961 } = starInfo;
2962
91a0204Claude2963 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2964 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2965 // and the badge simply doesn't render.
2966 let healthScore: HealthScore | null = null;
2967 if (repoId) {
2968 try {
2969 healthScore = await computeHealthScore(repoId);
2970 } catch {
2971 // swallow — badge is optional
2972 }
2973 }
2974
cb5a796Claude2975 // Pending-comments banner data (lazy + best-effort). Only the repo
2976 // owner sees the banner, so non-owner views skip the DB hit entirely.
2977 let repoHomePendingCount = 0;
2978 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2979 try {
2980 const { countPendingForRepo } = await import(
2981 "../lib/comment-moderation"
2982 );
2983 repoHomePendingCount = await countPendingForRepo(repoId);
2984 } catch {
2985 /* swallow */
2986 }
2987 }
2988
8c790e0Claude2989 // Push Watch discoverability — fetch the most recent push within 24 h.
2990 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2991 const recentPush: RecentPush | null = repoId
2992 ? await getRecentPush(repoId)
2993 : null;
2994
ebaae0fClaude2995 // AI stats strip — best-effort, fail-open to zero values.
2996 let aiStats: RepoAiStats = {
2997 mergedCount: 0,
2998 reviewCount: 0,
2999 securityAlertCount: 0,
3000 hoursSaved: "0.0",
3001 };
3002 if (repoId) {
3003 try {
3004 aiStats = await getRepoAiStats(repoId);
3005 } catch {
3006 /* swallow — show zero values */
3007 }
3008 }
3009
641aa42Claude3010 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3011 // if the DB is down the card simply won't appear. Only the owner sees it.
3012 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3013 let showOnboarding = false;
3014 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3015 try {
3016 // Check if onboarding_shown is still false (not yet dismissed)
3017 const [repoRow2] = await db
3018 .select({ onboardingShown: repositories.onboardingShown })
3019 .from(repositories)
3020 .where(eq(repositories.id, repoId))
3021 .limit(1);
3022 if (repoRow2 && !repoRow2.onboardingShown) {
3023 const [obRow] = await db
3024 .select()
3025 .from(repoOnboardingData)
3026 .where(eq(repoOnboardingData.repositoryId, repoId))
3027 .limit(1);
3028 onboardingRow = obRow ?? null;
3029 showOnboarding = !!onboardingRow;
3030 }
3031 } catch {
3032 /* swallow — onboarding is optional */
3033 }
3034 }
3035
544d842Claude3036 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3037 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3038 const repoHomeCss = `
3039 .repo-home-hero {
3040 position: relative;
3041 margin-bottom: var(--space-5);
3042 padding: var(--space-5) var(--space-6);
3043 background: var(--bg-elevated);
3044 border: 1px solid var(--border);
3045 border-radius: 16px;
3046 overflow: hidden;
3047 }
3048 .repo-home-hero::before {
3049 content: '';
3050 position: absolute;
3051 top: 0; left: 0; right: 0;
3052 height: 2px;
6fd5915Claude3053 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3054 opacity: 0.7;
3055 pointer-events: none;
3056 }
3057 .repo-home-hero-orb-wrap {
3058 position: absolute;
3059 inset: -25% -10% auto auto;
3060 width: 360px;
3061 height: 360px;
3062 pointer-events: none;
3063 z-index: 0;
3064 }
3065 .repo-home-hero-orb {
3066 position: absolute;
3067 inset: 0;
6fd5915Claude3068 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3069 filter: blur(80px);
3070 opacity: 0.7;
3071 animation: repoHomeOrb 14s ease-in-out infinite;
3072 }
3073 @keyframes repoHomeOrb {
3074 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3075 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3076 }
3077 @media (prefers-reduced-motion: reduce) {
3078 .repo-home-hero-orb { animation: none; }
3079 }
3080 .repo-home-hero-inner {
3081 position: relative;
3082 z-index: 1;
3083 }
3084 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3085 .repo-home-eyebrow {
3086 font-size: 12px;
3087 font-family: var(--font-mono);
3088 color: var(--text-muted);
3089 letter-spacing: 0.1em;
3090 text-transform: uppercase;
3091 margin-bottom: var(--space-2);
3092 }
3093 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3094 .repo-home-description {
3095 font-size: 15px;
3096 line-height: 1.55;
3097 color: var(--text);
3098 margin: 0;
3099 max-width: 720px;
3100 }
3101 .repo-home-description-empty {
3102 font-size: 14px;
3103 color: var(--text-muted);
3104 font-style: italic;
3105 margin: 0;
3106 }
3107 .repo-home-stat-row {
3108 display: flex;
3109 flex-wrap: wrap;
3110 gap: var(--space-4);
3111 margin-top: var(--space-3);
3112 font-size: 13px;
3113 color: var(--text-muted);
3114 }
3115 .repo-home-stat {
3116 display: inline-flex;
3117 align-items: center;
3118 gap: 6px;
3119 }
3120 .repo-home-stat strong {
3121 color: var(--text-strong);
3122 font-weight: 600;
3123 font-variant-numeric: tabular-nums;
3124 }
3125 .repo-home-stat .repo-home-stat-icon {
3126 color: var(--text-faint);
3127 font-size: 14px;
3128 line-height: 1;
3129 }
3130 .repo-home-stat a {
3131 color: var(--text-muted);
3132 transition: color var(--t-fast) var(--ease);
3133 }
3134 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3135
3136 /* Two-column layout: file tree + sidebar */
3137 .repo-home-grid {
3138 display: grid;
3139 grid-template-columns: minmax(0, 1fr) 280px;
3140 gap: var(--space-5);
3141 align-items: start;
3142 }
3143 @media (max-width: 960px) {
3144 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3145 }
3146 .repo-home-main { min-width: 0; }
3147
3148 /* Sidebar card */
3149 .repo-home-side {
3150 display: flex;
3151 flex-direction: column;
3152 gap: var(--space-4);
3153 }
3154 .repo-home-side-card {
3155 background: var(--bg-elevated);
3156 border: 1px solid var(--border);
3157 border-radius: 12px;
3158 padding: var(--space-4);
3159 }
3160 .repo-home-side-title {
3161 font-size: 11px;
3162 font-family: var(--font-mono);
3163 letter-spacing: 0.12em;
3164 text-transform: uppercase;
3165 color: var(--text-muted);
3166 margin: 0 0 var(--space-3);
3167 font-weight: 600;
3168 }
3169 .repo-home-side-row {
3170 display: flex;
3171 justify-content: space-between;
3172 align-items: center;
3173 gap: var(--space-2);
3174 font-size: 13px;
3175 padding: 6px 0;
3176 border-top: 1px solid var(--border);
3177 }
3178 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3179 .repo-home-side-key {
3180 color: var(--text-muted);
3181 display: inline-flex;
3182 align-items: center;
3183 gap: 6px;
3184 }
3185 .repo-home-side-val {
3186 color: var(--text-strong);
3187 font-weight: 500;
3188 font-variant-numeric: tabular-nums;
3189 max-width: 60%;
3190 text-align: right;
3191 overflow: hidden;
3192 text-overflow: ellipsis;
3193 white-space: nowrap;
3194 }
3195 .repo-home-side-val a { color: var(--text-strong); }
3196 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3197
3198 /* Clone / Code tabs */
3199 .repo-home-clone {
3200 background: var(--bg-elevated);
3201 border: 1px solid var(--border);
3202 border-radius: 12px;
3203 overflow: hidden;
3204 }
3205 .repo-home-clone-tabs {
3206 display: flex;
3207 gap: 0;
3208 background: var(--bg-secondary);
3209 border-bottom: 1px solid var(--border);
3210 padding: 0 var(--space-2);
3211 }
3212 .repo-home-clone-tab {
3213 appearance: none;
3214 background: transparent;
3215 border: 0;
3216 border-bottom: 2px solid transparent;
3217 padding: 9px 12px;
3218 font-size: 12px;
3219 font-weight: 500;
3220 color: var(--text-muted);
3221 cursor: pointer;
3222 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3223 font-family: inherit;
3224 margin-bottom: -1px;
3225 }
3226 .repo-home-clone-tab:hover { color: var(--text-strong); }
3227 .repo-home-clone-tab[aria-selected="true"] {
3228 color: var(--text-strong);
3229 border-bottom-color: var(--accent);
3230 }
3231 .repo-home-clone-body {
3232 padding: var(--space-3);
3233 display: flex;
3234 align-items: center;
3235 gap: var(--space-2);
3236 }
3237 .repo-home-clone-input {
3238 flex: 1;
3239 min-width: 0;
3240 font-family: var(--font-mono);
3241 font-size: 12px;
3242 background: var(--bg);
3243 border: 1px solid var(--border);
3244 border-radius: 8px;
3245 padding: 8px 10px;
3246 color: var(--text-strong);
3247 overflow: hidden;
3248 text-overflow: ellipsis;
3249 white-space: nowrap;
3250 }
3251 .repo-home-clone-copy {
3252 appearance: none;
3253 background: var(--bg-secondary);
3254 border: 1px solid var(--border);
3255 color: var(--text-strong);
3256 border-radius: 8px;
3257 padding: 8px 12px;
3258 font-size: 12px;
3259 font-weight: 600;
3260 cursor: pointer;
3261 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3262 font-family: inherit;
3263 }
3264 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3265 .repo-home-clone-pane { display: none; }
3266 .repo-home-clone-pane[data-active="true"] { display: flex; }
3267
3268 /* README card */
3269 .repo-home-readme {
3270 margin-top: var(--space-5);
3271 background: var(--bg-elevated);
3272 border: 1px solid var(--border);
3273 border-radius: 12px;
3274 overflow: hidden;
3275 }
3276 .repo-home-readme-head {
3277 display: flex;
3278 align-items: center;
3279 gap: 8px;
3280 padding: 10px 16px;
3281 background: var(--bg-secondary);
3282 border-bottom: 1px solid var(--border);
3283 font-size: 13px;
3284 color: var(--text-muted);
3285 }
3286 .repo-home-readme-head .repo-home-readme-icon {
3287 color: var(--accent);
3288 font-size: 14px;
3289 }
3290 .repo-home-readme-body {
3291 padding: var(--space-5) var(--space-6);
3292 }
3293
3294 /* Empty-state CTA */
3295 .repo-home-empty {
3296 position: relative;
3297 margin-top: var(--space-4);
3298 background: var(--bg-elevated);
3299 border: 1px solid var(--border);
3300 border-radius: 16px;
3301 padding: var(--space-6);
3302 overflow: hidden;
3303 }
3304 .repo-home-empty::before {
3305 content: '';
3306 position: absolute;
3307 top: 0; left: 0; right: 0;
3308 height: 2px;
6fd5915Claude3309 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3310 opacity: 0.7;
3311 pointer-events: none;
3312 }
3313 .repo-home-empty-eyebrow {
3314 font-size: 12px;
3315 font-family: var(--font-mono);
3316 color: var(--accent);
3317 letter-spacing: 0.12em;
3318 text-transform: uppercase;
3319 margin-bottom: var(--space-2);
3320 font-weight: 600;
3321 }
3322 .repo-home-empty-title {
3323 font-family: var(--font-display);
3324 font-weight: 800;
3325 letter-spacing: -0.025em;
3326 font-size: clamp(22px, 3vw, 30px);
3327 line-height: 1.1;
3328 margin: 0 0 var(--space-2);
3329 color: var(--text-strong);
3330 }
3331 .repo-home-empty-sub {
3332 color: var(--text-muted);
3333 font-size: 14px;
3334 line-height: 1.55;
3335 max-width: 640px;
3336 margin: 0 0 var(--space-4);
3337 }
3338 .repo-home-empty-snippet {
3339 background: var(--bg);
3340 border: 1px solid var(--border);
3341 border-radius: 10px;
3342 padding: var(--space-3) var(--space-4);
3343 font-family: var(--font-mono);
3344 font-size: 12.5px;
3345 line-height: 1.7;
3346 color: var(--text-strong);
3347 overflow-x: auto;
3348 white-space: pre;
3349 margin: 0;
3350 }
3351 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3352 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3353
91a0204Claude3354 /* Health score badge */
3355 .repo-health-badge {
3356 display: inline-flex;
3357 align-items: center;
3358 gap: 5px;
3359 padding: 3px 10px;
3360 border-radius: 999px;
3361 font-size: 11.5px;
3362 font-weight: 700;
3363 font-family: var(--font-mono);
3364 text-decoration: none;
3365 border: 1px solid transparent;
3366 transition: filter 120ms ease, opacity 120ms ease;
3367 vertical-align: middle;
3368 }
3369 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3370 .repo-health-badge-elite {
3371 background: rgba(52,211,153,0.15);
3372 color: #6ee7b7;
3373 border-color: rgba(52,211,153,0.30);
3374 }
3375 .repo-health-badge-strong {
3376 background: rgba(96,165,250,0.15);
3377 color: #93c5fd;
3378 border-color: rgba(96,165,250,0.30);
3379 }
3380 .repo-health-badge-improving {
3381 background: rgba(251,191,36,0.15);
3382 color: #fde68a;
3383 border-color: rgba(251,191,36,0.30);
3384 }
3385 .repo-health-badge-needs-attention {
3386 background: rgba(248,113,113,0.15);
3387 color: #fca5a5;
3388 border-color: rgba(248,113,113,0.30);
3389 }
3390
3391 /* Three-option empty-state panel */
3392 .repo-empty-options {
3393 display: grid;
3394 grid-template-columns: repeat(3, 1fr);
3395 gap: var(--space-3);
3396 margin-top: var(--space-5);
3397 }
3398 @media (max-width: 760px) {
3399 .repo-empty-options { grid-template-columns: 1fr; }
3400 }
3401 .repo-empty-option {
3402 position: relative;
3403 background: var(--bg-elevated);
3404 border: 1px solid var(--border);
3405 border-radius: 14px;
3406 padding: var(--space-4) var(--space-4);
3407 display: flex;
3408 flex-direction: column;
3409 gap: var(--space-2);
3410 overflow: hidden;
3411 transition: border-color 140ms ease, background 140ms ease;
3412 }
3413 .repo-empty-option:hover {
6fd5915Claude3414 border-color: rgba(91,110,232,0.45);
3415 background: rgba(91,110,232,0.04);
91a0204Claude3416 }
3417 .repo-empty-option::before {
3418 content: '';
3419 position: absolute;
3420 top: 0; left: 0; right: 0;
3421 height: 2px;
6fd5915Claude3422 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3423 opacity: 0.55;
3424 pointer-events: none;
3425 }
3426 .repo-empty-option-label {
3427 font-size: 10px;
3428 font-family: var(--font-mono);
3429 font-weight: 700;
3430 letter-spacing: 0.12em;
3431 text-transform: uppercase;
3432 color: var(--accent);
3433 margin-bottom: 2px;
3434 }
3435 .repo-empty-option-title {
3436 font-family: var(--font-display);
3437 font-weight: 700;
3438 font-size: 16px;
3439 letter-spacing: -0.01em;
3440 color: var(--text-strong);
3441 margin: 0;
3442 }
3443 .repo-empty-option-sub {
3444 font-size: 13px;
3445 color: var(--text-muted);
3446 line-height: 1.5;
3447 margin: 0;
3448 flex: 1;
3449 }
3450 .repo-empty-option-snippet {
3451 background: var(--bg);
3452 border: 1px solid var(--border);
3453 border-radius: 8px;
3454 padding: var(--space-2) var(--space-3);
3455 font-family: var(--font-mono);
3456 font-size: 11.5px;
3457 line-height: 1.75;
3458 color: var(--text-strong);
3459 overflow-x: auto;
3460 white-space: pre;
3461 margin: var(--space-1) 0 0;
3462 }
3463 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3464 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3465 .repo-empty-option-cta {
3466 display: inline-flex;
3467 align-items: center;
3468 gap: 6px;
3469 margin-top: auto;
3470 padding-top: var(--space-2);
3471 font-size: 13px;
3472 font-weight: 600;
3473 color: var(--accent);
3474 text-decoration: none;
3475 transition: color 120ms ease;
3476 }
3477 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3478
544d842Claude3479 @media (max-width: 720px) {
3480 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3481 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3482 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3483 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3484 .repo-home-side-val { max-width: 55%; }
3485 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3486 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3487 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3488 }
ebaae0fClaude3489
3490 /* AI stats strip */
3491 .repo-ai-stats-strip {
3492 display: flex;
3493 align-items: center;
3494 gap: 6px;
3495 margin-top: 12px;
3496 margin-bottom: 4px;
3497 font-size: 12px;
3498 color: var(--text-muted);
3499 flex-wrap: wrap;
3500 }
3501 .repo-ai-stats-strip a {
3502 color: var(--text-muted);
3503 text-decoration: underline;
3504 text-decoration-color: transparent;
3505 transition: color 120ms ease, text-decoration-color 120ms ease;
3506 }
3507 .repo-ai-stats-strip a:hover {
3508 color: var(--accent);
3509 text-decoration-color: currentColor;
3510 }
3511 .repo-ai-stats-sep {
3512 opacity: 0.4;
3513 user-select: none;
3514 }
544d842Claude3515 `;
3516 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3517 // SSH URL — port-aware:
3518 // Standard port 22 → git@host:owner/repo.git
3519 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3520 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3521 try {
3522 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3523 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3524 ? host
3525 : "localhost";
3526 const sshPort = config.sshPort;
3527 if (sshPort === 22) {
3528 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3529 } else {
3530 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3531 }
3532 } catch {
3533 // Fall through to default.
3534 }
3535 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3536 const formatRelative = (date: Date | null): string => {
3537 if (!date) return "never";
3538 const ms = Date.now() - date.getTime();
3539 const s = Math.max(0, Math.round(ms / 1000));
3540 if (s < 60) return "just now";
3541 const m = Math.round(s / 60);
3542 if (m < 60) return `${m} min ago`;
3543 const h = Math.round(m / 60);
3544 if (h < 24) return `${h}h ago`;
3545 const d = Math.round(h / 24);
3546 if (d < 30) return `${d}d ago`;
3547 const mo = Math.round(d / 30);
3548 if (mo < 12) return `${mo}mo ago`;
3549 const y = Math.round(d / 365);
3550 return `${y}y ago`;
3551 };
06d5ffeClaude3552
fc1817aClaude3553 if (tree.length === 0) {
5acce80Claude3554 const repoOgDesc = description
3555 ? `${owner}/${repo} on Gluecron — ${description}`
3556 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3557 return c.html(
5acce80Claude3558 <Layout
3559 title={`${owner}/${repo}`}
3560 user={user}
3561 description={repoOgDesc}
3562 ogTitle={`${owner}/${repo} — Gluecron`}
3563 ogDescription={repoOgDesc}
3564 twitterCard="summary"
3565 >
544d842Claude3566 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3567 <style dangerouslySetInnerHTML={{ __html: `
3568 .empty-options-grid {
3569 display: grid;
3570 grid-template-columns: repeat(3, 1fr);
3571 gap: var(--space-4);
3572 margin-top: var(--space-4);
3573 }
3574 @media (max-width: 800px) {
3575 .empty-options-grid { grid-template-columns: 1fr; }
3576 }
3577 .empty-option-card {
3578 position: relative;
3579 background: var(--bg-elevated);
3580 border: 1px solid var(--border);
3581 border-radius: 14px;
3582 padding: var(--space-5);
3583 display: flex;
3584 flex-direction: column;
3585 gap: var(--space-3);
3586 overflow: hidden;
3587 transition: border-color 140ms ease, box-shadow 140ms ease;
3588 }
3589 .empty-option-card:hover {
6fd5915Claude3590 border-color: rgba(91,110,232,0.45);
3591 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3592 }
3593 .empty-option-card::before {
3594 content: '';
3595 position: absolute;
3596 top: 0; left: 0; right: 0;
3597 height: 2px;
6fd5915Claude3598 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3599 opacity: 0.55;
3600 pointer-events: none;
3601 }
3602 .empty-option-label {
3603 font-size: 10.5px;
3604 font-family: var(--font-mono);
3605 text-transform: uppercase;
3606 letter-spacing: 0.14em;
3607 color: var(--accent);
3608 font-weight: 700;
3609 }
3610 .empty-option-title {
3611 font-family: var(--font-display);
3612 font-weight: 700;
3613 font-size: 17px;
3614 letter-spacing: -0.01em;
3615 color: var(--text-strong);
3616 margin: 0;
3617 line-height: 1.25;
3618 }
3619 .empty-option-body {
3620 font-size: 13px;
3621 color: var(--text-muted);
3622 line-height: 1.55;
3623 margin: 0;
3624 flex: 1;
3625 }
3626 .empty-option-snippet {
3627 background: var(--bg);
3628 border: 1px solid var(--border);
3629 border-radius: 8px;
3630 padding: var(--space-3) var(--space-4);
3631 font-family: var(--font-mono);
3632 font-size: 11.5px;
3633 line-height: 1.75;
3634 color: var(--text-strong);
3635 overflow-x: auto;
3636 white-space: pre;
3637 margin: 0;
3638 }
3639 .empty-option-snippet .ec { color: var(--text-faint); }
3640 .empty-option-snippet .em { color: var(--accent); }
3641 .empty-option-cta {
3642 display: inline-flex;
3643 align-items: center;
3644 gap: 6px;
3645 padding: 8px 16px;
3646 font-size: 13px;
3647 font-weight: 600;
3648 color: var(--text-strong);
3649 background: var(--bg-secondary);
3650 border: 1px solid var(--border);
3651 border-radius: 9999px;
3652 text-decoration: none;
3653 align-self: flex-start;
3654 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3655 }
3656 .empty-option-cta:hover {
6fd5915Claude3657 border-color: rgba(91,110,232,0.55);
3658 background: rgba(91,110,232,0.08);
91a0204Claude3659 color: var(--accent);
3660 text-decoration: none;
3661 }
3662 .empty-option-cta-accent {
6fd5915Claude3663 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
91a0204Claude3664 border-color: transparent;
3665 color: #fff;
3666 }
3667 .empty-option-cta-accent:hover {
3668 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3669 border-color: transparent;
3670 color: #fff;
6fd5915Claude3671 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.55);
91a0204Claude3672 }
3673 ` }} />
544d842Claude3674 <div class="repo-home-hero">
3675 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3676 <div class="repo-home-hero-orb" />
3677 </div>
3678 <div class="repo-home-hero-inner">
3679 <div class="repo-home-eyebrow">
3680 <strong>Repository</strong> · {owner}
3681 </div>
91a0204Claude3682 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3683 <RepoHeader
3684 owner={owner}
3685 repo={repo}
3686 starCount={starCount}
3687 starred={starred}
3688 forkCount={forkCount}
3689 currentUser={user?.username}
3690 archived={archived}
3691 isTemplate={isTemplate}
8c790e0Claude3692 recentPush={recentPush}
91a0204Claude3693 />
3694 {healthScore && (() => {
3695 const gradeLabel: Record<string, string> = {
3696 elite: "Elite", strong: "Strong",
3697 improving: "Improving", "needs-attention": "Needs Attention",
3698 };
3699 return (
3700 <a
3701 href={`/${owner}/${repo}/insights/health`}
3702 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3703 title={`Health score: ${healthScore!.total}/100`}
3704 >
3705 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3706 </a>
3707 );
3708 })()}
3709 </div>
544d842Claude3710 {description ? (
3711 <p class="repo-home-description">{description}</p>
3712 ) : (
3713 <p class="repo-home-description-empty">
3714 No description yet — push a README to tell the world what this
3715 ships.
3716 </p>
3717 )}
3718 </div>
3719 </div>
fc1817aClaude3720 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3721 <div style="margin-top:var(--space-4)">
3722 <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)">
3723 Getting started
3724 </div>
544d842Claude3725 <h2 class="repo-home-empty-title">
91a0204Claude3726 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3727 </h2>
3728 <p class="repo-home-empty-sub">
91a0204Claude3729 Choose how you want to kick things off. Every push wires up gate
3730 checks and AI review automatically.
544d842Claude3731 </p>
91a0204Claude3732 <div class="empty-options-grid">
3733 <div class="empty-option-card">
3734 <div class="empty-option-label">Option A</div>
3735 <h3 class="empty-option-title">Push your first commit</h3>
3736 <p class="empty-option-body">
3737 Wire up an existing project in seconds. Run these commands from
3738 your project directory.
3739 </p>
3740 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3741<span class="em">git remote add</span> origin {cloneHttpsUrl}
3742<span class="em">git branch</span> -M main
3743<span class="em">git push</span> -u origin main</pre>
3744 </div>
3745 <div class="empty-option-card">
3746 <div class="empty-option-label">Option B</div>
3747 <h3 class="empty-option-title">Import from GitHub</h3>
3748 <p class="empty-option-body">
3749 Mirror an existing GitHub repository here in one click. Gluecron
3750 syncs the full history and branches.
3751 </p>
3752 <a href="/import" class="empty-option-cta">
3753 Import repository
3754 </a>
3755 </div>
3756 <div class="empty-option-card">
3757 <div class="empty-option-label">Option C</div>
3758 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3759 <p class="empty-option-body">
3760 Let AI write your first feature. Describe what you want to build
3761 and Gluecron opens a pull request with the code.
3762 </p>
3763 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3764 Let AI write your first feature
3765 </a>
3766 </div>
3767 </div>
fc1817aClaude3768 </div>
3769 </Layout>
3770 );
3771 }
3772
3773 const readme = await getReadme(owner, repo, defaultBranch);
3774
544d842Claude3775 // Sidebar facts — derived from data we already have.
3776 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3777 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3778
5acce80Claude3779 const repoOgDesc = description
3780 ? `${owner}/${repo} on Gluecron — ${description}`
3781 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3782
fc1817aClaude3783 return c.html(
5acce80Claude3784 <Layout
3785 title={`${owner}/${repo}`}
3786 user={user}
3787 description={repoOgDesc}
3788 ogTitle={`${owner}/${repo} — Gluecron`}
3789 ogDescription={repoOgDesc}
3790 twitterCard="summary"
3791 >
544d842Claude3792 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3793 {/* Repo-context commands for the command palette (Feature 2) */}
3794 <script
3795 id="cmdk-repo-context"
3796 dangerouslySetInnerHTML={{
3797 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3798 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3799 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3800 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3801 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3802 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3803 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3804 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3805 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3806 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3807 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3808 ])};`,
3809 }}
3810 />
544d842Claude3811 <div class="repo-home-hero">
3812 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3813 <div class="repo-home-hero-orb" />
3814 </div>
3815 <div class="repo-home-hero-inner">
3816 <div class="repo-home-eyebrow">
3817 <strong>Repository</strong> · {owner}
3818 </div>
91a0204Claude3819 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3820 <RepoHeader
3821 owner={owner}
3822 repo={repo}
3823 starCount={starCount}
3824 starred={starred}
3825 forkCount={forkCount}
3826 currentUser={user?.username}
3827 archived={archived}
3828 isTemplate={isTemplate}
8c790e0Claude3829 recentPush={recentPush}
91a0204Claude3830 />
3831 {healthScore && (() => {
3832 const gradeLabel: Record<string, string> = {
3833 elite: "Elite", strong: "Strong",
3834 improving: "Improving", "needs-attention": "Needs Attention",
3835 };
3836 return (
3837 <a
3838 href={`/${owner}/${repo}/insights/health`}
3839 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3840 title={`Health score: ${healthScore!.total}/100`}
3841 >
3842 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3843 </a>
3844 );
3845 })()}
3846 </div>
544d842Claude3847 {description ? (
3848 <p class="repo-home-description">{description}</p>
3849 ) : (
3850 <p class="repo-home-description-empty">
3851 No description yet.
3852 </p>
3853 )}
3854 <div class="repo-home-stat-row" aria-label="Repository stats">
3855 <span class="repo-home-stat" title="Default branch">
3856 <span class="repo-home-stat-icon">{"⎇"}</span>
3857 <strong>{defaultBranch}</strong>
3858 </span>
3859 <a
3860 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3861 class="repo-home-stat"
3862 title="Browse all branches"
3863 >
3864 <span class="repo-home-stat-icon">{"⊢"}</span>
3865 <strong>{branches.length}</strong>{" "}
3866 branch{branches.length === 1 ? "" : "es"}
3867 </a>
3868 <span class="repo-home-stat" title="Top-level entries">
3869 <span class="repo-home-stat-icon">{"■"}</span>
3870 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3871 {dirCount > 0 && (
3872 <>
3873 {" · "}
3874 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3875 </>
3876 )}
3877 </span>
3878 {pushedAt && (
3879 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3880 <span class="repo-home-stat-icon">{"↻"}</span>
3881 Updated <strong>{formatRelative(pushedAt)}</strong>
3882 </span>
3883 )}
3884 </div>
3885 </div>
3886 </div>
71cd5ecClaude3887 {isTemplate && user && user.username !== owner && (
3888 <div
3889 class="panel"
dc26881CC LABS App3890 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3891 >
3892 <div style="font-size:13px">
3893 <strong>Template repository.</strong> Create a new repository from
3894 this template's files.
3895 </div>
3896 <form
001af43Claude3897 method="post"
71cd5ecClaude3898 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3899 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3900 >
3901 <input
3902 type="text"
3903 name="name"
3904 placeholder="new-repo-name"
3905 required
2c3ba6ecopilot-swe-agent[bot]3906 aria-label="New repository name"
71cd5ecClaude3907 style="width:200px"
3908 />
3909 <button type="submit" class="btn btn-primary">
3910 Use this template
3911 </button>
3912 </form>
3913 </div>
3914 )}
fc1817aClaude3915 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3916 <RepoHomePendingBanner
3917 owner={owner}
3918 repo={repo}
3919 count={repoHomePendingCount}
3920 />
641aa42Claude3921 {showOnboarding && onboardingRow && (
3922 <RepoOnboardingCard
3923 owner={owner}
3924 repo={repo}
3925 data={onboardingRow}
3926 />
3927 )}
c6018a5Claude3928 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3929 row sits just below the nav as a slim CTA strip. Scoped under
3930 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3931 <style
3932 dangerouslySetInnerHTML={{
3933 __html: `
3934 .repo-ai-cta-row {
3935 display: flex;
3936 flex-wrap: wrap;
3937 gap: 8px;
3938 margin: 12px 0 18px;
3939 padding: 10px 14px;
6fd5915Claude3940 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude3941 border: 1px solid var(--border);
3942 border-radius: 10px;
3943 position: relative;
3944 overflow: hidden;
3945 }
3946 .repo-ai-cta-row::before {
3947 content: '';
3948 position: absolute;
3949 top: 0; left: 0; right: 0;
3950 height: 1px;
6fd5915Claude3951 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude3952 opacity: 0.7;
3953 pointer-events: none;
3954 }
3955 .repo-ai-cta-label {
3956 font-size: 11px;
3957 font-weight: 600;
3958 letter-spacing: 0.06em;
3959 text-transform: uppercase;
3960 color: var(--accent);
3961 align-self: center;
3962 padding-right: 8px;
3963 border-right: 1px solid var(--border);
3964 margin-right: 4px;
3965 }
3966 .repo-ai-cta {
3967 display: inline-flex;
3968 align-items: center;
3969 gap: 6px;
3970 padding: 5px 10px;
3971 font-size: 12.5px;
3972 font-weight: 500;
3973 color: var(--text);
3974 background: var(--bg);
3975 border: 1px solid var(--border);
3976 border-radius: 7px;
3977 text-decoration: none;
3978 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3979 }
3980 .repo-ai-cta:hover {
6fd5915Claude3981 border-color: rgba(91,110,232,0.45);
c6018a5Claude3982 color: var(--text-strong);
6fd5915Claude3983 background: rgba(91,110,232,0.06);
c6018a5Claude3984 text-decoration: none;
3985 }
3986 .repo-ai-cta-icon {
3987 opacity: 0.75;
3988 font-size: 12px;
3989 }
3990 @media (max-width: 640px) {
3991 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3992 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3993 }
3994 `,
3995 }}
3996 />
3997 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3998 <span class="repo-ai-cta-label">AI surfaces</span>
3999 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4000 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4001 Chat
4002 </a>
4003 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4004 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4005 Previews
4006 </a>
4007 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4008 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4009 Migrations
4010 </a>
53c9249Claude4011 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4012 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4013 AI Search
c6018a5Claude4014 </a>
4015 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4016 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4017 AI release notes
4018 </a>
3646bfeClaude4019 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4020 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4021 Dev environment
4022 </a>
c6018a5Claude4023 </nav>
544d842Claude4024 <div class="repo-home-grid">
4025 <div class="repo-home-main">
4026 <BranchSwitcher
4027 owner={owner}
4028 repo={repo}
4029 currentRef={defaultBranch}
4030 branches={branches}
4031 pathType="tree"
4032 />
4033 <FileTable
4034 entries={tree}
4035 owner={owner}
4036 repo={repo}
4037 ref={defaultBranch}
4038 path=""
4039 />
ebaae0fClaude4040 {/* AI stats strip — one-line summary of AI value for this repo */}
4041 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4042 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4043 <span>{"⚡"}</span>
4044 {aiStats.mergedCount > 0 ? (
4045 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4046 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4047 </a>
4048 ) : (
4049 <span>0 AI merges this week</span>
4050 )}
4051 <span class="repo-ai-stats-sep">{"·"}</span>
4052 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4053 <span class="repo-ai-stats-sep">{"·"}</span>
4054 {aiStats.securityAlertCount > 0 ? (
4055 <a href={`/${owner}/${repo}/issues?label=security`}>
4056 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4057 </a>
4058 ) : (
4059 <span>0 open security alerts</span>
4060 )}
4061 </div>
4062 ) : (
4063 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4064 <span>{"⚡"}</span>
4065 <span>AI is watching — no activity this week</span>
4066 </div>
4067 )}
544d842Claude4068 {readme && (() => {
4069 const readmeHtml = renderMarkdown(readme);
4070 return (
4071 <div class="repo-home-readme">
4072 <div class="repo-home-readme-head">
4073 <span class="repo-home-readme-icon">{"☰"}</span>
4074 <span>README.md</span>
4075 </div>
4076 <style>{markdownCss}</style>
4077 <div class="markdown-body repo-home-readme-body">
4078 {html([readmeHtml] as unknown as TemplateStringsArray)}
4079 </div>
4080 </div>
4081 );
4082 })()}
4083 </div>
4084 <aside class="repo-home-side" aria-label="Repository details">
4085 <div class="repo-home-clone">
4086 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4087 <button
4088 type="button"
4089 class="repo-home-clone-tab"
4090 role="tab"
4091 aria-selected="true"
4092 data-pane="https"
4093 data-repo-home-clone-tab
4094 >
4095 HTTPS
4096 </button>
4097 <button
4098 type="button"
4099 class="repo-home-clone-tab"
4100 role="tab"
4101 aria-selected="false"
4102 data-pane="ssh"
4103 data-repo-home-clone-tab
4104 >
4105 SSH
4106 </button>
4107 <button
4108 type="button"
4109 class="repo-home-clone-tab"
4110 role="tab"
4111 aria-selected="false"
4112 data-pane="cli"
4113 data-repo-home-clone-tab
4114 >
4115 CLI
4116 </button>
4117 </div>
4118 <div
4119 class="repo-home-clone-pane"
4120 data-pane="https"
4121 data-active="true"
4122 role="tabpanel"
4123 >
4124 <div class="repo-home-clone-body">
4125 <input
4126 class="repo-home-clone-input"
4127 type="text"
4128 value={cloneHttpsUrl}
4129 readonly
4130 aria-label="HTTPS clone URL"
4131 data-repo-home-clone-input
4132 />
4133 <button
4134 type="button"
4135 class="repo-home-clone-copy"
4136 data-repo-home-copy={cloneHttpsUrl}
4137 >
4138 Copy
4139 </button>
4140 </div>
4141 </div>
4142 <div
4143 class="repo-home-clone-pane"
4144 data-pane="ssh"
4145 data-active="false"
4146 role="tabpanel"
4147 >
4148 <div class="repo-home-clone-body">
4149 <input
4150 class="repo-home-clone-input"
4151 type="text"
4152 value={cloneSshUrl}
4153 readonly
4154 aria-label="SSH clone URL"
4155 data-repo-home-clone-input
4156 />
4157 <button
4158 type="button"
4159 class="repo-home-clone-copy"
4160 data-repo-home-copy={cloneSshUrl}
4161 >
4162 Copy
4163 </button>
4164 </div>
4165 </div>
4166 <div
4167 class="repo-home-clone-pane"
4168 data-pane="cli"
4169 data-active="false"
4170 role="tabpanel"
4171 >
4172 <div class="repo-home-clone-body">
4173 <input
4174 class="repo-home-clone-input"
4175 type="text"
4176 value={cloneCliCmd}
4177 readonly
4178 aria-label="Gluecron CLI clone command"
4179 data-repo-home-clone-input
4180 />
4181 <button
4182 type="button"
4183 class="repo-home-clone-copy"
4184 data-repo-home-copy={cloneCliCmd}
4185 >
4186 Copy
4187 </button>
4188 </div>
79136bbClaude4189 </div>
fc1817aClaude4190 </div>
544d842Claude4191 <div class="repo-home-side-card">
4192 <h3 class="repo-home-side-title">About</h3>
4193 <div class="repo-home-side-row">
4194 <span class="repo-home-side-key">Default branch</span>
4195 <span class="repo-home-side-val">{defaultBranch}</span>
4196 </div>
4197 <div class="repo-home-side-row">
4198 <span class="repo-home-side-key">Branches</span>
4199 <span class="repo-home-side-val">
4200 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4201 {branches.length}
4202 </a>
4203 </span>
4204 </div>
4205 <div class="repo-home-side-row">
4206 <span class="repo-home-side-key">Stars</span>
4207 <span class="repo-home-side-val">{starCount}</span>
4208 </div>
4209 <div class="repo-home-side-row">
4210 <span class="repo-home-side-key">Forks</span>
4211 <span class="repo-home-side-val">{forkCount}</span>
4212 </div>
4213 {pushedAt && (
4214 <div class="repo-home-side-row">
4215 <span class="repo-home-side-key">Last push</span>
4216 <span class="repo-home-side-val">
4217 {formatRelative(pushedAt)}
4218 </span>
4219 </div>
4220 )}
4221 {createdAt && (
4222 <div class="repo-home-side-row">
4223 <span class="repo-home-side-key">Created</span>
4224 <span class="repo-home-side-val">
4225 {formatRelative(createdAt)}
4226 </span>
4227 </div>
4228 )}
4229 {(archived || isTemplate) && (
4230 <div class="repo-home-side-row">
4231 <span class="repo-home-side-key">State</span>
4232 <span class="repo-home-side-val">
4233 {archived ? "Archived" : "Template"}
4234 </span>
4235 </div>
4236 )}
4237 </div>
f1dc38bClaude4238 {/* Claude AI — quick-access card for authenticated users */}
4239 {user && (
4240 <div class="repo-home-side-card" style="margin-top:12px">
4241 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4242 ✨ Claude AI
4243 </h3>
4244 <a
4245 href={`/${owner}/${repo}/claude`}
4246 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"
4247 >
4248 Claude Code sessions
4249 </a>
4250 <a
4251 href={`/${owner}/${repo}/ask`}
4252 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4253 >
4254 Ask AI about this repo
4255 </a>
4256 </div>
4257 )}
544d842Claude4258 </aside>
4259 </div>
4260 <script
4261 dangerouslySetInnerHTML={{
4262 __html: `
4263 (function(){
4264 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4265 tabs.forEach(function(tab){
4266 tab.addEventListener('click', function(){
4267 var target = tab.getAttribute('data-pane');
4268 tabs.forEach(function(t){
4269 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4270 });
4271 var panes = document.querySelectorAll('.repo-home-clone-pane');
4272 panes.forEach(function(p){
4273 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4274 });
4275 });
4276 });
4277 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4278 copyBtns.forEach(function(btn){
4279 btn.addEventListener('click', function(){
4280 var text = btn.getAttribute('data-repo-home-copy') || '';
4281 var done = function(){
4282 var prev = btn.textContent;
4283 btn.textContent = 'Copied';
4284 setTimeout(function(){ btn.textContent = prev; }, 1200);
4285 };
4286 if (navigator.clipboard && navigator.clipboard.writeText) {
4287 navigator.clipboard.writeText(text).then(done, done);
4288 } else {
4289 var ta = document.createElement('textarea');
4290 ta.value = text;
4291 document.body.appendChild(ta);
4292 ta.select();
4293 try { document.execCommand('copy'); } catch (e) {}
4294 document.body.removeChild(ta);
4295 done();
4296 }
4297 });
4298 });
4299 })();
4300 `,
4301 }}
4302 />
fc1817aClaude4303 </Layout>
4304 );
4305});
4306
4307// Browse tree at ref/path
4308web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4309 const { owner, repo } = c.req.param();
06d5ffeClaude4310 const user = c.get("user");
fc1817aClaude4311 const refAndPath = c.req.param("ref");
4312
4313 const branches = await listBranches(owner, repo);
4314 let ref = "";
4315 let treePath = "";
4316
4317 for (const branch of branches) {
4318 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4319 ref = branch;
4320 treePath = refAndPath.slice(branch.length + 1);
4321 break;
4322 }
4323 }
4324
4325 if (!ref) {
4326 const slashIdx = refAndPath.indexOf("/");
4327 if (slashIdx === -1) {
4328 ref = refAndPath;
4329 } else {
4330 ref = refAndPath.slice(0, slashIdx);
4331 treePath = refAndPath.slice(slashIdx + 1);
4332 }
4333 }
4334
4335 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4336 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4337 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4338
4339 return c.html(
06d5ffeClaude4340 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4341 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4342 <RepoHeader owner={owner} repo={repo} />
4343 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4344 <div class="tree-header">
4345 <div class="tree-header-row">
4346 <BranchSwitcher
4347 owner={owner}
4348 repo={repo}
4349 currentRef={ref}
4350 branches={branches}
4351 pathType="tree"
4352 subPath={treePath}
4353 />
4354 <div class="tree-header-stats">
4355 <span class="tree-stat" title="Entries in this directory">
4356 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4357 {dirCount > 0 && (
4358 <>
4359 {" · "}
4360 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4361 </>
4362 )}
4363 </span>
4364 <a
4365 href={`/${owner}/${repo}/search`}
4366 class="tree-stat tree-stat-link"
4367 title="Search code in this repository"
4368 >
4369 {"⌕"} Search
4370 </a>
4371 </div>
4372 </div>
4373 <div class="tree-breadcrumb-row">
4374 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4375 </div>
4376 </div>
fc1817aClaude4377 <FileTable
4378 entries={tree}
4379 owner={owner}
4380 repo={repo}
4381 ref={ref}
4382 path={treePath}
4383 />
4384 </Layout>
4385 );
4386});
4387
06d5ffeClaude4388// View file blob with syntax highlighting
fc1817aClaude4389web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4390 const { owner, repo } = c.req.param();
06d5ffeClaude4391 const user = c.get("user");
fc1817aClaude4392 const refAndPath = c.req.param("ref");
4393
4394 const branches = await listBranches(owner, repo);
4395 let ref = "";
4396 let filePath = "";
4397
4398 for (const branch of branches) {
4399 if (refAndPath.startsWith(branch + "/")) {
4400 ref = branch;
4401 filePath = refAndPath.slice(branch.length + 1);
4402 break;
4403 }
4404 }
4405
4406 if (!ref) {
4407 const slashIdx = refAndPath.indexOf("/");
4408 if (slashIdx === -1) return c.text("Not found", 404);
4409 ref = refAndPath.slice(0, slashIdx);
4410 filePath = refAndPath.slice(slashIdx + 1);
4411 }
4412
4413 const blob = await getBlob(owner, repo, ref, filePath);
4414 if (!blob) {
4415 return c.html(
06d5ffeClaude4416 <Layout title="Not Found" user={user}>
fc1817aClaude4417 <div class="empty-state">
4418 <h2>File not found</h2>
4419 </div>
4420 </Layout>,
4421 404
4422 );
4423 }
4424
06d5ffeClaude4425 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4426 const lineCount = blob.isBinary
4427 ? 0
4428 : (blob.content.endsWith("\n")
4429 ? blob.content.split("\n").length - 1
4430 : blob.content.split("\n").length);
4431 const formatBytes = (n: number): string => {
4432 if (n < 1024) return `${n} B`;
4433 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4434 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4435 };
fc1817aClaude4436
4437 return c.html(
06d5ffeClaude4438 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4439 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4440 <RepoHeader owner={owner} repo={repo} />
4441 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4442 <div class="blob-toolbar">
4443 <BranchSwitcher
4444 owner={owner}
4445 repo={repo}
4446 currentRef={ref}
4447 branches={branches}
4448 pathType="blob"
4449 subPath={filePath}
4450 />
4451 <div class="blob-breadcrumb">
4452 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4453 </div>
4454 </div>
4455 <div class="blob-view blob-card">
4456 <div class="blob-header blob-header-polished">
4457 <div class="blob-header-meta">
4458 <span class="blob-header-icon" aria-hidden="true">
4459 {"📄"}
4460 </span>
4461 <span class="blob-header-name">{fileName}</span>
4462 <span class="blob-header-size">
4463 {formatBytes(blob.size)}
4464 {!blob.isBinary && (
4465 <>
4466 {" · "}
4467 {lineCount} line{lineCount === 1 ? "" : "s"}
4468 </>
4469 )}
4470 </span>
4471 </div>
4472 <div class="blob-header-actions">
4473 <a
4474 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4475 class="blob-pill"
4476 >
79136bbClaude4477 Raw
4478 </a>
efb11c5Claude4479 <a
4480 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4481 class="blob-pill"
4482 >
79136bbClaude4483 Blame
4484 </a>
efb11c5Claude4485 <a
4486 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4487 class="blob-pill"
4488 >
16b325cClaude4489 History
4490 </a>
0074234Claude4491 {user && (
efb11c5Claude4492 <a
4493 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4494 class="blob-pill blob-pill-accent"
4495 >
0074234Claude4496 Edit
4497 </a>
4498 )}
efb11c5Claude4499 </div>
fc1817aClaude4500 </div>
4501 {blob.isBinary ? (
efb11c5Claude4502 <div class="blob-binary">
fc1817aClaude4503 Binary file not shown.
4504 </div>
06d5ffeClaude4505 ) : (() => {
4506 const { html: highlighted, language } = highlightCode(
4507 blob.content,
4508 fileName
4509 );
4510 if (language) {
4511 return (
4512 <HighlightedCode
4513 highlightedHtml={highlighted}
efb11c5Claude4514 lineCount={lineCount}
06d5ffeClaude4515 />
4516 );
4517 }
4518 const lines = blob.content.split("\n");
4519 if (lines[lines.length - 1] === "") lines.pop();
4520 return <PlainCode lines={lines} />;
4521 })()}
fc1817aClaude4522 </div>
4523 </Layout>
4524 );
4525});
4526
398a10cClaude4527// ─── Branches list ────────────────────────────────────────────────────────
4528// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4529// author + relative time + ahead/behind vs the default branch. No DB. All
4530// data comes from git plumbing; failures degrade gracefully (counts omitted).
4531web.get("/:owner/:repo/branches", async (c) => {
4532 const { owner, repo } = c.req.param();
4533 const user = c.get("user");
4534
4535 if (!(await repoExists(owner, repo))) return c.notFound();
4536
4537 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4538 const branches = await listBranches(owner, repo);
4539 const repoDir = getRepoPath(owner, repo);
4540
4541 type BranchRow = {
4542 name: string;
4543 isDefault: boolean;
4544 sha: string;
4545 subject: string;
4546 author: string;
4547 date: string;
4548 ahead: number;
4549 behind: number;
4550 };
4551
4552 const runGit = async (args: string[]): Promise<string> => {
4553 try {
4554 const proc = Bun.spawn(["git", ...args], {
4555 cwd: repoDir,
4556 stdout: "pipe",
4557 stderr: "pipe",
4558 });
4559 const out = await new Response(proc.stdout).text();
4560 await proc.exited;
4561 return out;
4562 } catch {
4563 return "";
4564 }
4565 };
4566
4567 const meta = await runGit([
4568 "for-each-ref",
4569 "--sort=-committerdate",
4570 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4571 "refs/heads/",
4572 ]);
4573 const metaByName: Record<
4574 string,
4575 { sha: string; subject: string; author: string; date: string }
4576 > = {};
4577 for (const line of meta.split("\n").filter(Boolean)) {
4578 const [name, sha, subject, author, date] = line.split("\0");
4579 metaByName[name] = { sha, subject, author, date };
4580 }
4581
4582 const branchOrder = [...branches].sort((a, b) => {
4583 if (a === defaultBranch) return -1;
4584 if (b === defaultBranch) return 1;
4585 const aDate = metaByName[a]?.date || "";
4586 const bDate = metaByName[b]?.date || "";
4587 return bDate.localeCompare(aDate);
4588 });
4589
4590 const rows: BranchRow[] = [];
4591 for (const name of branchOrder) {
4592 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4593 let ahead = 0;
4594 let behind = 0;
4595 if (name !== defaultBranch && metaByName[defaultBranch]) {
4596 const out = await runGit([
4597 "rev-list",
4598 "--left-right",
4599 "--count",
4600 `${defaultBranch}...${name}`,
4601 ]);
4602 const parts = out.trim().split(/\s+/);
4603 if (parts.length === 2) {
4604 behind = parseInt(parts[0], 10) || 0;
4605 ahead = parseInt(parts[1], 10) || 0;
4606 }
4607 }
4608 rows.push({
4609 name,
4610 isDefault: name === defaultBranch,
4611 sha: m.sha,
4612 subject: m.subject,
4613 author: m.author,
4614 date: m.date,
4615 ahead,
4616 behind,
4617 });
4618 }
4619
4620 const relative = (iso: string): string => {
4621 if (!iso) return "—";
4622 const d = new Date(iso);
4623 if (Number.isNaN(d.getTime())) return "—";
4624 const diff = Date.now() - d.getTime();
4625 const m = Math.floor(diff / 60000);
4626 if (m < 1) return "just now";
4627 if (m < 60) return `${m} min ago`;
4628 const h = Math.floor(m / 60);
4629 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4630 const dd = Math.floor(h / 24);
4631 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4632 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4633 };
4634
4635 const success = c.req.query("success");
4636 const error = c.req.query("error");
4637
4638 return c.html(
4639 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4640 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4641 <RepoHeader owner={owner} repo={repo} />
4642 <RepoNav owner={owner} repo={repo} active="code" />
4643 <div
4644 class="branches-wrap"
eed4684Claude4645 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4646 >
4647 <header class="branches-head" style="margin-bottom:var(--space-5)">
4648 <div
4649 class="branches-eyebrow"
4650 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"
4651 >
4652 <span
4653 class="branches-eyebrow-dot"
4654 aria-hidden="true"
6fd5915Claude4655 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);box-shadow:0 0 0 3px rgba(91,110,232,0.18)"
398a10cClaude4656 />
4657 Repository · Branches
4658 </div>
4659 <h1
4660 class="branches-title"
4661 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)"
4662 >
4663 <span class="gradient-text">{rows.length}</span>{" "}
4664 branch{rows.length === 1 ? "" : "es"}
4665 </h1>
4666 <p
4667 class="branches-sub"
4668 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4669 >
4670 All work-in-progress lines for{" "}
4671 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4672 counts are relative to{" "}
4673 <code style="font-size:12.5px">{defaultBranch}</code>.
4674 </p>
4675 </header>
4676
4677 {success && (
4678 <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">
4679 {decodeURIComponent(success)}
4680 </div>
4681 )}
4682 {error && (
4683 <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">
4684 {decodeURIComponent(error)}
4685 </div>
4686 )}
4687
4688 {rows.length === 0 ? (
4689 <div class="commits-empty">
4690 <div class="commits-empty-orb" aria-hidden="true" />
4691 <div class="commits-empty-inner">
4692 <div class="commits-empty-icon" aria-hidden="true">
4693 <svg
4694 width="22"
4695 height="22"
4696 viewBox="0 0 24 24"
4697 fill="none"
4698 stroke="currentColor"
4699 stroke-width="2"
4700 stroke-linecap="round"
4701 stroke-linejoin="round"
4702 >
4703 <line x1="6" y1="3" x2="6" y2="15" />
4704 <circle cx="18" cy="6" r="3" />
4705 <circle cx="6" cy="18" r="3" />
4706 <path d="M18 9a9 9 0 0 1-9 9" />
4707 </svg>
4708 </div>
4709 <h3 class="commits-empty-title">No branches yet</h3>
4710 <p class="commits-empty-sub">
4711 Push your first commit to create the default branch.
4712 </p>
4713 </div>
4714 </div>
4715 ) : (
4716 <div class="branches-list">
4717 {rows.map((r) => (
4718 <div class="branches-row">
4719 <div class="branches-row-icon" aria-hidden="true">
4720 <svg
4721 width="14"
4722 height="14"
4723 viewBox="0 0 24 24"
4724 fill="none"
4725 stroke="currentColor"
4726 stroke-width="2"
4727 stroke-linecap="round"
4728 stroke-linejoin="round"
4729 >
4730 <line x1="6" y1="3" x2="6" y2="15" />
4731 <circle cx="18" cy="6" r="3" />
4732 <circle cx="6" cy="18" r="3" />
4733 <path d="M18 9a9 9 0 0 1-9 9" />
4734 </svg>
4735 </div>
4736 <div class="branches-row-main">
4737 <div class="branches-row-name">
4738 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4739 {r.isDefault && (
4740 <span
4741 class="branches-row-default"
4742 title="Default branch"
4743 >
4744 Default
4745 </span>
4746 )}
4747 </div>
4748 <div class="branches-row-meta">
4749 {r.subject ? (
4750 <>
4751 <strong>{r.author || "—"}</strong>
4752 <span class="sep">·</span>
4753 <span
4754 title={r.date ? new Date(r.date).toISOString() : ""}
4755 >
4756 updated {relative(r.date)}
4757 </span>
4758 {r.sha && (
4759 <>
4760 <span class="sep">·</span>
4761 <a
4762 href={`/${owner}/${repo}/commit/${r.sha}`}
4763 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4764 >
4765 {r.sha.slice(0, 7)}
4766 </a>
4767 </>
4768 )}
4769 </>
4770 ) : (
4771 <span>No commit metadata</span>
4772 )}
4773 </div>
4774 </div>
4775 <div class="branches-row-side">
4776 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4777 <span
4778 class="branches-row-divergence"
4779 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4780 >
4781 <span class="ahead">{r.ahead} ahead</span>
4782 <span style="opacity:0.4">|</span>
4783 <span class="behind">{r.behind} behind</span>
4784 </span>
4785 )}
4786 <div class="branches-row-actions">
4787 <a
4788 href={`/${owner}/${repo}/commits/${r.name}`}
4789 class="branches-btn"
4790 title="View commits on this branch"
4791 >
4792 Commits
4793 </a>
4794 {!r.isDefault &&
4795 user &&
4796 user.username === owner && (
4797 <form
4798 method="post"
4799 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4800 style="margin:0"
4801 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4802 >
4803 <button
4804 type="submit"
4805 class="branches-btn branches-btn-danger"
4806 >
4807 Delete
4808 </button>
4809 </form>
4810 )}
4811 </div>
4812 </div>
4813 </div>
4814 ))}
4815 </div>
4816 )}
4817 </div>
4818 </Layout>
4819 );
4820});
4821
4822// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4823// that are not merged into the default branch — matches the explicit
4824// confirmation on the row's delete button.
4825web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4826 const { owner, repo } = c.req.param();
4827 const branchName = decodeURIComponent(c.req.param("name"));
4828 const user = c.get("user")!;
4829
4830 // Owner-only check (mirrors collaborators.tsx pattern).
4831 const [ownerRow] = await db
4832 .select()
4833 .from(users)
4834 .where(eq(users.username, owner))
4835 .limit(1);
4836 if (!ownerRow || ownerRow.id !== user.id) {
4837 return c.redirect(
4838 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4839 );
4840 }
4841
4842 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4843 if (branchName === defaultBranch) {
4844 return c.redirect(
4845 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4846 );
4847 }
4848
4849 const branches = await listBranches(owner, repo);
4850 if (!branches.includes(branchName)) {
4851 return c.redirect(
4852 `/${owner}/${repo}/branches?error=Branch+not+found`
4853 );
4854 }
4855
4856 try {
4857 const repoDir = getRepoPath(owner, repo);
4858 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4859 cwd: repoDir,
4860 stdout: "pipe",
4861 stderr: "pipe",
4862 });
4863 await proc.exited;
4864 if (proc.exitCode !== 0) {
4865 return c.redirect(
4866 `/${owner}/${repo}/branches?error=Delete+failed`
4867 );
4868 }
4869 } catch {
4870 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4871 }
4872
4873 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4874});
4875
4876// ─── Tags list ────────────────────────────────────────────────────────────
4877// Pulls from `listTags` (sorted newest first). For each tag we look up an
4878// associated release row so the "View release" CTA can deep-link directly
4879// without making the user hunt for it.
4880web.get("/:owner/:repo/tags", async (c) => {
4881 const { owner, repo } = c.req.param();
4882 const user = c.get("user");
4883
4884 if (!(await repoExists(owner, repo))) return c.notFound();
4885
4886 const tags = await listTags(owner, repo);
4887
4888 // Map tags -> releases. Best-effort; releases table may not exist in
4889 // every test setup, so any error falls through with an empty set.
4890 const tagsWithReleases = new Set<string>();
4891 try {
4892 const [ownerRow] = await db
4893 .select()
4894 .from(users)
4895 .where(eq(users.username, owner))
4896 .limit(1);
4897 if (ownerRow) {
4898 const [repoRow] = await db
4899 .select()
4900 .from(repositories)
4901 .where(
4902 and(
4903 eq(repositories.ownerId, ownerRow.id),
4904 eq(repositories.name, repo)
4905 )
4906 )
4907 .limit(1);
4908 if (repoRow) {
4909 // Raw SQL so we don't need to import the releases schema here.
4910 const result = await db.execute(
4911 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4912 );
4913 const rows: any[] = (result as any).rows || (result as any) || [];
4914 for (const row of rows) {
4915 const tag = row?.tag;
4916 if (typeof tag === "string") tagsWithReleases.add(tag);
4917 }
4918 }
4919 }
4920 } catch {
4921 // No releases table or DB error — leave set empty.
4922 }
4923
4924 const relative = (iso: string): string => {
4925 if (!iso) return "—";
4926 const d = new Date(iso);
4927 if (Number.isNaN(d.getTime())) return "—";
4928 const diff = Date.now() - d.getTime();
4929 const m = Math.floor(diff / 60000);
4930 if (m < 1) return "just now";
4931 if (m < 60) return `${m} min ago`;
4932 const h = Math.floor(m / 60);
4933 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4934 const dd = Math.floor(h / 24);
4935 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4936 return d.toLocaleDateString("en-US", {
4937 month: "short",
4938 day: "numeric",
4939 year: "numeric",
4940 });
4941 };
4942
4943 return c.html(
4944 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4945 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4946 <RepoHeader owner={owner} repo={repo} />
4947 <RepoNav owner={owner} repo={repo} active="code" />
4948 <div
4949 class="tags-wrap"
eed4684Claude4950 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4951 >
4952 <header class="tags-head" style="margin-bottom:var(--space-5)">
4953 <div
4954 class="tags-eyebrow"
4955 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"
4956 >
4957 <span
4958 class="tags-eyebrow-dot"
4959 aria-hidden="true"
6fd5915Claude4960 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);box-shadow:0 0 0 3px rgba(91,110,232,0.18)"
398a10cClaude4961 />
4962 Repository · Tags
4963 </div>
4964 <h1
4965 class="tags-title"
4966 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)"
4967 >
4968 <span class="gradient-text">{tags.length}</span>{" "}
4969 tag{tags.length === 1 ? "" : "s"}
4970 </h1>
4971 <p
4972 class="tags-sub"
4973 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4974 >
4975 Named points in the history — typically releases, milestones,
4976 or shipped versions. Click a tag to browse the tree at that
4977 revision.
4978 </p>
4979 </header>
4980
4981 {tags.length === 0 ? (
4982 <div class="commits-empty">
4983 <div class="commits-empty-orb" aria-hidden="true" />
4984 <div class="commits-empty-inner">
4985 <div class="commits-empty-icon" aria-hidden="true">
4986 <svg
4987 width="22"
4988 height="22"
4989 viewBox="0 0 24 24"
4990 fill="none"
4991 stroke="currentColor"
4992 stroke-width="2"
4993 stroke-linecap="round"
4994 stroke-linejoin="round"
4995 >
4996 <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" />
4997 <line x1="7" y1="7" x2="7.01" y2="7" />
4998 </svg>
4999 </div>
5000 <h3 class="commits-empty-title">No tags yet</h3>
5001 <p class="commits-empty-sub">
5002 Tag a commit to mark a release or milestone. From the CLI:{" "}
5003 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5004 </p>
5005 </div>
5006 </div>
5007 ) : (
5008 <div class="tags-list">
5009 {tags.map((t) => {
5010 const hasRelease = tagsWithReleases.has(t.name);
5011 return (
5012 <div class="tags-row">
5013 <div class="tags-row-icon" aria-hidden="true">
5014 <svg
5015 width="14"
5016 height="14"
5017 viewBox="0 0 24 24"
5018 fill="none"
5019 stroke="currentColor"
5020 stroke-width="2"
5021 stroke-linecap="round"
5022 stroke-linejoin="round"
5023 >
5024 <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" />
5025 <line x1="7" y1="7" x2="7.01" y2="7" />
5026 </svg>
5027 </div>
5028 <div class="tags-row-main">
5029 <div class="tags-row-name">
5030 <a
5031 href={`/${owner}/${repo}/tree/${t.name}`}
5032 style="text-decoration:none"
5033 >
5034 <span class="tags-row-version">{t.name}</span>
5035 </a>
5036 </div>
5037 <div class="tags-row-meta">
5038 <span
5039 title={t.date ? new Date(t.date).toISOString() : ""}
5040 >
5041 Tagged {relative(t.date)}
5042 </span>
5043 <span class="sep">·</span>
5044 <a
5045 href={`/${owner}/${repo}/commit/${t.sha}`}
5046 class="tags-row-sha"
5047 title={t.sha}
5048 >
5049 {t.sha.slice(0, 7)}
5050 </a>
5051 </div>
5052 </div>
5053 <div class="tags-row-side">
5054 <a
5055 href={`/${owner}/${repo}/tree/${t.name}`}
5056 class="tags-row-link"
5057 >
5058 Browse files
5059 </a>
5060 {hasRelease && (
5061 <a
5062 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5063 class="tags-row-link"
6fd5915Claude5064 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5065 >
5066 View release
5067 </a>
5068 )}
5069 </div>
5070 </div>
5071 );
5072 })}
5073 </div>
5074 )}
5075 </div>
5076 </Layout>
5077 );
5078});
5079
fc1817aClaude5080// Commit log
5081web.get("/:owner/:repo/commits/:ref?", async (c) => {
5082 const { owner, repo } = c.req.param();
06d5ffeClaude5083 const user = c.get("user");
fc1817aClaude5084 const ref =
5085 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5086 const branches = await listBranches(owner, repo);
fc1817aClaude5087
5088 const commits = await listCommits(owner, repo, ref, 50);
5089
3951454Claude5090 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5091 // Also resolve repoId here so we can query the recent push for the
5092 // Push Watch live/watch indicator in the header.
3951454Claude5093 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5094 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5095 try {
5096 const [ownerRow] = await db
5097 .select()
5098 .from(users)
5099 .where(eq(users.username, owner))
5100 .limit(1);
5101 if (ownerRow) {
5102 const [repoRow] = await db
5103 .select()
5104 .from(repositories)
5105 .where(
5106 and(
5107 eq(repositories.ownerId, ownerRow.id),
5108 eq(repositories.name, repo)
5109 )
5110 )
5111 .limit(1);
8c790e0Claude5112 if (repoRow) {
5113 // Fetch verifications and recent push in parallel.
5114 const [verRows, rp] = await Promise.all([
5115 commits.length > 0
5116 ? db
5117 .select()
5118 .from(commitVerifications)
5119 .where(
5120 and(
5121 eq(commitVerifications.repositoryId, repoRow.id),
5122 inArray(
5123 commitVerifications.commitSha,
5124 commits.map((c) => c.sha)
5125 )
5126 )
5127 )
5128 : Promise.resolve([]),
5129 getRecentPush(repoRow.id),
5130 ]);
5131 for (const r of verRows) {
3951454Claude5132 verifications[r.commitSha] = {
5133 verified: r.verified,
5134 reason: r.reason,
5135 };
5136 }
8c790e0Claude5137 commitsPageRecentPush = rp;
3951454Claude5138 }
5139 }
5140 } catch {
5141 // DB unavailable — skip the badges gracefully.
5142 }
5143
fc1817aClaude5144 return c.html(
06d5ffeClaude5145 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5146 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5147 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5148 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5149 <div class="commits-hero">
5150 <div class="commits-hero-orb-wrap" aria-hidden="true">
5151 <div class="commits-hero-orb" />
fc1817aClaude5152 </div>
efb11c5Claude5153 <div class="commits-hero-inner">
5154 <div class="commits-eyebrow">
5155 <strong>History</strong> · {owner}/{repo}
5156 </div>
5157 <h1 class="commits-title">
5158 <span class="gradient-text">{commits.length}</span> recent commit
5159 {commits.length === 1 ? "" : "s"} on{" "}
5160 <span class="commits-branch">{ref}</span>
5161 </h1>
5162 <p class="commits-sub">
5163 Browse the project's history. Click any commit to see the full
5164 diff, AI review notes, and signature status.
5165 </p>
5166 </div>
5167 </div>
5168 <div class="commits-toolbar">
5169 <BranchSwitcher
3951454Claude5170 owner={owner}
5171 repo={repo}
efb11c5Claude5172 currentRef={ref}
5173 branches={branches}
5174 pathType="commits"
3951454Claude5175 />
398a10cClaude5176 <div class="commits-toolbar-actions">
5177 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5178 {"⊢"} Branches
5179 </a>
5180 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5181 {"#"} Tags
5182 </a>
5183 </div>
efb11c5Claude5184 </div>
5185 {commits.length === 0 ? (
5186 <div class="commits-empty">
398a10cClaude5187 <div class="commits-empty-orb" aria-hidden="true" />
5188 <div class="commits-empty-inner">
5189 <div class="commits-empty-icon" aria-hidden="true">
5190 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5191 <circle cx="12" cy="12" r="4" />
5192 <line x1="1.05" y1="12" x2="7" y2="12" />
5193 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5194 </svg>
5195 </div>
5196 <h3 class="commits-empty-title">No commits yet</h3>
5197 <p class="commits-empty-sub">
5198 This branch is empty. Push your first commit, or use the
5199 web editor to create a file.
5200 </p>
5201 </div>
efb11c5Claude5202 </div>
5203 ) : (
5204 <div class="commits-list-wrap">
398a10cClaude5205 {(() => {
5206 // Group commits by day for the section headers.
5207 const dayLabel = (iso: string): string => {
5208 const d = new Date(iso);
5209 if (Number.isNaN(d.getTime())) return "Unknown";
5210 const today = new Date();
5211 const yesterday = new Date(today);
5212 yesterday.setDate(today.getDate() - 1);
5213 const sameDay = (a: Date, b: Date) =>
5214 a.getFullYear() === b.getFullYear() &&
5215 a.getMonth() === b.getMonth() &&
5216 a.getDate() === b.getDate();
5217 if (sameDay(d, today)) return "Today";
5218 if (sameDay(d, yesterday)) return "Yesterday";
5219 return d.toLocaleDateString("en-US", {
5220 month: "long",
5221 day: "numeric",
5222 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5223 });
5224 };
5225 const relative = (iso: string): string => {
5226 const d = new Date(iso);
5227 if (Number.isNaN(d.getTime())) return "";
5228 const diff = Date.now() - d.getTime();
5229 const m = Math.floor(diff / 60000);
5230 if (m < 1) return "just now";
5231 if (m < 60) return `${m} min ago`;
5232 const h = Math.floor(m / 60);
5233 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5234 const dd = Math.floor(h / 24);
5235 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5236 return d.toLocaleDateString("en-US", {
5237 month: "short",
5238 day: "numeric",
5239 });
5240 };
5241 const initial = (name: string): string =>
5242 (name || "?").trim().charAt(0).toUpperCase() || "?";
5243 // Build groups preserving order.
5244 const groups: Array<{ label: string; items: typeof commits }> = [];
5245 let lastLabel = "";
5246 for (const cm of commits) {
5247 const label = dayLabel(cm.date);
5248 if (label !== lastLabel) {
5249 groups.push({ label, items: [] });
5250 lastLabel = label;
5251 }
5252 groups[groups.length - 1].items.push(cm);
5253 }
5254 return groups.map((g) => (
5255 <>
5256 <div class="commits-day-head">
5257 <span class="commits-day-head-dot" aria-hidden="true" />
5258 Commits on {g.label}
5259 </div>
5260 {g.items.map((cm) => {
5261 const v = verifications[cm.sha];
5262 return (
5263 <div class="commits-row">
5264 <div class="commits-avatar" aria-hidden="true">
5265 {initial(cm.author)}
5266 </div>
5267 <div class="commits-row-body">
5268 <div class="commits-row-msg">
5269 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5270 {cm.message}
5271 </a>
5272 {v?.verified && (
5273 <span
5274 class="commits-row-verified"
5275 title="Signed with a registered key"
5276 >
5277 Verified
5278 </span>
5279 )}
5280 </div>
5281 <div class="commits-row-meta">
5282 <strong>{cm.author}</strong>
5283 <span class="sep">·</span>
5284 <span
5285 class="commits-row-time"
5286 title={new Date(cm.date).toISOString()}
5287 >
5288 committed {relative(cm.date)}
5289 </span>
5290 {cm.parentShas.length > 1 && (
5291 <>
5292 <span class="sep">·</span>
5293 <span>merge of {cm.parentShas.length} parents</span>
5294 </>
5295 )}
5296 </div>
5297 </div>
5298 <div class="commits-row-side">
5299 <a
5300 href={`/${owner}/${repo}/commit/${cm.sha}`}
5301 class="commits-row-sha"
5302 title={cm.sha}
5303 >
5304 {cm.sha.slice(0, 7)}
5305 </a>
8c790e0Claude5306 <a
5307 href={`/${owner}/${repo}/push/${cm.sha}`}
5308 class="commits-row-watch"
5309 title="Watch gate + deploy results for this push"
5310 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5311 >
5312 <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">
5313 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5314 <circle cx="12" cy="12" r="3" />
5315 </svg>
5316 </a>
398a10cClaude5317 <button
5318 type="button"
5319 class="commits-row-copy"
5320 data-copy-sha={cm.sha}
5321 title="Copy full SHA"
5322 aria-label={`Copy SHA ${cm.sha}`}
5323 >
5324 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5325 <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" />
5326 <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" />
5327 </svg>
5328 </button>
5329 </div>
5330 </div>
5331 );
5332 })}
5333 </>
5334 ));
5335 })()}
efb11c5Claude5336 </div>
fc1817aClaude5337 )}
398a10cClaude5338 <script
5339 dangerouslySetInnerHTML={{
5340 __html: `
5341 (function(){
5342 document.addEventListener('click', function(e){
5343 var t = e.target; if (!t) return;
5344 var btn = t.closest && t.closest('[data-copy-sha]');
5345 if (!btn) return;
5346 e.preventDefault();
5347 var sha = btn.getAttribute('data-copy-sha') || '';
5348 if (!navigator.clipboard) return;
5349 navigator.clipboard.writeText(sha).then(function(){
5350 btn.classList.add('is-copied');
5351 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5352 }).catch(function(){});
5353 });
5354 })();
5355 `,
5356 }}
5357 />
fc1817aClaude5358 </Layout>
5359 );
5360});
5361
5362// Single commit with diff
5363web.get("/:owner/:repo/commit/:sha", async (c) => {
5364 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5365 const user = c.get("user");
fc1817aClaude5366
05b973eClaude5367 // Fetch commit, full message, and diff in parallel
5368 const [commit, fullMessage, diffResult] = await Promise.all([
5369 getCommit(owner, repo, sha),
5370 getCommitFullMessage(owner, repo, sha),
5371 getDiff(owner, repo, sha),
5372 ]);
fc1817aClaude5373 if (!commit) {
5374 return c.html(
06d5ffeClaude5375 <Layout title="Not Found" user={user}>
fc1817aClaude5376 <div class="empty-state">
5377 <h2>Commit not found</h2>
5378 </div>
5379 </Layout>,
5380 404
5381 );
5382 }
5383
3951454Claude5384 // Block J3 — try to verify this commit's signature.
5385 let verification:
5386 | { verified: boolean; reason: string; signatureType: string | null }
5387 | null = null;
0cdfd89Claude5388 // Block J8 — external CI commit statuses rollup.
5389 let statusCombined:
5390 | {
5391 state: "pending" | "success" | "failure";
5392 total: number;
5393 contexts: Array<{
5394 context: string;
5395 state: string;
5396 description: string | null;
5397 targetUrl: string | null;
5398 }>;
5399 }
5400 | null = null;
3951454Claude5401 try {
5402 const [ownerRow] = await db
5403 .select()
5404 .from(users)
5405 .where(eq(users.username, owner))
5406 .limit(1);
5407 if (ownerRow) {
5408 const [repoRow] = await db
5409 .select()
5410 .from(repositories)
5411 .where(
5412 and(
5413 eq(repositories.ownerId, ownerRow.id),
5414 eq(repositories.name, repo)
5415 )
5416 )
5417 .limit(1);
5418 if (repoRow) {
5419 const { verifyCommit } = await import("../lib/signatures");
5420 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5421 verification = {
5422 verified: v.verified,
5423 reason: v.reason,
5424 signatureType: v.signatureType,
5425 };
0cdfd89Claude5426 try {
5427 const { combinedStatus } = await import("../lib/commit-statuses");
5428 const combined = await combinedStatus(repoRow.id, commit.sha);
5429 if (combined.total > 0) {
5430 statusCombined = {
5431 state: combined.state as any,
5432 total: combined.total,
5433 contexts: combined.contexts.map((c) => ({
5434 context: c.context,
5435 state: c.state,
5436 description: c.description,
5437 targetUrl: c.targetUrl,
5438 })),
5439 };
5440 }
5441 } catch {
5442 statusCombined = null;
5443 }
3951454Claude5444 }
5445 }
5446 } catch {
5447 verification = null;
5448 }
5449
05b973eClaude5450 const { files, raw } = diffResult;
fc1817aClaude5451
efb11c5Claude5452 // Diff stats: count additions / deletions across all files for the
5453 // header summary bar. Computed here from the parsed diff so we don't
5454 // touch the DiffView component.
5455 let additions = 0;
5456 let deletions = 0;
5457 for (const f of files) {
5458 const hunks = (f as any).hunks as Array<any> | undefined;
5459 if (Array.isArray(hunks)) {
5460 for (const h of hunks) {
5461 const lines = (h?.lines || []) as Array<any>;
5462 for (const ln of lines) {
5463 const t = ln?.type || ln?.kind;
5464 if (t === "add" || t === "added" || t === "+") additions += 1;
5465 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5466 deletions += 1;
5467 }
5468 }
5469 }
5470 }
5471 // Fall back: scan raw if file-level counting yielded zero (it's just a
5472 // header polish — never let a parsing miss break the page).
5473 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5474 for (const line of raw.split("\n")) {
5475 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5476 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5477 }
5478 }
5479 const fileCount = files.length;
5480
fc1817aClaude5481 return c.html(
06d5ffeClaude5482 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5483 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5484 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5485 <div class="commit-detail-card">
5486 <div class="commit-detail-eyebrow">
5487 <strong>Commit</strong>
5488 <span class="commit-detail-sha-pill" title={commit.sha}>
5489 {commit.sha.slice(0, 7)}
5490 </span>
3951454Claude5491 {verification && verification.reason !== "unsigned" && (
5492 <span
efb11c5Claude5493 class={`commit-detail-verify ${
3951454Claude5494 verification.verified
efb11c5Claude5495 ? "commit-detail-verify-ok"
5496 : "commit-detail-verify-warn"
3951454Claude5497 }`}
5498 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5499 >
5500 {verification.verified ? "Verified" : verification.reason}
5501 </span>
5502 )}
fc1817aClaude5503 </div>
efb11c5Claude5504 <h1 class="commit-detail-title">{commit.message}</h1>
5505 {fullMessage !== commit.message && (
5506 <pre class="commit-detail-body">{fullMessage}</pre>
5507 )}
5508 <div class="commit-detail-meta">
5509 <span class="commit-detail-author">
5510 <strong>{commit.author}</strong> committed on{" "}
5511 {new Date(commit.date).toLocaleDateString("en-US", {
5512 month: "long",
5513 day: "numeric",
5514 year: "numeric",
5515 })}
5516 </span>
fc1817aClaude5517 {commit.parentShas.length > 0 && (
efb11c5Claude5518 <span class="commit-detail-parents">
5519 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5520 {commit.parentShas.map((p, idx) => (
5521 <>
5522 {idx > 0 && " "}
5523 <a
5524 href={`/${owner}/${repo}/commit/${p}`}
5525 class="commit-detail-sha-link"
5526 >
5527 {p.slice(0, 7)}
5528 </a>
5529 </>
fc1817aClaude5530 ))}
5531 </span>
5532 )}
5533 </div>
efb11c5Claude5534 <div class="commit-detail-stats">
5535 <span class="commit-detail-stat">
5536 <strong>{fileCount}</strong>{" "}
5537 file{fileCount === 1 ? "" : "s"} changed
5538 </span>
5539 <span class="commit-detail-stat commit-detail-stat-add">
5540 <span class="commit-detail-stat-mark">+</span>
5541 <strong>{additions}</strong>
5542 </span>
5543 <span class="commit-detail-stat commit-detail-stat-del">
5544 <span class="commit-detail-stat-mark">−</span>
5545 <strong>{deletions}</strong>
5546 </span>
5547 <span class="commit-detail-sha-full" title="Full SHA">
5548 {commit.sha}
5549 </span>
5550 </div>
0cdfd89Claude5551 {statusCombined && (
efb11c5Claude5552 <div class="commit-detail-checks">
5553 <div class="commit-detail-checks-head">
5554 <strong>Checks</strong>
5555 <span class="commit-detail-checks-summary">
5556 {statusCombined.total} total ·{" "}
5557 <span
5558 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5559 >
5560 {statusCombined.state}
5561 </span>
0cdfd89Claude5562 </span>
efb11c5Claude5563 </div>
5564 <div class="commit-detail-check-row">
0cdfd89Claude5565 {statusCombined.contexts.map((cx) => (
5566 <span
efb11c5Claude5567 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5568 title={cx.description || cx.context}
5569 >
5570 {cx.targetUrl ? (
efb11c5Claude5571 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5572 {cx.context}: {cx.state}
5573 </a>
5574 ) : (
5575 <>
5576 {cx.context}: {cx.state}
5577 </>
5578 )}
5579 </span>
5580 ))}
5581 </div>
5582 </div>
5583 )}
fc1817aClaude5584 </div>
ea9ed4cClaude5585 <DiffView
5586 raw={raw}
5587 files={files}
5588 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5589 />
fc1817aClaude5590 </Layout>
5591 );
5592});
5593
79136bbClaude5594// Raw file download
5595web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5596 const { owner, repo } = c.req.param();
5597 const refAndPath = c.req.param("ref");
5598
5599 const branches = await listBranches(owner, repo);
5600 let ref = "";
5601 let filePath = "";
5602
5603 for (const branch of branches) {
5604 if (refAndPath.startsWith(branch + "/")) {
5605 ref = branch;
5606 filePath = refAndPath.slice(branch.length + 1);
5607 break;
5608 }
5609 }
5610
5611 if (!ref) {
5612 const slashIdx = refAndPath.indexOf("/");
5613 if (slashIdx === -1) return c.text("Not found", 404);
5614 ref = refAndPath.slice(0, slashIdx);
5615 filePath = refAndPath.slice(slashIdx + 1);
5616 }
5617
5618 const data = await getRawBlob(owner, repo, ref, filePath);
5619 if (!data) return c.text("Not found", 404);
5620
5621 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5622 return new Response(data as BodyInit, {
79136bbClaude5623 headers: {
5624 "Content-Type": "application/octet-stream",
5625 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5626 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5627 },
5628 });
5629});
5630
5631// Blame view
5632web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5633 const { owner, repo } = c.req.param();
5634 const user = c.get("user");
5635 const refAndPath = c.req.param("ref");
5636
5637 const branches = await listBranches(owner, repo);
5638 let ref = "";
5639 let filePath = "";
5640
5641 for (const branch of branches) {
5642 if (refAndPath.startsWith(branch + "/")) {
5643 ref = branch;
5644 filePath = refAndPath.slice(branch.length + 1);
5645 break;
5646 }
5647 }
5648
5649 if (!ref) {
5650 const slashIdx = refAndPath.indexOf("/");
5651 if (slashIdx === -1) return c.text("Not found", 404);
5652 ref = refAndPath.slice(0, slashIdx);
5653 filePath = refAndPath.slice(slashIdx + 1);
5654 }
5655
5656 const blameLines = await getBlame(owner, repo, ref, filePath);
5657 if (blameLines.length === 0) {
5658 return c.html(
5659 <Layout title="Not Found" user={user}>
5660 <div class="empty-state">
5661 <h2>File not found</h2>
5662 </div>
5663 </Layout>,
5664 404
5665 );
5666 }
5667
5668 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5669 // Unique contributors (by author) tracked once for the header chip.
5670 const blameAuthors = new Set<string>();
5671 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5672
5673 return c.html(
5674 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5675 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5676 <RepoHeader owner={owner} repo={repo} />
5677 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5678 <header class="blame-head">
5679 <div class="blame-eyebrow">
5680 <span class="blame-eyebrow-dot" aria-hidden="true" />
5681 Blame · Line-by-line history
5682 </div>
5683 <h1 class="blame-title">
5684 <code>{fileName}</code>
5685 </h1>
5686 <p class="blame-sub">
5687 Each line is annotated with the commit that last touched it.
5688 Click any SHA to jump to that commit and see the surrounding
5689 change.
5690 </p>
5691 </header>
efb11c5Claude5692 <div class="blame-toolbar">
5693 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5694 </div>
398a10cClaude5695 <div class="blame-card">
5696 <div class="blame-header">
efb11c5Claude5697 <div class="blame-header-meta">
5698 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5699 <span class="blame-header-name">{fileName}</span>
5700 <span class="blame-header-tag">Blame</span>
5701 <span class="blame-header-stats">
5702 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5703 {blameAuthors.size} contributor
5704 {blameAuthors.size === 1 ? "" : "s"}
5705 </span>
5706 </div>
5707 <div class="blame-header-actions">
5708 <a
5709 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5710 class="blob-pill"
5711 >
5712 Normal view
5713 </a>
5714 <a
5715 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5716 class="blob-pill"
5717 >
5718 Raw
5719 </a>
5720 </div>
79136bbClaude5721 </div>
398a10cClaude5722 <div style="overflow-x:auto">
5723 <table class="blame-table">
79136bbClaude5724 <tbody>
5725 {blameLines.map((line, i) => {
5726 const showInfo =
5727 i === 0 || blameLines[i - 1].sha !== line.sha;
5728 return (
398a10cClaude5729 <tr class={showInfo ? "blame-row-first" : ""}>
5730 <td class="blame-gutter">
79136bbClaude5731 {showInfo && (
398a10cClaude5732 <span class="blame-gutter-inner">
79136bbClaude5733 <a
5734 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5735 class="blame-gutter-sha"
5736 title={`Commit ${line.sha}`}
79136bbClaude5737 >
5738 {line.sha.slice(0, 7)}
398a10cClaude5739 </a>
5740 <span class="blame-gutter-author" title={line.author}>
5741 {line.author}
5742 </span>
5743 </span>
79136bbClaude5744 )}
5745 </td>
398a10cClaude5746 <td class="blame-line-num">{line.lineNum}</td>
5747 <td class="blame-line-content">{line.content}</td>
79136bbClaude5748 </tr>
5749 );
5750 })}
5751 </tbody>
5752 </table>
5753 </div>
5754 </div>
5755 </Layout>
5756 );
5757});
5758
53c9249Claude5759// Search — keyword + optional Claude semantic mode
79136bbClaude5760web.get("/:owner/:repo/search", async (c) => {
5761 const { owner, repo } = c.req.param();
5762 const user = c.get("user");
5763 const q = c.req.query("q") || "";
53c9249Claude5764 const aiAvailable = isAiAvailable();
5765 // Default to semantic when Claude is available and no explicit mode set.
5766 const modeParam = c.req.query("mode");
5767 const mode: "semantic" | "keyword" =
5768 modeParam === "keyword"
5769 ? "keyword"
5770 : modeParam === "semantic"
5771 ? "semantic"
5772 : aiAvailable
5773 ? "semantic"
5774 : "keyword";
5775 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5776
5777 if (!(await repoExists(owner, repo))) return c.notFound();
5778
5779 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5780
5781 // Keyword results (always available as fallback / when in keyword mode)
5782 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5783 // Semantic results (Claude-powered)
5784 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5785 let semanticHits: SemanticHit[] = [];
5786 let semanticMode: "semantic" | "keyword" = "semantic";
5787 let quotaExceeded = false;
79136bbClaude5788
5789 if (q.trim()) {
53c9249Claude5790 if (isSemantic) {
5791 // Resolve repo DB id for caching
5792 const [ownerRow] = await db
5793 .select({ id: users.id })
5794 .from(users)
5795 .where(eq(users.username, owner))
5796 .limit(1);
5797 const [repoRow] = ownerRow
5798 ? await db
5799 .select({ id: repositories.id })
5800 .from(repositories)
5801 .where(
5802 and(
5803 eq(repositories.ownerId, ownerRow.id),
5804 eq(repositories.name, repo)
5805 )
5806 )
5807 .limit(1)
5808 : [];
5809
5810 const rateLimitKey = user
5811 ? `user:${user.id}`
5812 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5813
5814 const searchResult = await claudeSemanticSearch(
5815 owner,
5816 repo,
5817 repoRow?.id ?? `${owner}/${repo}`,
5818 q.trim(),
5819 { branch: defaultBranch, rateLimitKey }
5820 );
5821 semanticHits = searchResult.results;
5822 semanticMode = searchResult.mode;
5823 quotaExceeded = searchResult.quotaExceeded;
5824 } else {
5825 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5826 }
79136bbClaude5827 }
5828
53c9249Claude5829 const aiSearchCss = `
5830 /* ─── Semantic search mode toggle ─── */
5831 .search-mode-bar {
5832 display: flex;
5833 align-items: center;
5834 gap: 8px;
5835 margin-bottom: 14px;
5836 flex-wrap: wrap;
5837 }
5838 .search-mode-label {
5839 font-size: 12.5px;
5840 color: var(--text-muted);
5841 font-weight: 500;
5842 }
5843 .search-mode-toggle {
5844 display: inline-flex;
5845 background: var(--bg-elevated);
5846 border: 1px solid var(--border);
5847 border-radius: 9999px;
5848 padding: 3px;
5849 gap: 2px;
5850 }
5851 .search-mode-btn {
5852 display: inline-flex;
5853 align-items: center;
5854 gap: 5px;
5855 padding: 5px 12px;
5856 border-radius: 9999px;
5857 font-size: 12.5px;
5858 font-weight: 500;
5859 color: var(--text-muted);
5860 text-decoration: none;
5861 transition: color 120ms ease, background 120ms ease;
5862 cursor: pointer;
5863 border: none;
5864 background: none;
5865 font: inherit;
5866 }
5867 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5868 .search-mode-btn.active {
6fd5915Claude5869 background: rgba(91,110,232,0.15);
53c9249Claude5870 color: var(--text-strong);
5871 }
5872 .search-mode-ai-badge {
5873 display: inline-flex;
5874 align-items: center;
5875 gap: 4px;
5876 padding: 2px 8px;
5877 border-radius: 9999px;
5878 font-size: 11px;
5879 font-weight: 600;
6fd5915Claude5880 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5881 color: #c4b5fd;
6fd5915Claude5882 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5883 margin-left: 2px;
5884 }
5885 .search-quota-warn {
5886 font-size: 12.5px;
5887 color: var(--text-muted);
5888 padding: 6px 12px;
5889 background: rgba(255,200,0,0.06);
5890 border: 1px solid rgba(255,200,0,0.20);
5891 border-radius: 8px;
5892 }
5893
5894 /* ─── Semantic result cards ─── */
5895 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5896 .sem-result {
5897 padding: 14px 16px;
5898 background: var(--bg-elevated);
5899 border: 1px solid var(--border);
5900 border-radius: 12px;
5901 transition: border-color 120ms ease;
5902 }
5903 .sem-result:hover { border-color: var(--border-strong); }
5904 .sem-result-head {
5905 display: flex;
5906 align-items: flex-start;
5907 justify-content: space-between;
5908 gap: 10px;
5909 flex-wrap: wrap;
5910 margin-bottom: 6px;
5911 }
5912 .sem-result-path {
5913 font-family: var(--font-mono);
5914 font-size: 13px;
5915 font-weight: 600;
5916 color: var(--text-strong);
5917 text-decoration: none;
5918 word-break: break-all;
5919 }
5920 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5921 .sem-result-conf {
5922 display: inline-flex;
5923 align-items: center;
5924 gap: 4px;
5925 padding: 2px 9px;
5926 border-radius: 9999px;
5927 font-size: 11.5px;
5928 font-weight: 600;
5929 white-space: nowrap;
5930 flex-shrink: 0;
5931 }
5932 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
6fd5915Claude5933 .sem-conf-possible { background: rgba(91,110,232,0.12); color: #5b6ee8; border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude5934 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5935 .sem-result-reason {
5936 font-size: 12.5px;
5937 color: var(--text-muted);
5938 margin-bottom: 8px;
5939 line-height: 1.5;
5940 }
5941 .sem-result-snippet {
5942 margin: 0;
5943 padding: 10px 12px;
5944 background: rgba(0,0,0,0.22);
5945 border: 1px solid var(--border);
5946 border-radius: 8px;
5947 font-family: var(--font-mono);
5948 font-size: 12px;
5949 line-height: 1.55;
5950 color: var(--text);
5951 overflow-x: auto;
5952 white-space: pre-wrap;
5953 word-break: break-word;
5954 max-height: 240px;
5955 overflow-y: auto;
5956 }
5957 `;
5958
5959 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5960 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5961
79136bbClaude5962 return c.html(
5963 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude5964 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude5965 <RepoHeader owner={owner} repo={repo} />
5966 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5967 <div class="search-hero">
5968 <div class="search-eyebrow">
5969 <strong>Search</strong> · {owner}/{repo}
5970 </div>
5971 <h1 class="search-title">
53c9249Claude5972 {isSemantic ? (
5973 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5974 ) : (
5975 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5976 )}
efb11c5Claude5977 </h1>
5978 <form
5979 method="get"
5980 action={`/${owner}/${repo}/search`}
5981 class="search-form"
5982 role="search"
5983 >
53c9249Claude5984 <input type="hidden" name="mode" value={mode} />
efb11c5Claude5985 <div class="search-input-wrap">
5986 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5987 <input
5988 type="text"
5989 name="q"
5990 value={q}
53c9249Claude5991 placeholder={
5992 isSemantic
5993 ? "Ask a question or describe what you're looking for…"
5994 : "Search code on the default branch…"
5995 }
efb11c5Claude5996 aria-label="Search code"
5997 class="search-input"
5998 autocomplete="off"
5999 autofocus
6000 />
6001 </div>
6002 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6003 Search
6004 </button>
efb11c5Claude6005 </form>
6006 </div>
53c9249Claude6007
6008 {/* Mode toggle */}
6009 <div class="search-mode-bar">
6010 <span class="search-mode-label">Mode:</span>
6011 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6012 {aiAvailable ? (
6013 <a
6014 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6015 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6016 aria-pressed={isSemantic ? "true" : "false"}
6017 >
6018 {"✨"} Semantic AI
6019 <span class="search-mode-ai-badge">AI-powered</span>
6020 </a>
6021 ) : null}
6022 <a
6023 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6024 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6025 aria-pressed={!isSemantic ? "true" : "false"}
6026 >
6027 {"⌕"} Keyword
6028 </a>
6029 </div>
6030 {isSemantic && aiAvailable && (
6031 <span style="font-size:12px;color:var(--text-muted)">
6032 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6033 </span>
53c9249Claude6034 )}
6035 {quotaExceeded && (
6036 <span class="search-quota-warn">
6037 Semantic search quota reached — showing keyword results
efb11c5Claude6038 </span>
53c9249Claude6039 )}
6040 </div>
6041
6042 {/* ─── Semantic results ─── */}
6043 {isSemantic && q && (
6044 <>
6045 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6046 <div class="search-quota-warn" style="margin-bottom:10px">
6047 Falling back to keyword search — Claude found no relevant files.
6048 </div>
6049 )}
6050 {semanticHits.length === 0 ? (
6051 <div class="search-empty">
6052 <p>
6053 No matches for <strong>"{q}"</strong>.{" "}
6054 Try a different phrasing or{" "}
6055 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6056 switch to keyword search
6057 </a>.
6058 </p>
6059 </div>
6060 ) : (
6061 <>
6062 <div class="search-results-head">
6063 <span class="search-results-count">
6064 <strong>{semanticHits.length}</strong> result
6065 {semanticHits.length !== 1 ? "s" : ""}
6066 </span>
6067 <span class="search-results-query">
6068 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6069 <span class="search-results-q">"{q}"</span>
6070 </span>
6071 </div>
6072 <div class="sem-results">
6073 {semanticHits.map((h) => {
6074 const confClass =
6075 h.confidence > 0.7
6076 ? "sem-conf-strong"
6077 : h.confidence > 0.4
6078 ? "sem-conf-possible"
6079 : "sem-conf-weak";
6080 const confLabel =
6081 h.confidence > 0.7
6082 ? "Strong match"
6083 : h.confidence > 0.4
6084 ? "Possible match"
6085 : "Weak match";
6086 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6087 const preview =
6088 h.snippet.length > 800
6089 ? h.snippet.slice(0, 800) + "\n…"
6090 : h.snippet;
6091 return (
6092 <div class="sem-result">
6093 <div class="sem-result-head">
6094 <a href={href} class="sem-result-path">
6095 {h.file}
6096 {h.lineNumber ? (
6097 <span style="color:var(--text-muted);font-weight:500">
6098 :{h.lineNumber}
6099 </span>
6100 ) : null}
6101 </a>
6102 <span class={`sem-result-conf ${confClass}`}>
6103 {confLabel}
6104 </span>
6105 </div>
6106 {h.reason && (
6107 <p class="sem-result-reason">{h.reason}</p>
6108 )}
6109 {preview && (
6110 <pre class="sem-result-snippet">{preview}</pre>
6111 )}
6112 </div>
6113 );
6114 })}
6115 </div>
6116 </>
6117 )}
6118 </>
79136bbClaude6119 )}
53c9249Claude6120
6121 {/* ─── Keyword results ─── */}
6122 {!isSemantic && q && (
6123 <>
6124 <div class="search-results-head">
6125 <span class="search-results-count">
6126 <strong>{keywordResults.length}</strong> result
6127 {keywordResults.length !== 1 ? "s" : ""}
6128 </span>
6129 <span class="search-results-query">
6130 for <span class="search-results-q">"{q}"</span> on{" "}
6131 <code>{defaultBranch}</code>
6132 </span>
6133 </div>
6134 {keywordResults.length === 0 ? (
6135 <div class="search-empty">
6136 <p>
6137 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6138 {aiAvailable && (
6139 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6140 try AI semantic search
79136bbClaude6141 </a>
53c9249Claude6142 )}.
6143 </p>
6144 </div>
6145 ) : (
6146 <div class="search-results">
6147 {(() => {
6148 const grouped: Record<
6149 string,
6150 Array<{ lineNum: number; line: string }>
6151 > = {};
6152 for (const r of keywordResults) {
6153 if (!grouped[r.file]) grouped[r.file] = [];
6154 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6155 }
6156 return Object.entries(grouped).map(([file, matches]) => (
6157 <div class="search-file diff-file">
6158 <div class="search-file-head diff-file-header">
6159 <a
6160 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6161 class="search-file-link"
6162 >
6163 {file}
6164 </a>
6165 <span class="search-file-count">
6166 {matches.length} match{matches.length === 1 ? "" : "es"}
6167 </span>
6168 </div>
6169 <div class="blob-code">
6170 <table>
6171 <tbody>
6172 {matches.map((m) => (
6173 <tr>
6174 <td class="line-num">{m.lineNum}</td>
6175 <td class="line-content">{m.line}</td>
6176 </tr>
6177 ))}
6178 </tbody>
6179 </table>
6180 </div>
6181 </div>
6182 ));
6183 })()}
6184 </div>
6185 )}
6186 </>
6187 )}
79136bbClaude6188 </Layout>
6189 );
6190});
6191
fc1817aClaude6192export default web;