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.tsxBlame6196 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";
f8e5feaccanty labs67import { LandingProPage } from "../views/landing-pro";
52ad8b1Claude68import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude69import {
70 listQueuedAiBuildIssues,
71 listRecentAutoMerges,
72 listRecentAiReviews,
73 countAiReviewsSince,
74 listDemoActivityFeed,
75} from "../lib/demo-activity";
11c3ab6ccanty labs76import { AgentWorkspace } from "../views/agent-workspace";
77import { DailyBrief } from "../views/daily-brief";
78import { TrustReport } from "../views/trust-report";
79import { ProductionLayers } from "../views/production-layers";
80import { DistributionView } from "../views/distribution";
81import { OrgMemory } from "../views/org-memory";
fc1817aClaude82
06d5ffeClaude83const web = new Hono<AuthEnv>();
84
85// Soft auth on all web routes — c.get("user") available but may be null
86web.use("*", softAuth);
fc1817aClaude87
ebaae0fClaude88// ---------------------------------------------------------------------------
89// Repo AI stats — computed once per repo home page load, best-effort.
90// Fast because every WHERE clause is bounded to a 7-day window.
91// ---------------------------------------------------------------------------
92interface RepoAiStats {
93 mergedCount: number;
94 reviewCount: number;
95 securityAlertCount: number;
96 hoursSaved: string;
97}
98
99async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
100 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
101
102 // 1. AI-merged PRs this week: activity_feed entries with action =
103 // 'auto_merge.merged' in the last 7 days for this repo.
104 // This is cheaper than a JOIN on pull_requests and covers both the
105 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
106 const [mergedRow] = await db
107 .select({ n: count() })
108 .from(activityFeed)
109 .where(
110 and(
111 eq(activityFeed.repositoryId, repoId),
112 eq(activityFeed.action, "auto_merge.merged"),
113 gte(activityFeed.createdAt, since)
114 )
115 );
116 const mergedCount = Number(mergedRow?.n ?? 0);
117
118 // 2. AI reviews this week: pr_comments where is_ai_review = true in
119 // the last 7 days, joined through pull_requests to scope to this repo.
120 const [reviewRow] = await db
121 .select({ n: count() })
122 .from(prComments)
123 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
124 .where(
125 and(
126 eq(pullRequests.repositoryId, repoId),
127 eq(prComments.isAiReview, true),
128 gte(prComments.createdAt, since)
129 )
130 );
131 const reviewCount = Number(reviewRow?.n ?? 0);
132
133 // 3. Open security alerts: open issues that carry a label whose name
134 // contains 'security' (case-insensitive) for this repo.
135 const [secRow] = await db
136 .select({ n: count() })
137 .from(issues)
138 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
139 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
140 .where(
141 and(
142 eq(issues.repositoryId, repoId),
143 eq(issues.state, "open"),
144 sql`lower(${labels.name}) like '%security%'`
145 )
146 );
147 const securityAlertCount = Number(secRow?.n ?? 0);
148
149 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
150 const hours = mergedCount * 1.5 + reviewCount * 0.5;
151 const hoursSaved = hours.toFixed(1);
152
153 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
154}
155
8c790e0Claude156/**
157 * Query the most recent push to a repo from the activity_feed table.
158 * Returns a RecentPush if there's been a push in the last 24 hours,
159 * or null otherwise. Used by the RepoHeader live/watch indicator.
160 *
161 * Queries activity_feed where action='push' and targetId holds the commit SHA.
162 */
163async function getRecentPush(repoId: string): Promise<RecentPush | null> {
164 try {
165 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
166 const [row] = await db
167 .select({
168 targetId: activityFeed.targetId,
169 createdAt: activityFeed.createdAt,
170 })
171 .from(activityFeed)
172 .where(
173 and(
174 eq(activityFeed.repositoryId, repoId),
175 eq(activityFeed.action, "push"),
176 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
177 )
178 )
179 .orderBy(desc(activityFeed.createdAt))
180 .limit(1);
181 if (!row || !row.targetId) return null;
182 return {
183 sha: row.targetId,
184 ageMs: Date.now() - new Date(row.createdAt).getTime(),
185 };
186 } catch {
187 // DB unavailable or schema mismatch — degrade gracefully.
188 return null;
189 }
190}
191
efb11c5Claude192/**
193 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
194 *
195 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
196 * scope is route-local and `layout.tsx` is locked. Each polished handler
197 * injects this via a `<style>` tag; the rules are namespaced by surface
198 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
199 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
200 * `.repo-home-*` styling Agent A already shipped.
201 */
202const codeBrowseCss = `
203 /* ───────── shared primitives ───────── */
204 .cb-hairline::before,
205 .new-repo-hero::before,
206 .profile-hero::before,
207 .commits-hero::before,
208 .commit-detail-card::before,
209 .search-hero::before {
210 content: '';
211 position: absolute;
212 top: 0; left: 0; right: 0;
213 height: 2px;
6fd5915Claude214 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude215 opacity: 0.7;
216 pointer-events: none;
217 }
218 @keyframes cbHeroOrb {
219 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
220 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
221 }
222 @media (prefers-reduced-motion: reduce) {
223 .new-repo-hero-orb,
224 .profile-hero-orb,
225 .commits-hero-orb { animation: none; }
226 }
227
228 /* ───────── new-repo ───────── */
229 .new-repo-hero {
230 position: relative;
231 margin-bottom: var(--space-5);
232 padding: var(--space-5) var(--space-6);
233 background: var(--bg-elevated);
234 border: 1px solid var(--border);
235 border-radius: 16px;
236 overflow: hidden;
237 }
238 .new-repo-hero-orb-wrap {
239 position: absolute;
240 inset: -25% -10% auto auto;
241 width: 360px;
242 height: 360px;
243 pointer-events: none;
244 z-index: 0;
245 }
246 .new-repo-hero-orb {
247 position: absolute;
248 inset: 0;
6fd5915Claude249 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude250 filter: blur(80px);
251 opacity: 0.7;
252 animation: cbHeroOrb 14s ease-in-out infinite;
253 }
254 .new-repo-hero-inner { position: relative; z-index: 1; }
255 .new-repo-eyebrow {
256 font-size: 12px;
257 font-family: var(--font-mono);
258 color: var(--text-muted);
259 letter-spacing: 0.1em;
260 text-transform: uppercase;
261 margin-bottom: var(--space-2);
262 }
263 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
264 .new-repo-title {
265 font-family: var(--font-display);
266 font-weight: 800;
267 letter-spacing: -0.028em;
268 font-size: clamp(28px, 4vw, 40px);
269 line-height: 1.05;
270 margin: 0 0 var(--space-2);
271 color: var(--text-strong);
272 }
273 .new-repo-sub {
274 font-size: 15px;
275 color: var(--text-muted);
276 margin: 0;
277 line-height: 1.55;
278 max-width: 620px;
279 }
280 .new-repo-form {
281 max-width: 680px;
282 }
283 .new-repo-error {
284 background: rgba(218, 54, 51, 0.12);
285 border: 1px solid rgba(218, 54, 51, 0.35);
286 color: #ffb3b3;
287 padding: 10px 14px;
288 border-radius: 10px;
289 margin-bottom: var(--space-4);
290 font-size: 14px;
291 }
292 .new-repo-form-grid {
293 display: flex;
294 flex-direction: column;
295 gap: var(--space-4);
296 }
297 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
298 .new-repo-label {
299 font-size: 13px;
300 color: var(--text-strong);
301 font-weight: 600;
302 }
303 .new-repo-label-optional {
304 color: var(--text-muted);
305 font-weight: 400;
306 font-size: 12px;
307 }
308 .new-repo-input {
309 appearance: none;
310 width: 100%;
311 background: var(--bg);
312 border: 1px solid var(--border);
313 color: var(--text-strong);
314 border-radius: 10px;
315 padding: 10px 12px;
316 font-size: 14px;
317 font-family: inherit;
318 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
319 }
320 .new-repo-input:focus {
321 outline: none;
322 border-color: var(--accent);
6fd5915Claude323 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude324 }
325 .new-repo-input-disabled {
326 color: var(--text-muted);
327 background: var(--bg-secondary);
328 cursor: not-allowed;
329 }
330 .new-repo-hint {
331 font-size: 12px;
332 color: var(--text-muted);
333 margin: 4px 0 0;
334 }
335 .new-repo-hint code {
336 font-family: var(--font-mono);
337 font-size: 11.5px;
338 background: var(--bg-secondary);
339 border: 1px solid var(--border);
340 border-radius: 5px;
341 padding: 1px 5px;
342 color: var(--text-strong);
343 }
344 .new-repo-visibility {
345 display: grid;
346 grid-template-columns: 1fr 1fr;
347 gap: var(--space-2);
348 }
349 @media (max-width: 600px) {
350 .new-repo-visibility { grid-template-columns: 1fr; }
351 }
352 .new-repo-vis-card {
353 display: flex;
354 gap: 10px;
355 padding: 14px;
356 background: var(--bg);
357 border: 1px solid var(--border);
358 border-radius: 12px;
359 cursor: pointer;
360 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
361 }
362 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
363 .new-repo-vis-card:has(input:checked) {
6fd5915Claude364 border-color: rgba(91,110,232,0.55);
365 background: rgba(91,110,232,0.06);
366 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude367 }
368 .new-repo-vis-radio {
369 margin-top: 3px;
370 accent-color: var(--accent);
371 }
372 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
373 .new-repo-vis-label {
374 font-size: 14px;
375 font-weight: 600;
376 color: var(--text-strong);
377 }
378 .new-repo-vis-desc {
379 font-size: 12.5px;
380 color: var(--text-muted);
381 line-height: 1.45;
382 }
383 .new-repo-callout {
384 margin-top: var(--space-2);
385 padding: var(--space-3) var(--space-4);
6fd5915Claude386 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
387 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude388 border-radius: 12px;
389 }
390 .new-repo-callout-eyebrow {
391 font-size: 11px;
392 font-family: var(--font-mono);
393 text-transform: uppercase;
394 letter-spacing: 0.12em;
395 color: var(--accent);
396 font-weight: 700;
397 margin-bottom: 4px;
398 }
399 .new-repo-callout-body {
400 font-size: 13px;
401 color: var(--text);
402 line-height: 1.5;
403 margin: 0;
404 }
405 .new-repo-callout-body code {
406 font-family: var(--font-mono);
407 font-size: 12px;
408 color: var(--accent);
6fd5915Claude409 background: rgba(91,110,232,0.1);
efb11c5Claude410 border-radius: 4px;
411 padding: 1px 5px;
412 }
398a10cClaude413 .new-repo-templates {
414 display: flex;
415 flex-wrap: wrap;
416 gap: 8px;
417 margin-top: 4px;
418 }
419 .new-repo-template-chip {
420 position: relative;
421 display: inline-flex;
422 align-items: center;
423 gap: 6px;
424 padding: 8px 14px;
425 background: var(--bg);
426 border: 1px solid var(--border);
427 border-radius: 999px;
428 font-size: 13px;
429 color: var(--text);
430 cursor: pointer;
431 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
432 }
433 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
434 .new-repo-template-chip:hover {
435 border-color: var(--border-strong, var(--border));
436 color: var(--text-strong);
437 }
438 .new-repo-template-chip:has(input:checked) {
6fd5915Claude439 border-color: rgba(91,110,232,0.55);
440 background: rgba(91,110,232,0.10);
398a10cClaude441 color: var(--text-strong);
6fd5915Claude442 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude443 }
444 .new-repo-template-chip-dot {
445 width: 8px; height: 8px;
446 border-radius: 999px;
447 background: var(--text-faint, var(--text-muted));
448 transition: background 140ms ease, box-shadow 140ms ease;
449 }
450 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude451 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
452 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude453 }
efb11c5Claude454 .new-repo-actions {
455 display: flex;
456 gap: var(--space-2);
457 margin-top: var(--space-2);
398a10cClaude458 align-items: center;
459 }
460 .new-repo-submit {
461 min-width: 180px;
6fd5915Claude462 border: 1px solid rgba(91,110,232,0.45);
463 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude464 color: #fff;
465 font-weight: 700;
6fd5915Claude466 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude467 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
468 }
469 .new-repo-submit:hover {
470 transform: translateY(-1px);
6fd5915Claude471 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude472 filter: brightness(1.06);
473 }
474 .new-repo-submit:focus-visible {
6fd5915Claude475 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude476 outline-offset: 2px;
efb11c5Claude477 }
478
479 /* ───────── profile ───────── */
480 .profile-hero {
481 position: relative;
482 margin-bottom: var(--space-5);
483 padding: var(--space-5) var(--space-6);
484 background: var(--bg-elevated);
485 border: 1px solid var(--border);
486 border-radius: 16px;
487 overflow: hidden;
488 }
489 .profile-hero-orb-wrap {
490 position: absolute;
491 inset: -25% -10% auto auto;
492 width: 360px;
493 height: 360px;
494 pointer-events: none;
495 z-index: 0;
496 }
497 .profile-hero-orb {
498 position: absolute;
499 inset: 0;
6fd5915Claude500 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude501 filter: blur(80px);
502 opacity: 0.7;
503 animation: cbHeroOrb 14s ease-in-out infinite;
504 }
505 .profile-hero-inner {
506 position: relative;
507 z-index: 1;
508 display: flex;
509 align-items: flex-start;
510 gap: var(--space-5);
511 }
512 .profile-hero-avatar {
513 flex: 0 0 auto;
514 width: 88px;
515 height: 88px;
516 border-radius: 50%;
6fd5915Claude517 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude518 color: #fff;
519 display: flex;
520 align-items: center;
521 justify-content: center;
522 font-size: 38px;
523 font-weight: 700;
524 font-family: var(--font-display);
6fd5915Claude525 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude526 }
527 .profile-hero-text { flex: 1; min-width: 0; }
528 .profile-eyebrow {
529 font-size: 12px;
530 font-family: var(--font-mono);
531 color: var(--text-muted);
532 letter-spacing: 0.1em;
533 text-transform: uppercase;
534 margin-bottom: var(--space-2);
535 }
536 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
537 .profile-name {
538 font-family: var(--font-display);
539 font-weight: 800;
540 letter-spacing: -0.028em;
541 font-size: clamp(28px, 3.6vw, 36px);
542 line-height: 1.05;
543 margin: 0 0 4px;
544 color: var(--text-strong);
545 }
546 .profile-handle {
547 font-family: var(--font-mono);
548 font-size: 13px;
549 color: var(--text-muted);
550 margin-bottom: var(--space-2);
551 }
552 .profile-bio {
553 font-size: 14.5px;
554 color: var(--text);
555 line-height: 1.55;
556 margin: 0 0 var(--space-3);
557 max-width: 640px;
558 }
559 .profile-meta {
560 display: flex;
561 align-items: center;
562 gap: var(--space-4);
563 flex-wrap: wrap;
564 font-size: 13px;
565 }
566 .profile-meta-link {
567 color: var(--text-muted);
568 transition: color var(--t-fast, 0.15s) ease;
569 }
570 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
571 .profile-meta-link strong {
572 color: var(--text-strong);
573 font-weight: 600;
574 font-variant-numeric: tabular-nums;
575 }
576 .profile-follow-form { margin: 0; }
577 @media (max-width: 600px) {
578 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
579 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
580 }
581 .profile-readme {
582 margin-bottom: var(--space-6);
583 background: var(--bg-elevated);
584 border: 1px solid var(--border);
585 border-radius: 12px;
586 overflow: hidden;
587 }
588 .profile-readme-head {
589 display: flex;
590 align-items: center;
591 gap: 8px;
592 padding: 10px 16px;
593 background: var(--bg-secondary);
594 border-bottom: 1px solid var(--border);
595 font-size: 13px;
596 color: var(--text-muted);
597 }
598 .profile-readme-icon { color: var(--accent); font-size: 14px; }
599 .profile-readme-body { padding: var(--space-5) var(--space-6); }
600 .profile-section-head {
601 display: flex;
602 align-items: baseline;
603 gap: var(--space-2);
604 margin-bottom: var(--space-3);
605 }
606 .profile-section-title {
607 font-family: var(--font-display);
608 font-weight: 700;
609 font-size: 20px;
610 letter-spacing: -0.015em;
611 margin: 0;
612 color: var(--text-strong);
613 }
614 .profile-section-count {
615 font-family: var(--font-mono);
616 font-size: 12px;
617 color: var(--text-muted);
618 background: var(--bg-secondary);
619 border: 1px solid var(--border);
620 border-radius: 999px;
621 padding: 2px 10px;
622 font-variant-numeric: tabular-nums;
623 }
624 .profile-empty {
625 display: flex;
626 align-items: center;
627 justify-content: space-between;
628 gap: var(--space-3);
629 padding: var(--space-4) var(--space-5);
630 background: var(--bg-elevated);
631 border: 1px dashed var(--border);
632 border-radius: 12px;
633 }
634 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
635
636 /* ───────── tree (file browser) ───────── */
637 .tree-header {
638 margin-bottom: var(--space-3);
639 display: flex;
640 flex-direction: column;
641 gap: var(--space-2);
642 }
643 .tree-header-row {
644 display: flex;
645 align-items: center;
646 justify-content: space-between;
647 gap: var(--space-3);
648 flex-wrap: wrap;
649 }
650 .tree-header-stats {
651 display: flex;
652 align-items: center;
653 gap: var(--space-3);
654 font-size: 12px;
655 color: var(--text-muted);
656 }
657 .tree-stat strong {
658 color: var(--text-strong);
659 font-weight: 600;
660 font-variant-numeric: tabular-nums;
661 }
662 .tree-stat-link {
663 color: var(--text-muted);
664 padding: 4px 10px;
665 border-radius: 999px;
666 border: 1px solid var(--border);
667 background: var(--bg-elevated);
668 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
669 }
670 .tree-stat-link:hover {
671 color: var(--accent);
6fd5915Claude672 border-color: rgba(91,110,232,0.45);
efb11c5Claude673 text-decoration: none;
674 }
675 .tree-breadcrumb-row {
676 font-size: 13px;
677 }
678
679 /* ───────── blob (file viewer) ───────── */
680 .blob-toolbar {
681 margin-bottom: var(--space-3);
682 display: flex;
683 flex-direction: column;
684 gap: var(--space-2);
685 }
686 .blob-breadcrumb { font-size: 13px; }
687 .blob-card {
688 background: var(--bg-elevated);
689 border: 1px solid var(--border);
690 border-radius: 12px;
691 overflow: hidden;
692 }
693 .blob-header-polished {
694 display: flex;
695 align-items: center;
696 justify-content: space-between;
697 gap: var(--space-3);
698 padding: 10px 14px;
699 background: var(--bg-secondary);
700 border-bottom: 1px solid var(--border);
701 }
702 .blob-header-meta {
703 display: flex;
704 align-items: center;
705 gap: var(--space-2);
706 min-width: 0;
707 flex: 1;
708 }
709 .blob-header-icon { font-size: 14px; opacity: 0.85; }
710 .blob-header-name {
711 font-family: var(--font-mono);
712 font-size: 13px;
713 color: var(--text-strong);
714 font-weight: 600;
715 overflow: hidden;
716 text-overflow: ellipsis;
717 white-space: nowrap;
718 }
719 .blob-header-size {
720 font-family: var(--font-mono);
721 font-size: 11.5px;
722 color: var(--text-muted);
723 font-variant-numeric: tabular-nums;
724 border-left: 1px solid var(--border);
725 padding-left: var(--space-2);
726 margin-left: 2px;
727 }
728 .blob-header-actions {
729 display: flex;
730 gap: 6px;
731 flex-shrink: 0;
732 }
733 .blob-pill {
734 display: inline-flex;
735 align-items: center;
736 padding: 4px 12px;
737 font-size: 12px;
738 font-weight: 500;
739 color: var(--text);
740 background: var(--bg-elevated);
741 border: 1px solid var(--border);
742 border-radius: 999px;
743 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
744 }
745 .blob-pill:hover {
746 color: var(--accent);
6fd5915Claude747 border-color: rgba(91,110,232,0.45);
efb11c5Claude748 text-decoration: none;
6fd5915Claude749 background: rgba(91,110,232,0.06);
efb11c5Claude750 }
751 .blob-pill-accent {
752 color: var(--accent);
6fd5915Claude753 border-color: rgba(91,110,232,0.35);
754 background: rgba(91,110,232,0.08);
efb11c5Claude755 }
756 .blob-pill-accent:hover {
6fd5915Claude757 background: rgba(91,110,232,0.14);
efb11c5Claude758 }
759 .blob-binary {
760 padding: var(--space-5);
761 color: var(--text-muted);
762 text-align: center;
763 font-size: 13px;
764 background: var(--bg);
765 }
766
767 /* ───────── commits list ───────── */
768 .commits-hero {
769 position: relative;
770 margin-bottom: var(--space-4);
771 padding: var(--space-5) var(--space-6);
772 background: var(--bg-elevated);
773 border: 1px solid var(--border);
774 border-radius: 16px;
775 overflow: hidden;
776 }
777 .commits-hero-orb-wrap {
778 position: absolute;
779 inset: -25% -10% auto auto;
780 width: 320px;
781 height: 320px;
782 pointer-events: none;
783 z-index: 0;
784 }
785 .commits-hero-orb {
786 position: absolute;
787 inset: 0;
6fd5915Claude788 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude789 filter: blur(80px);
790 opacity: 0.7;
791 animation: cbHeroOrb 14s ease-in-out infinite;
792 }
793 .commits-hero-inner { position: relative; z-index: 1; }
794 .commits-eyebrow {
795 font-size: 12px;
796 font-family: var(--font-mono);
797 color: var(--text-muted);
798 letter-spacing: 0.1em;
799 text-transform: uppercase;
800 margin-bottom: var(--space-2);
801 }
802 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
803 .commits-title {
804 font-family: var(--font-display);
805 font-weight: 800;
806 letter-spacing: -0.025em;
807 font-size: clamp(22px, 3vw, 30px);
808 line-height: 1.15;
809 margin: 0 0 var(--space-2);
810 color: var(--text-strong);
811 }
812 .commits-branch {
813 font-family: var(--font-mono);
814 font-size: 0.7em;
815 color: var(--text);
816 background: var(--bg-secondary);
817 border: 1px solid var(--border);
818 border-radius: 6px;
819 padding: 2px 8px;
820 font-weight: 500;
821 vertical-align: middle;
822 }
823 .commits-sub {
824 font-size: 14px;
825 color: var(--text-muted);
826 margin: 0;
827 line-height: 1.55;
828 max-width: 640px;
829 }
398a10cClaude830 .commits-toolbar {
831 display: flex;
832 justify-content: space-between;
833 align-items: center;
834 flex-wrap: wrap;
835 gap: var(--space-2);
836 margin-bottom: var(--space-3);
837 }
838 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
839 .commits-toolbar-link {
840 display: inline-flex;
841 align-items: center;
842 gap: 6px;
843 padding: 6px 12px;
844 font-size: 12.5px;
845 font-weight: 500;
846 color: var(--text-muted);
847 background: rgba(255,255,255,0.025);
848 border: 1px solid var(--border);
849 border-radius: 8px;
850 text-decoration: none;
851 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
852 }
853 .commits-toolbar-link:hover {
854 border-color: var(--border-strong);
855 color: var(--text-strong);
856 background: rgba(255,255,255,0.04);
857 text-decoration: none;
858 }
efb11c5Claude859 .commits-list-wrap {
860 background: var(--bg-elevated);
861 border: 1px solid var(--border);
398a10cClaude862 border-radius: 14px;
efb11c5Claude863 overflow: hidden;
398a10cClaude864 position: relative;
865 }
866 .commits-list-wrap::before {
867 content: '';
868 position: absolute;
869 top: 0; left: 0; right: 0;
870 height: 2px;
6fd5915Claude871 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude872 opacity: 0.55;
873 pointer-events: none;
874 }
875 .commits-day-head {
876 display: flex;
877 align-items: center;
878 gap: 10px;
879 padding: 10px 18px;
880 font-size: 11.5px;
881 font-family: var(--font-mono);
882 text-transform: uppercase;
883 letter-spacing: 0.08em;
884 color: var(--text-muted);
885 background: var(--bg-secondary);
886 border-bottom: 1px solid var(--border);
887 }
888 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
889 .commits-day-head-dot {
890 width: 6px; height: 6px;
891 border-radius: 9999px;
6fd5915Claude892 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude893 }
894 .commits-row {
895 display: flex;
896 align-items: flex-start;
897 gap: 14px;
898 padding: 14px 18px;
899 border-bottom: 1px solid var(--border-subtle);
900 transition: background 120ms ease;
901 }
902 .commits-row:last-child { border-bottom: none; }
903 .commits-row:hover { background: rgba(255,255,255,0.022); }
904 .commits-avatar {
905 width: 34px; height: 34px;
906 border-radius: 9999px;
6fd5915Claude907 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude908 color: #fff;
909 display: inline-flex;
910 align-items: center;
911 justify-content: center;
912 font-family: var(--font-display);
913 font-weight: 700;
914 font-size: 13.5px;
915 flex-shrink: 0;
916 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
917 }
918 .commits-row-body { flex: 1; min-width: 0; }
919 .commits-row-msg {
920 display: flex;
921 align-items: center;
922 gap: 8px;
923 flex-wrap: wrap;
924 }
925 .commits-row-msg a {
926 font-family: var(--font-display);
927 font-weight: 600;
928 font-size: 14px;
929 color: var(--text-strong);
930 text-decoration: none;
931 letter-spacing: -0.005em;
932 }
933 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
934 .commits-row-verified {
935 font-size: 9.5px;
936 padding: 1px 7px;
937 border-radius: 9999px;
938 background: rgba(52,211,153,0.16);
939 color: #6ee7b7;
940 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
941 text-transform: uppercase;
942 letter-spacing: 0.06em;
943 font-weight: 700;
944 }
945 .commits-row-meta {
946 margin-top: 4px;
947 display: flex;
948 align-items: center;
949 gap: 8px;
950 flex-wrap: wrap;
951 font-size: 12.5px;
952 color: var(--text-muted);
953 }
954 .commits-row-meta strong { color: var(--text); font-weight: 600; }
955 .commits-row-meta .sep { opacity: 0.4; }
956 .commits-row-time {
957 font-variant-numeric: tabular-nums;
958 }
959 .commits-row-side {
960 display: flex;
961 align-items: center;
962 gap: 8px;
963 flex-shrink: 0;
964 }
965 .commits-row-sha {
966 display: inline-flex;
967 align-items: center;
968 padding: 4px 10px;
969 border-radius: 9999px;
970 font-family: var(--font-mono);
971 font-size: 11.5px;
972 font-weight: 600;
973 color: var(--text-strong);
974 background: rgba(255,255,255,0.04);
975 border: 1px solid var(--border);
976 text-decoration: none;
977 letter-spacing: 0.04em;
978 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
979 }
980 .commits-row-sha:hover {
6fd5915Claude981 border-color: rgba(91,110,232,0.55);
398a10cClaude982 color: var(--accent);
6fd5915Claude983 background: rgba(91,110,232,0.08);
398a10cClaude984 text-decoration: none;
985 }
986 .commits-row-copy {
987 display: inline-flex;
988 align-items: center;
989 justify-content: center;
990 width: 28px; height: 28px;
991 padding: 0;
992 border-radius: 8px;
993 background: transparent;
994 border: 1px solid var(--border);
995 color: var(--text-muted);
996 cursor: pointer;
997 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude998 }
398a10cClaude999 .commits-row-copy:hover {
1000 border-color: var(--border-strong);
1001 color: var(--text);
1002 background: rgba(255,255,255,0.04);
1003 }
1004 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude1005 /* Push Watch link — eye icon next to the SHA chip */
1006 .commits-row-watch {
1007 display: inline-flex;
1008 align-items: center;
1009 justify-content: center;
1010 width: 28px;
1011 height: 28px;
1012 border-radius: 6px;
1013 border: 1px solid var(--border);
1014 color: var(--text-muted);
1015 background: transparent;
1016 cursor: pointer;
1017 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1018 text-decoration: none !important;
1019 }
1020 .commits-row-watch:hover {
6fd5915Claude1021 border-color: rgba(91,110,232,0.45);
8c790e0Claude1022 color: var(--accent);
6fd5915Claude1023 background: rgba(91,110,232,0.08);
8c790e0Claude1024 text-decoration: none !important;
1025 }
efb11c5Claude1026 .commits-empty {
398a10cClaude1027 position: relative;
1028 overflow: hidden;
1029 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1030 text-align: center;
398a10cClaude1031 background: var(--bg-elevated);
1032 border: 1px dashed var(--border-strong);
1033 border-radius: 16px;
1034 }
1035 .commits-empty-orb {
1036 position: absolute;
1037 inset: -40% 30% auto 30%;
1038 height: 280px;
6fd5915Claude1039 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1040 filter: blur(70px);
1041 opacity: 0.7;
1042 pointer-events: none;
1043 z-index: 0;
1044 }
1045 .commits-empty-inner { position: relative; z-index: 1; }
1046 .commits-empty-icon {
1047 width: 56px; height: 56px;
1048 border-radius: 9999px;
6fd5915Claude1049 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1050 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1051 display: inline-flex;
1052 align-items: center;
1053 justify-content: center;
1054 color: #c4b5fd;
1055 margin: 0 auto 14px;
1056 }
1057 .commits-empty-title {
1058 font-family: var(--font-display);
1059 font-size: 18px;
1060 font-weight: 700;
1061 margin: 0 0 6px;
1062 color: var(--text-strong);
1063 }
1064 .commits-empty-sub {
1065 margin: 0 auto 0;
1066 font-size: 13.5px;
efb11c5Claude1067 color: var(--text-muted);
398a10cClaude1068 max-width: 420px;
1069 line-height: 1.5;
1070 }
1071
1072 /* ───────── branches list ───────── */
1073 .branches-list {
efb11c5Claude1074 background: var(--bg-elevated);
398a10cClaude1075 border: 1px solid var(--border);
1076 border-radius: 14px;
1077 overflow: hidden;
1078 position: relative;
1079 }
1080 .branches-list::before {
1081 content: '';
1082 position: absolute;
1083 top: 0; left: 0; right: 0;
1084 height: 2px;
6fd5915Claude1085 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1086 opacity: 0.55;
1087 pointer-events: none;
1088 }
1089 .branches-row {
1090 display: flex;
1091 align-items: center;
1092 gap: var(--space-3);
1093 padding: 14px 18px;
1094 border-bottom: 1px solid var(--border-subtle);
1095 transition: background 120ms ease;
1096 flex-wrap: wrap;
1097 }
1098 .branches-row:last-child { border-bottom: none; }
1099 .branches-row:hover { background: rgba(255,255,255,0.022); }
1100 .branches-row-icon {
1101 width: 32px; height: 32px;
1102 border-radius: 8px;
6fd5915Claude1103 background: rgba(91,110,232,0.10);
398a10cClaude1104 color: #c4b5fd;
1105 display: inline-flex;
1106 align-items: center;
1107 justify-content: center;
1108 flex-shrink: 0;
6fd5915Claude1109 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1110 }
1111 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1112 .branches-row-name {
1113 display: inline-flex;
1114 align-items: center;
1115 gap: 8px;
1116 flex-wrap: wrap;
1117 }
1118 .branches-row-name a {
1119 font-family: var(--font-mono);
1120 font-size: 13.5px;
1121 color: var(--text-strong);
1122 font-weight: 600;
1123 text-decoration: none;
1124 }
1125 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1126 .branches-row-default {
1127 font-size: 10px;
1128 padding: 2px 8px;
1129 border-radius: 9999px;
6fd5915Claude1130 background: rgba(91,110,232,0.14);
398a10cClaude1131 color: #c4b5fd;
6fd5915Claude1132 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1133 text-transform: uppercase;
1134 letter-spacing: 0.08em;
1135 font-weight: 700;
1136 font-family: var(--font-mono);
1137 }
1138 .branches-row-meta {
1139 display: flex;
1140 align-items: center;
1141 gap: 8px;
1142 flex-wrap: wrap;
1143 font-size: 12.5px;
1144 color: var(--text-muted);
1145 font-variant-numeric: tabular-nums;
1146 }
1147 .branches-row-meta .sep { opacity: 0.4; }
1148 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1149 .branches-row-side {
1150 display: flex;
1151 align-items: center;
1152 gap: 10px;
1153 flex-shrink: 0;
1154 flex-wrap: wrap;
1155 }
1156 .branches-row-divergence {
1157 display: inline-flex;
1158 align-items: center;
1159 gap: 8px;
1160 font-family: var(--font-mono);
1161 font-size: 11.5px;
1162 color: var(--text-muted);
1163 padding: 4px 10px;
1164 border-radius: 9999px;
1165 background: rgba(255,255,255,0.035);
1166 border: 1px solid var(--border);
1167 font-variant-numeric: tabular-nums;
1168 }
1169 .branches-row-divergence .ahead { color: #6ee7b7; }
1170 .branches-row-divergence .behind { color: #fca5a5; }
1171 .branches-row-actions {
1172 display: flex;
1173 align-items: center;
1174 gap: 6px;
1175 }
1176 .branches-btn {
1177 display: inline-flex;
1178 align-items: center;
1179 gap: 5px;
1180 padding: 6px 12px;
1181 border-radius: 9999px;
1182 font-size: 12px;
1183 font-weight: 600;
1184 text-decoration: none;
1185 border: 1px solid var(--border);
1186 background: transparent;
1187 color: var(--text-muted);
1188 cursor: pointer;
1189 font: inherit;
1190 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1191 }
1192 .branches-btn:hover {
1193 border-color: var(--border-strong);
1194 color: var(--text);
1195 background: rgba(255,255,255,0.04);
1196 text-decoration: none;
1197 }
1198 .branches-btn-danger {
1199 color: #fca5a5;
1200 border-color: rgba(248,113,113,0.30);
1201 }
1202 .branches-btn-danger:hover {
1203 border-style: dashed;
1204 border-color: rgba(248,113,113,0.65);
1205 background: rgba(248,113,113,0.06);
1206 color: #fecaca;
1207 }
1208
1209 /* ───────── tags list ───────── */
1210 .tags-list {
1211 background: var(--bg-elevated);
1212 border: 1px solid var(--border);
1213 border-radius: 14px;
1214 overflow: hidden;
1215 position: relative;
1216 }
1217 .tags-list::before {
1218 content: '';
1219 position: absolute;
1220 top: 0; left: 0; right: 0;
1221 height: 2px;
6fd5915Claude1222 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1223 opacity: 0.55;
1224 pointer-events: none;
1225 }
1226 .tags-row {
1227 display: flex;
1228 align-items: center;
1229 gap: var(--space-3);
1230 padding: 14px 18px;
1231 border-bottom: 1px solid var(--border-subtle);
1232 transition: background 120ms ease;
1233 flex-wrap: wrap;
1234 }
1235 .tags-row:last-child { border-bottom: none; }
1236 .tags-row:hover { background: rgba(255,255,255,0.022); }
1237 .tags-row-icon {
1238 width: 32px; height: 32px;
1239 border-radius: 8px;
6fd5915Claude1240 background: rgba(95,143,160,0.10);
398a10cClaude1241 color: #67e8f9;
1242 display: inline-flex;
1243 align-items: center;
1244 justify-content: center;
1245 flex-shrink: 0;
6fd5915Claude1246 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1247 }
1248 .tags-row-main { flex: 1; min-width: 240px; }
1249 .tags-row-name {
1250 display: inline-flex;
1251 align-items: center;
1252 gap: 8px;
1253 flex-wrap: wrap;
1254 }
1255 .tags-row-version {
1256 display: inline-flex;
1257 align-items: center;
1258 padding: 3px 11px;
1259 border-radius: 9999px;
1260 font-family: var(--font-mono);
1261 font-size: 13px;
1262 font-weight: 700;
1263 color: #67e8f9;
6fd5915Claude1264 background: rgba(95,143,160,0.12);
1265 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1266 letter-spacing: 0.01em;
1267 }
1268 .tags-row-meta {
1269 margin-top: 4px;
1270 display: flex;
1271 align-items: center;
1272 gap: 8px;
1273 flex-wrap: wrap;
1274 font-size: 12.5px;
1275 color: var(--text-muted);
1276 font-variant-numeric: tabular-nums;
1277 }
1278 .tags-row-meta .sep { opacity: 0.4; }
1279 .tags-row-sha {
1280 font-family: var(--font-mono);
1281 font-size: 11.5px;
1282 color: var(--text-strong);
1283 padding: 2px 8px;
1284 border-radius: 6px;
1285 background: rgba(255,255,255,0.04);
1286 border: 1px solid var(--border);
1287 text-decoration: none;
1288 letter-spacing: 0.04em;
1289 }
1290 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1291 .tags-row-side {
1292 display: flex;
1293 align-items: center;
1294 gap: 6px;
1295 flex-shrink: 0;
1296 flex-wrap: wrap;
1297 }
1298 .tags-row-link {
1299 display: inline-flex;
1300 align-items: center;
1301 gap: 5px;
1302 padding: 6px 12px;
1303 border-radius: 9999px;
1304 font-size: 12px;
1305 font-weight: 600;
1306 text-decoration: none;
1307 color: var(--text-muted);
1308 background: rgba(255,255,255,0.025);
1309 border: 1px solid var(--border);
1310 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1311 }
1312 .tags-row-link:hover {
6fd5915Claude1313 border-color: rgba(91,110,232,0.45);
398a10cClaude1314 color: var(--text-strong);
6fd5915Claude1315 background: rgba(91,110,232,0.06);
398a10cClaude1316 text-decoration: none;
efb11c5Claude1317 }
1318
1319 /* ───────── commit detail ───────── */
1320 .commit-detail-card {
1321 position: relative;
1322 margin-bottom: var(--space-5);
1323 padding: var(--space-5) var(--space-5);
1324 background: var(--bg-elevated);
1325 border: 1px solid var(--border);
1326 border-radius: 14px;
1327 overflow: hidden;
1328 }
1329 .commit-detail-eyebrow {
1330 display: flex;
1331 align-items: center;
1332 gap: var(--space-2);
1333 font-size: 12px;
1334 font-family: var(--font-mono);
1335 text-transform: uppercase;
1336 letter-spacing: 0.1em;
1337 color: var(--text-muted);
1338 margin-bottom: var(--space-2);
1339 }
1340 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1341 .commit-detail-sha-pill {
1342 font-family: var(--font-mono);
1343 font-size: 11.5px;
1344 color: var(--text-strong);
1345 background: var(--bg-secondary);
1346 border: 1px solid var(--border);
1347 border-radius: 999px;
1348 padding: 2px 10px;
1349 letter-spacing: 0.04em;
1350 }
1351 .commit-detail-verify {
1352 font-size: 10px;
1353 padding: 2px 8px;
1354 border-radius: 999px;
1355 text-transform: uppercase;
1356 letter-spacing: 0.06em;
1357 font-weight: 700;
1358 color: #fff;
1359 }
1360 .commit-detail-verify-ok {
1361 background: linear-gradient(135deg, #2ea043, #34d399);
1362 }
1363 .commit-detail-verify-warn {
1364 background: linear-gradient(135deg, #d29922, #f59e0b);
1365 }
1366 .commit-detail-title {
1367 font-family: var(--font-display);
1368 font-weight: 700;
1369 letter-spacing: -0.018em;
1370 font-size: clamp(20px, 2.5vw, 26px);
1371 line-height: 1.25;
1372 margin: 0 0 var(--space-2);
1373 color: var(--text-strong);
1374 }
1375 .commit-detail-body {
1376 white-space: pre-wrap;
1377 color: var(--text-muted);
1378 font-size: 14px;
1379 line-height: 1.55;
1380 margin: 0 0 var(--space-3);
1381 font-family: var(--font-mono);
1382 background: var(--bg);
1383 border: 1px solid var(--border);
1384 border-radius: 8px;
1385 padding: var(--space-3) var(--space-4);
1386 max-height: 280px;
1387 overflow: auto;
1388 }
1389 .commit-detail-meta {
1390 display: flex;
1391 flex-wrap: wrap;
1392 gap: var(--space-3);
1393 font-size: 13px;
1394 color: var(--text-muted);
1395 margin-bottom: var(--space-3);
1396 }
1397 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1398 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1399 .commit-detail-sha-link {
1400 font-family: var(--font-mono);
1401 font-size: 12.5px;
1402 color: var(--accent);
6fd5915Claude1403 background: rgba(91,110,232,0.08);
efb11c5Claude1404 border-radius: 6px;
1405 padding: 1px 6px;
1406 margin-left: 2px;
1407 }
6fd5915Claude1408 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1409 .commit-detail-stats {
1410 display: flex;
1411 flex-wrap: wrap;
1412 align-items: center;
1413 gap: var(--space-3);
1414 padding-top: var(--space-3);
1415 border-top: 1px solid var(--border);
1416 font-size: 13px;
1417 color: var(--text-muted);
1418 }
1419 .commit-detail-stat {
1420 display: inline-flex;
1421 align-items: center;
1422 gap: 4px;
1423 font-variant-numeric: tabular-nums;
1424 }
1425 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1426 .commit-detail-stat-add strong { color: #34d399; }
1427 .commit-detail-stat-del strong { color: #f87171; }
1428 .commit-detail-stat-mark {
1429 font-family: var(--font-mono);
1430 font-weight: 700;
1431 font-size: 14px;
1432 }
1433 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1434 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1435 .commit-detail-sha-full {
1436 margin-left: auto;
1437 font-family: var(--font-mono);
1438 font-size: 11.5px;
1439 color: var(--text-faint);
1440 letter-spacing: 0.02em;
1441 overflow: hidden;
1442 text-overflow: ellipsis;
1443 white-space: nowrap;
1444 max-width: 100%;
1445 }
1446 .commit-detail-checks {
1447 margin-top: var(--space-3);
1448 padding-top: var(--space-3);
1449 border-top: 1px solid var(--border);
1450 }
1451 .commit-detail-checks-head {
1452 display: flex;
1453 align-items: center;
1454 gap: var(--space-2);
1455 font-size: 13px;
1456 color: var(--text-muted);
1457 margin-bottom: 8px;
1458 }
1459 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1460 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1461 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1462 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1463 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1464 .commit-detail-check {
1465 font-size: 11px;
1466 padding: 2px 8px;
1467 border-radius: 999px;
1468 color: #fff;
1469 font-weight: 500;
1470 }
398a10cClaude1471 .commit-detail-check a { color: inherit; text-decoration: none; }
1472 .commit-detail-check-success { background: #2ea043; }
1473 .commit-detail-check-pending { background: #d29922; }
1474 .commit-detail-check-failure { background: #da3633; }
1475
1476 /* ───────── blame ───────── */
1477 .blame-head { margin-bottom: var(--space-5); }
1478 .blame-eyebrow {
1479 display: inline-flex;
1480 align-items: center;
1481 gap: 8px;
1482 text-transform: uppercase;
1483 font-family: var(--font-mono);
1484 font-size: 11px;
1485 letter-spacing: 0.16em;
1486 color: var(--text-muted);
1487 font-weight: 600;
1488 margin-bottom: 10px;
1489 }
1490 .blame-eyebrow-dot {
1491 width: 8px; height: 8px;
1492 border-radius: 9999px;
6fd5915Claude1493 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1494 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1495 }
1496 .blame-title {
1497 font-family: var(--font-display);
1498 font-size: clamp(22px, 3vw, 30px);
1499 font-weight: 800;
1500 letter-spacing: -0.025em;
1501 line-height: 1.15;
1502 margin: 0 0 6px;
1503 color: var(--text-strong);
1504 }
1505 .blame-title code {
1506 font-family: var(--font-mono);
1507 font-size: 0.78em;
1508 color: var(--text-strong);
1509 font-weight: 700;
1510 }
1511 .blame-sub {
1512 margin: 0;
1513 font-size: 14px;
1514 color: var(--text-muted);
1515 line-height: 1.5;
1516 max-width: 700px;
1517 }
efb11c5Claude1518 .blame-toolbar {
398a10cClaude1519 display: flex;
1520 justify-content: space-between;
1521 align-items: center;
1522 gap: var(--space-3);
efb11c5Claude1523 margin-bottom: var(--space-3);
398a10cClaude1524 flex-wrap: wrap;
efb11c5Claude1525 font-size: 13px;
1526 }
398a10cClaude1527 .blame-toolbar-actions { display: flex; gap: 6px; }
1528 .blame-card {
1529 background: var(--bg-elevated);
1530 border: 1px solid var(--border);
1531 border-radius: 14px;
1532 overflow: hidden;
1533 position: relative;
1534 }
1535 .blame-card::before {
1536 content: '';
1537 position: absolute;
1538 top: 0; left: 0; right: 0;
1539 height: 2px;
6fd5915Claude1540 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1541 opacity: 0.55;
1542 pointer-events: none;
1543 }
efb11c5Claude1544 .blame-header {
1545 display: flex;
1546 align-items: center;
1547 justify-content: space-between;
1548 gap: var(--space-3);
1549 padding: 10px 14px;
1550 background: var(--bg-secondary);
1551 border-bottom: 1px solid var(--border);
1552 }
1553 .blame-header-meta {
1554 display: flex;
1555 align-items: center;
1556 gap: var(--space-2);
1557 min-width: 0;
1558 flex-wrap: wrap;
1559 }
1560 .blame-header-icon { color: var(--accent); font-size: 14px; }
1561 .blame-header-name {
1562 font-family: var(--font-mono);
1563 font-size: 13px;
1564 color: var(--text-strong);
1565 font-weight: 600;
1566 }
1567 .blame-header-tag {
1568 font-size: 10.5px;
1569 text-transform: uppercase;
1570 letter-spacing: 0.08em;
1571 font-family: var(--font-mono);
6fd5915Claude1572 background: rgba(91,110,232,0.12);
efb11c5Claude1573 color: var(--accent);
1574 border-radius: 999px;
1575 padding: 2px 8px;
1576 font-weight: 600;
1577 }
1578 .blame-header-stats {
1579 font-family: var(--font-mono);
1580 font-size: 11.5px;
1581 color: var(--text-muted);
1582 font-variant-numeric: tabular-nums;
1583 border-left: 1px solid var(--border);
1584 padding-left: var(--space-2);
1585 }
1586 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1587 .blame-table {
1588 width: 100%;
1589 border-collapse: collapse;
1590 font-family: var(--font-mono);
1591 font-size: 12.5px;
1592 line-height: 1.6;
1593 }
1594 .blame-table tr { border-bottom: 1px solid transparent; }
1595 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1596 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1597 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1598 .blame-gutter {
1599 width: 220px;
1600 min-width: 220px;
1601 padding: 0 12px;
1602 vertical-align: top;
1603 background: rgba(255,255,255,0.012);
1604 border-right: 1px solid var(--border-subtle);
1605 font-variant-numeric: tabular-nums;
1606 color: var(--text-muted);
1607 font-size: 11px;
1608 white-space: nowrap;
1609 overflow: hidden;
1610 text-overflow: ellipsis;
1611 padding-top: 2px;
1612 padding-bottom: 2px;
1613 }
1614 .blame-gutter-inner {
1615 display: inline-flex;
1616 align-items: center;
1617 gap: 7px;
1618 max-width: 100%;
1619 overflow: hidden;
1620 }
1621 .blame-gutter-sha {
1622 font-family: var(--font-mono);
1623 font-size: 10.5px;
1624 font-weight: 600;
1625 color: #c4b5fd;
6fd5915Claude1626 background: rgba(91,110,232,0.10);
398a10cClaude1627 padding: 1px 7px;
1628 border-radius: 9999px;
6fd5915Claude1629 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1630 text-decoration: none;
1631 letter-spacing: 0.04em;
1632 flex-shrink: 0;
1633 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1634 }
1635 .blame-gutter-sha:hover {
6fd5915Claude1636 background: rgba(91,110,232,0.22);
398a10cClaude1637 color: #ddd6fe;
6fd5915Claude1638 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1639 text-decoration: none;
1640 }
1641 .blame-gutter-author {
1642 color: var(--text);
1643 overflow: hidden;
1644 text-overflow: ellipsis;
1645 white-space: nowrap;
1646 font-size: 11px;
1647 font-family: var(--font-sans, inherit);
1648 }
1649 .blame-line-num {
1650 width: 1%;
1651 min-width: 50px;
1652 padding: 0 12px;
1653 text-align: right;
1654 color: var(--text-faint);
1655 user-select: none;
1656 border-right: 1px solid var(--border-subtle);
1657 font-variant-numeric: tabular-nums;
1658 }
1659 .blame-line-content {
1660 padding: 0 14px;
1661 white-space: pre;
1662 color: var(--text);
1663 transition: background 120ms ease;
1664 }
efb11c5Claude1665
1666 /* ───────── search ───────── */
1667 .search-hero {
1668 position: relative;
1669 margin-bottom: var(--space-4);
1670 padding: var(--space-5) var(--space-6);
1671 background: var(--bg-elevated);
1672 border: 1px solid var(--border);
1673 border-radius: 16px;
1674 overflow: hidden;
1675 }
1676 .search-eyebrow {
1677 font-size: 12px;
1678 font-family: var(--font-mono);
1679 color: var(--text-muted);
1680 letter-spacing: 0.1em;
1681 text-transform: uppercase;
1682 margin-bottom: var(--space-2);
1683 }
1684 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1685 .search-title {
1686 font-family: var(--font-display);
1687 font-weight: 800;
1688 letter-spacing: -0.025em;
1689 font-size: clamp(22px, 3vw, 30px);
1690 line-height: 1.15;
1691 margin: 0 0 var(--space-3);
1692 color: var(--text-strong);
1693 }
1694 .search-form {
1695 display: flex;
1696 gap: var(--space-2);
1697 align-items: stretch;
1698 }
1699 .search-input-wrap {
1700 position: relative;
1701 flex: 1;
1702 display: flex;
1703 align-items: center;
1704 }
1705 .search-input-icon {
1706 position: absolute;
1707 left: 12px;
1708 color: var(--text-muted);
1709 font-size: 15px;
1710 pointer-events: none;
1711 }
1712 .search-input {
1713 appearance: none;
1714 width: 100%;
1715 padding: 10px 12px 10px 34px;
1716 background: var(--bg);
1717 border: 1px solid var(--border);
1718 border-radius: 10px;
1719 color: var(--text-strong);
1720 font-size: 14px;
1721 font-family: inherit;
1722 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1723 }
1724 .search-input:focus {
1725 outline: none;
1726 border-color: var(--accent);
6fd5915Claude1727 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1728 }
1729 .search-submit { min-width: 96px; }
1730 .search-results-head {
1731 display: flex;
1732 align-items: baseline;
1733 gap: var(--space-2);
1734 margin-bottom: var(--space-3);
1735 font-size: 13px;
1736 color: var(--text-muted);
1737 }
1738 .search-results-count strong {
1739 color: var(--text-strong);
1740 font-weight: 600;
1741 font-variant-numeric: tabular-nums;
1742 }
1743 .search-results-q { color: var(--text-strong); font-weight: 600; }
1744 .search-results-head code {
1745 font-family: var(--font-mono);
1746 font-size: 12px;
1747 background: var(--bg-secondary);
1748 border: 1px solid var(--border);
1749 border-radius: 5px;
1750 padding: 1px 6px;
1751 color: var(--text);
1752 }
1753 .search-empty {
1754 padding: var(--space-5) var(--space-6);
1755 background: var(--bg-elevated);
1756 border: 1px dashed var(--border);
1757 border-radius: 12px;
1758 color: var(--text-muted);
1759 font-size: 14px;
1760 }
1761 .search-empty strong { color: var(--text-strong); }
1762 .search-results {
1763 display: flex;
1764 flex-direction: column;
1765 gap: var(--space-3);
1766 }
1767 .search-file-head {
1768 display: flex;
1769 align-items: center;
1770 justify-content: space-between;
1771 gap: var(--space-2);
1772 }
1773 .search-file-link {
1774 font-family: var(--font-mono);
1775 font-size: 13px;
1776 color: var(--text-strong);
1777 font-weight: 600;
1778 }
1779 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1780 .search-file-count {
1781 font-family: var(--font-mono);
1782 font-size: 11.5px;
1783 color: var(--text-muted);
1784 font-variant-numeric: tabular-nums;
1785 }
1786`;
1787
fc1817aClaude1788// Home page
06d5ffeClaude1789web.get("/", async (c) => {
1790 const user = c.get("user");
1791
1792 if (user) {
0316dbbClaude1793 return c.redirect("/dashboard");
06d5ffeClaude1794 }
1795
8e9f1d9Claude1796 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1797 let publicStats: PublicStats | null = null;
8e9f1d9Claude1798 try {
1799 const [repoRow] = await db
1800 .select({ n: sql<number>`count(*)::int` })
1801 .from(repositories)
1802 .where(eq(repositories.isPrivate, false));
1803 const [userRow] = await db
1804 .select({ n: sql<number>`count(*)::int` })
1805 .from(users);
1806 stats = {
1807 publicRepos: Number(repoRow?.n ?? 0),
1808 users: Number(userRow?.n ?? 0),
1809 };
1810 } catch {
1811 stats = undefined;
1812 }
1813
52ad8b1Claude1814 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1815 try {
1816 publicStats = await computePublicStats();
1817 } catch {
1818 publicStats = null;
1819 }
1820
534f04aClaude1821 // Block M1 — initial SSR snapshot for the live-now demo feed.
1822 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1823 // in try/catch so a freak module-level explosion can't take down /.
1824 let liveFeed: LandingLiveFeed | null = null;
1825 try {
1826 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1827 listQueuedAiBuildIssues(3),
1828 listRecentAutoMerges(3, 24),
1829 listRecentAiReviews(3, 24),
1830 countAiReviewsSince(24),
1831 listDemoActivityFeed(10),
1832 ]);
1833 liveFeed = {
1834 queued: queued.map((i) => ({
1835 repo: i.repo,
1836 number: i.number,
1837 title: i.title,
1838 createdAt: i.createdAt,
1839 })),
1840 merges: merges.map((m) => ({
1841 repo: m.repo,
1842 number: m.number,
1843 title: m.title,
1844 mergedAt: m.mergedAt,
1845 })),
1846 reviews: reviewList.map((r) => ({
1847 repo: r.repo,
1848 prNumber: r.prNumber,
1849 commentSnippet: r.commentSnippet,
1850 createdAt: r.createdAt,
1851 })),
1852 reviewCount,
1853 feed: feed.map((e) => ({
1854 kind: e.kind,
1855 repo: e.repo,
1856 ref: e.ref,
1857 at: e.at,
1858 })),
1859 };
1860 } catch {
1861 liveFeed = null;
1862 }
1863
29924bcClaude1864 void LandingPage;
f8e5feaccanty labs1865 void Landing2030Page;
1866 return c.html(
1867 "<!DOCTYPE html>" +
1868 String(
1869 <LandingProPage
1870 stats={stats}
1871 publicStats={publicStats}
1872 liveFeed={liveFeed}
1873 />
1874 )
1875 );
fc1817aClaude1876});
1877
06d5ffeClaude1878// New repository form
1879web.get("/new", requireAuth, (c) => {
1880 const user = c.get("user")!;
1881 const error = c.req.query("error");
1882
1883 return c.html(
1884 <Layout title="New repository" user={user}>
efb11c5Claude1885 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1886 <div class="new-repo-hero">
1887 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1888 <div class="new-repo-hero-orb" />
1889 </div>
1890 <div class="new-repo-hero-inner">
1891 <div class="new-repo-eyebrow">
1892 <strong>Create</strong> · {user.username}
1893 </div>
1894 <h1 class="new-repo-title">
1895 Spin up a <span class="gradient-text">repository</span>.
1896 </h1>
1897 <p class="new-repo-sub">
1898 Push your first commit, and Gluecron wires up gate checks, AI review,
1899 and auto-merge from the moment your branch lands.
1900 </p>
1901 </div>
1902 </div>
06d5ffeClaude1903 <div class="new-repo-form">
efb11c5Claude1904 {error && (
1905 <div class="new-repo-error" role="alert">
1906 {decodeURIComponent(error)}
1907 </div>
1908 )}
1909 <form method="post" action="/new" class="new-repo-form-grid">
1910 <div class="new-repo-row">
1911 <label class="new-repo-label">Owner</label>
1912 <input
1913 type="text"
1914 value={user.username}
1915 disabled
1916 aria-label="Owner"
1917 class="new-repo-input new-repo-input-disabled"
1918 />
06d5ffeClaude1919 </div>
efb11c5Claude1920 <div class="new-repo-row">
1921 <label class="new-repo-label" for="name">
1922 Repository name
1923 </label>
06d5ffeClaude1924 <input
1925 type="text"
1926 id="name"
1927 name="name"
1928 required
1929 pattern="^[a-zA-Z0-9._-]+$"
1930 placeholder="my-project"
1931 autocomplete="off"
efb11c5Claude1932 class="new-repo-input"
06d5ffeClaude1933 />
efb11c5Claude1934 <p class="new-repo-hint">
1935 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1936 <code>{user.username}/&lt;name&gt;</code>.
1937 </p>
06d5ffeClaude1938 </div>
efb11c5Claude1939 <div class="new-repo-row">
1940 <label class="new-repo-label" for="description">
1941 Description{" "}
1942 <span class="new-repo-label-optional">(optional)</span>
1943 </label>
06d5ffeClaude1944 <input
1945 type="text"
1946 id="description"
1947 name="description"
1948 placeholder="A short description of your repository"
efb11c5Claude1949 class="new-repo-input"
06d5ffeClaude1950 />
1951 </div>
efb11c5Claude1952 <div class="new-repo-row">
1953 <span class="new-repo-label">Visibility</span>
1954 <div class="new-repo-visibility">
1955 <label class="new-repo-vis-card">
1956 <input
1957 type="radio"
1958 name="visibility"
1959 value="public"
1960 checked
1961 class="new-repo-vis-radio"
1962 />
1963 <span class="new-repo-vis-body">
1964 <span class="new-repo-vis-label">Public</span>
1965 <span class="new-repo-vis-desc">
1966 Anyone can see this repository. You choose who can commit.
1967 </span>
1968 </span>
1969 </label>
1970 <label class="new-repo-vis-card">
1971 <input
1972 type="radio"
1973 name="visibility"
1974 value="private"
1975 class="new-repo-vis-radio"
1976 />
1977 <span class="new-repo-vis-body">
1978 <span class="new-repo-vis-label">Private</span>
1979 <span class="new-repo-vis-desc">
1980 Only you (and collaborators you invite) can see this
1981 repository.
1982 </span>
1983 </span>
1984 </label>
1985 </div>
1986 </div>
398a10cClaude1987 <div class="new-repo-row">
1988 <span class="new-repo-label">
1989 Starter content{" "}
1990 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1991 </span>
1992 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1993 <label class="new-repo-template-chip">
1994 <input type="radio" name="starter" value="empty" checked />
1995 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1996 Empty
1997 </label>
1998 <label class="new-repo-template-chip">
1999 <input type="radio" name="starter" value="readme" />
2000 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2001 README
2002 </label>
2003 <label class="new-repo-template-chip">
2004 <input type="radio" name="starter" value="readme-mit" />
2005 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2006 README + MIT
2007 </label>
2008 <label class="new-repo-template-chip">
2009 <input type="radio" name="starter" value="node" />
2010 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2011 Node + .gitignore
2012 </label>
2013 </div>
2014 <p class="new-repo-hint">
2015 Just a UI hint — push your own commits to fill the repo.
2016 </p>
2017 </div>
44f1a02Claude2018 <div class="new-repo-row">
2019 <label class="new-repo-label" for="data_region">
2020 Data region
2021 </label>
2022 <select
2023 id="data_region"
2024 name="data_region"
2025 class="new-repo-input"
2026 style="cursor: pointer;"
2027 >
2028 <option value="us" selected>US (default)</option>
2029 <option value="eu">EU (Frankfurt)</option>
2030 </select>
2031 <p class="new-repo-hint">
2032 EU data residency requires a{" "}
2033 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2034 Pro plan or higher
2035 </a>
2036 . Repositories cannot be moved between regions after creation.
2037 </p>
2038 </div>
efb11c5Claude2039 <div class="new-repo-callout">
2040 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2041 <p class="new-repo-callout-body">
2042 Every push is gate-checked and reviewed by Claude automatically.
2043 Label an issue <code>ai-build</code> and Gluecron will open the PR
2044 for you.
2045 </p>
2046 </div>
2047 <div class="new-repo-actions">
398a10cClaude2048 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2049 Create repository
2050 </button>
2051 <a href="/dashboard" class="btn new-repo-cancel">
2052 Cancel
2053 </a>
06d5ffeClaude2054 </div>
2055 </form>
2056 </div>
2057 </Layout>
2058 );
2059});
2060
2061web.post("/new", requireAuth, async (c) => {
2062 const user = c.get("user")!;
2063 const body = await c.req.parseBody();
2064 const name = String(body.name || "").trim();
2065 const description = String(body.description || "").trim();
2066 const isPrivate = body.visibility === "private";
44f1a02Claude2067 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2068
2069 if (!name) {
2070 return c.redirect("/new?error=Repository+name+is+required");
2071 }
2072
c63b860Claude2073 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2074 // outage never blocks repo creation.
2075 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2076 const gate = await checkRepoCreateAllowed(user.id);
2077 if (!gate.ok) {
2078 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2079 }
2080
06d5ffeClaude2081 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2082 return c.redirect("/new?error=Invalid+repository+name");
2083 }
2084
2085 if (await repoExists(user.username, name)) {
2086 return c.redirect("/new?error=Repository+already+exists");
2087 }
2088
2089 const diskPath = await initBareRepo(user.username, name);
2090
3ef4c9dClaude2091 const [newRepo] = await db
2092 .insert(repositories)
2093 .values({
2094 name,
2095 ownerId: user.id,
2096 description: description || null,
2097 isPrivate,
2098 diskPath,
44f1a02Claude2099 dataRegion,
3ef4c9dClaude2100 })
2101 .returning();
2102
2103 if (newRepo) {
2104 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2105 await bootstrapRepository({
2106 repositoryId: newRepo.id,
2107 ownerUserId: user.id,
2108 defaultBranch: "main",
2109 });
2110 }
06d5ffeClaude2111
2112 return c.redirect(`/${user.username}/${name}`);
2113});
2114
11c3ab6ccanty labs2115// Daily brief — GET /brief
2116web.get("/brief", (c) => {
2117 const user = c.get("user");
2118 return c.html(<DailyBrief user={user} />);
2119});
2120
2121// Trust report — GET /trust (public)
2122web.get("/trust", (c) => {
2123 return c.html(<TrustReport />);
2124});
2125
2126// Production layers — GET /layers
2127web.get("/layers", (c) => {
2128 const user = c.get("user");
2129 return c.html(<ProductionLayers user={user} />);
2130});
2131
2132// Distribution — GET /distribute
2133web.get("/distribute", (c) => {
2134 const user = c.get("user");
2135 return c.html(<DistributionView user={user} />);
2136});
2137
06d5ffeClaude2138// User profile
fc1817aClaude2139web.get("/:owner", async (c) => {
06d5ffeClaude2140 const { owner: ownerName } = c.req.param();
2141 const user = c.get("user");
2142
2143 // Avoid clashing with fixed routes
2144 if (
2145 ["login", "register", "logout", "new", "settings", "api"].includes(
2146 ownerName
2147 )
2148 ) {
2149 return c.notFound();
2150 }
2151
2152 let ownerUser;
2153 try {
2154 const [found] = await db
2155 .select()
2156 .from(users)
2157 .where(eq(users.username, ownerName))
2158 .limit(1);
2159 ownerUser = found;
2160 } catch {
2161 // DB not available — check if repos exist on disk
2162 ownerUser = null;
2163 }
2164
2165 // Even without DB, show repos if they exist on disk
2166 let repos: any[] = [];
2167 if (ownerUser) {
2168 const allRepos = await db
2169 .select()
2170 .from(repositories)
2171 .where(eq(repositories.ownerId, ownerUser.id))
2172 .orderBy(desc(repositories.updatedAt));
2173
2174 // Show public repos to everyone, private only to owner
2175 repos =
2176 user?.id === ownerUser.id
2177 ? allRepos
2178 : allRepos.filter((r) => !r.isPrivate);
2179 }
2180
7aa8b99Claude2181 // Block J4 — follow counts + viewer's follow state
2182 let followState = {
2183 followers: 0,
2184 following: 0,
2185 viewerFollows: false,
2186 };
2187 if (ownerUser) {
2188 try {
2189 const { followCounts, isFollowing } = await import("../lib/follows");
2190 const counts = await followCounts(ownerUser.id);
2191 followState.followers = counts.followers;
2192 followState.following = counts.following;
2193 if (user && user.id !== ownerUser.id) {
2194 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2195 }
2196 } catch {
2197 // DB hiccup — fall back to zeros.
2198 }
2199 }
2200 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2201
d412586Claude2202 // Block J5 — profile README. Render owner/owner repo's README on the
2203 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2204 // back to "<user>/.github" for org-style profile repos.
2205 let profileReadmeHtml: string | null = null;
2206 try {
2207 const candidates = [ownerName, ".github"];
2208 for (const rname of candidates) {
2209 if (await repoExists(ownerName, rname)) {
2210 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2211 const md = await getReadme(ownerName, rname, ref);
2212 if (md) {
2213 profileReadmeHtml = renderMarkdown(md);
2214 break;
2215 }
2216 }
2217 }
2218 } catch {
2219 profileReadmeHtml = null;
2220 }
2221
efb11c5Claude2222 const displayName = ownerUser?.displayName || ownerName;
2223 const memberSince = ownerUser?.createdAt
2224 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2225 : null;
2226
fc1817aClaude2227 return c.html(
06d5ffeClaude2228 <Layout title={ownerName} user={user}>
efb11c5Claude2229 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2230 <div class="profile-hero">
2231 <div class="profile-hero-orb-wrap" aria-hidden="true">
2232 <div class="profile-hero-orb" />
06d5ffeClaude2233 </div>
efb11c5Claude2234 <div class="profile-hero-inner">
2235 <div class="profile-hero-avatar" aria-hidden="true">
2236 {displayName[0].toUpperCase()}
2237 </div>
2238 <div class="profile-hero-text">
2239 <div class="profile-eyebrow">
2240 <strong>Developer</strong>
2241 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2242 <>
2243 {" "}· Joined{" "}
2244 {memberSince.toLocaleDateString("en-US", {
2245 month: "short",
2246 year: "numeric",
2247 })}
2248 </>
2249 )}
2250 </div>
2251 <h1 class="profile-name">
2252 <span class="gradient-text">{displayName}</span>
2253 </h1>
2254 <div class="profile-handle">@{ownerName}</div>
2255 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2256 <div class="profile-meta">
2257 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2258 <strong>{followState.followers}</strong> follower
2259 {followState.followers === 1 ? "" : "s"}
2260 </a>
2261 <a href={`/${ownerName}/following`} class="profile-meta-link">
2262 <strong>{followState.following}</strong> following
2263 </a>
2264 <a href={`/${ownerName}`} class="profile-meta-link">
2265 <strong>{repos.length}</strong> repo
2266 {repos.length === 1 ? "" : "s"}
2267 </a>
2268 {canFollow && (
2269 <form
2270 method="post"
2271 action={`/${ownerName}/${
2272 followState.viewerFollows ? "unfollow" : "follow"
2273 }`}
2274 class="profile-follow-form"
7aa8b99Claude2275 >
efb11c5Claude2276 <button
2277 type="submit"
2278 class={`btn ${
2279 followState.viewerFollows ? "" : "btn-primary"
2280 } btn-sm`}
2281 >
2282 {followState.viewerFollows ? "Unfollow" : "Follow"}
2283 </button>
2284 </form>
2285 )}
2286 </div>
7aa8b99Claude2287 </div>
06d5ffeClaude2288 </div>
2289 </div>
d412586Claude2290 {profileReadmeHtml && (
efb11c5Claude2291 <div class="profile-readme">
2292 <div class="profile-readme-head">
2293 <span class="profile-readme-icon">{"☰"}</span>
2294 <span>{ownerName}/{ownerName} README.md</span>
2295 </div>
2296 <div
2297 class="markdown-body profile-readme-body"
2298 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2299 />
2300 </div>
d412586Claude2301 )}
efb11c5Claude2302 <div class="profile-section-head">
2303 <h2 class="profile-section-title">Repositories</h2>
2304 <span class="profile-section-count">{repos.length}</span>
2305 </div>
06d5ffeClaude2306 {repos.length === 0 ? (
efb11c5Claude2307 <div class="profile-empty">
2308 <p class="profile-empty-text">
2309 No repositories yet
2310 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2311 </p>
2312 {user?.id === ownerUser?.id && (
2313 <a href="/new" class="btn btn-primary btn-sm">
2314 + Create your first
2315 </a>
2316 )}
2317 </div>
06d5ffeClaude2318 ) : (
2319 <div class="card-grid">
2320 {repos.map((repo) => (
2321 <RepoCard repo={repo} ownerName={ownerName} />
2322 ))}
2323 </div>
2324 )}
fc1817aClaude2325 </Layout>
2326 );
2327});
2328
06d5ffeClaude2329// Star/unstar a repo
2330web.post("/:owner/:repo/star", requireAuth, async (c) => {
2331 const { owner: ownerName, repo: repoName } = c.req.param();
2332 const user = c.get("user")!;
2333
2334 try {
2335 const [ownerUser] = await db
2336 .select()
2337 .from(users)
2338 .where(eq(users.username, ownerName))
2339 .limit(1);
2340 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2341
2342 const [repo] = await db
2343 .select()
2344 .from(repositories)
2345 .where(
2346 and(
2347 eq(repositories.ownerId, ownerUser.id),
2348 eq(repositories.name, repoName)
2349 )
2350 )
2351 .limit(1);
2352 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2353
2354 // Toggle star
2355 const [existing] = await db
2356 .select()
2357 .from(stars)
2358 .where(
2359 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2360 )
2361 .limit(1);
2362
2363 if (existing) {
2364 await db.delete(stars).where(eq(stars.id, existing.id));
2365 await db
2366 .update(repositories)
2367 .set({ starCount: Math.max(0, repo.starCount - 1) })
2368 .where(eq(repositories.id, repo.id));
2369 } else {
2370 await db.insert(stars).values({
2371 userId: user.id,
2372 repositoryId: repo.id,
2373 });
2374 await db
2375 .update(repositories)
2376 .set({ starCount: repo.starCount + 1 })
2377 .where(eq(repositories.id, repo.id));
2378 }
2379 } catch {
2380 // DB error — ignore
2381 }
2382
2383 return c.redirect(`/${ownerName}/${repoName}`);
2384});
2385
641aa42Claude2386// ---------------------------------------------------------------------------
2387// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2388// ---------------------------------------------------------------------------
2389const obCss = `
2390 .ob-card {
2391 position: relative;
2392 margin: 14px 0 18px;
6fd5915Claude2393 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2394 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2395 border-radius: 14px;
2396 overflow: hidden;
2397 }
2398 .ob-card::before {
2399 content: '';
2400 position: absolute;
2401 top: 0; left: 0; right: 0;
2402 height: 2px;
6fd5915Claude2403 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2404 pointer-events: none;
2405 }
2406 .ob-header {
2407 padding: 16px 20px 10px;
2408 border-bottom: 1px solid var(--border);
2409 }
2410 .ob-header h3 {
2411 font-size: 16px;
2412 font-weight: 700;
2413 margin: 0 0 4px;
2414 color: var(--text-strong);
2415 }
2416 .ob-header p {
2417 font-size: 13px;
2418 color: var(--text-muted);
2419 margin: 0;
2420 }
2421 .ob-sections {
2422 display: grid;
2423 grid-template-columns: repeat(3, 1fr);
2424 gap: 0;
2425 }
2426 @media (max-width: 760px) {
2427 .ob-sections { grid-template-columns: 1fr; }
2428 }
2429 .ob-section {
2430 padding: 14px 20px;
2431 border-right: 1px solid var(--border);
2432 }
2433 .ob-section:last-child { border-right: none; }
2434 .ob-section h4 {
2435 font-size: 12px;
2436 font-weight: 700;
2437 text-transform: uppercase;
2438 letter-spacing: 0.08em;
2439 color: var(--accent);
2440 margin: 0 0 8px;
2441 }
2442 .ob-preview {
2443 font-family: var(--font-mono);
2444 font-size: 11.5px;
2445 background: var(--bg);
2446 border: 1px solid var(--border);
2447 border-radius: 6px;
2448 padding: 8px 10px;
2449 color: var(--text-muted);
2450 line-height: 1.55;
2451 margin-bottom: 8px;
2452 white-space: pre-wrap;
2453 overflow: hidden;
2454 max-height: 80px;
2455 position: relative;
2456 }
2457 .ob-preview::after {
2458 content: '';
2459 position: absolute;
2460 bottom: 0; left: 0; right: 0;
2461 height: 24px;
2462 background: linear-gradient(transparent, var(--bg));
2463 pointer-events: none;
2464 }
2465 .ob-labels {
2466 display: flex;
2467 flex-wrap: wrap;
2468 gap: 5px;
2469 margin-bottom: 8px;
2470 }
2471 .ob-label-chip {
2472 display: inline-flex;
2473 align-items: center;
2474 padding: 2px 8px;
2475 border-radius: 9999px;
2476 font-size: 11.5px;
2477 font-weight: 600;
2478 line-height: 1.5;
2479 border: 1px solid transparent;
2480 }
2481 .ob-section ul {
2482 margin: 0;
2483 padding: 0 0 0 16px;
2484 font-size: 13px;
2485 color: var(--text-muted);
2486 line-height: 1.7;
2487 }
2488 .ob-section ul li { margin-bottom: 2px; }
2489 .ob-footer {
2490 display: flex;
2491 align-items: center;
2492 justify-content: flex-end;
2493 padding: 10px 20px;
2494 border-top: 1px solid var(--border);
2495 gap: 10px;
2496 }
2497 .ob-dismiss {
2498 appearance: none;
2499 background: transparent;
2500 border: 1px solid var(--border);
2501 color: var(--text-muted);
2502 border-radius: 6px;
2503 padding: 5px 12px;
2504 font-size: 12.5px;
2505 font-family: inherit;
2506 cursor: pointer;
2507 transition: background var(--t-fast), color var(--t-fast);
2508 }
2509 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2510 .btn-sm {
2511 appearance: none;
2512 background: var(--bg-elevated);
2513 border: 1px solid var(--border);
2514 color: var(--text-strong);
2515 border-radius: 6px;
2516 padding: 5px 12px;
2517 font-size: 12.5px;
2518 font-weight: 600;
2519 font-family: inherit;
2520 cursor: pointer;
2521 text-decoration: none;
2522 display: inline-flex;
2523 align-items: center;
2524 transition: background var(--t-fast);
2525 }
2526 .btn-sm:hover { background: var(--bg-hover); }
2527 .btn-sm.btn-primary {
2528 background: var(--accent);
2529 color: #fff;
2530 border-color: var(--accent);
2531 }
2532 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2533`;
2534
2535// Onboarding card component — shown to repo owner until dismissed
2536function RepoOnboardingCard({
2537 owner,
2538 repo,
2539 data,
2540}: {
2541 owner: string;
2542 repo: string;
2543 data: typeof repoOnboardingData.$inferSelect;
2544}) {
2545 const labels = (data.suggestedLabels ?? []) as Array<{
2546 name: string;
2547 color: string;
2548 description: string;
2549 }>;
2550 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2551 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2552
2553 return (
2554 <>
2555 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2556 <div class="ob-card" id="repo-onboarding">
2557 <div class="ob-header">
2558 <h3>Get started with {owner}/{repo}</h3>
2559 <p>
2560 Detected: {data.detectedLanguage ?? "Unknown"}
2561 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2562 Here&rsquo;s what we suggest to hit the ground running.
2563 </p>
2564 </div>
2565 <div class="ob-sections">
2566 <div class="ob-section">
2567 <h4>Suggested README</h4>
2568 <div class="ob-preview">{readmePreview}</div>
2569 <button
2570 class="btn-sm"
2571 type="button"
2572 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(_){};})()`}
2573 aria-label="Copy suggested README to clipboard"
2574 >
2575 Copy to clipboard
2576 </button>
2577 </div>
2578 <div class="ob-section">
2579 <h4>Suggested labels ({labels.length})</h4>
2580 <div class="ob-labels">
2581 {labels.slice(0, 6).map((l) => (
2582 <span
2583 class="ob-label-chip"
2584 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2585 title={l.description}
2586 >
2587 {l.name}
2588 </span>
2589 ))}
2590 </div>
2591 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2592 <button class="btn-sm btn-primary" type="submit">
2593 Create all labels
2594 </button>
2595 </form>
2596 </div>
2597 <div class="ob-section">
2598 <h4>First steps</h4>
2599 <ul>
2600 {suggestions.map((s) => (
2601 <li>{s}</li>
2602 ))}
2603 </ul>
2604 </div>
2605 </div>
2606 <div class="ob-footer">
2607 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2608 <button class="ob-dismiss" type="submit">
2609 Dismiss
2610 </button>
2611 </form>
2612 </div>
2613 <script
2614 dangerouslySetInnerHTML={{
2615 __html: `
2616 (function(){
2617 var card = document.getElementById('repo-onboarding');
2618 if (!card) return;
2619 document.addEventListener('keydown', function(e){
2620 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2621 card.style.display = 'none';
2622 }
2623 });
2624 })();
2625 `,
2626 }}
2627 />
2628 </div>
2629 </>
2630 );
2631}
2632
2633// ---------------------------------------------------------------------------
2634// Setup routes — create suggested labels + dismiss onboarding
2635// ---------------------------------------------------------------------------
2636
2637// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2638// requireAuth + write access. Idempotent (label UPSERT by name).
2639web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2640 const { owner, repo } = c.req.param();
2641 const user = c.get("user")!;
2642
2643 // Resolve repo + verify write access
2644 let repoRow: { id: string; ownerId: string } | null = null;
2645 try {
2646 const [ownerUser] = await db
2647 .select({ id: users.id })
2648 .from(users)
2649 .where(eq(users.username, owner))
2650 .limit(1);
2651 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2652 const [r] = await db
2653 .select({ id: repositories.id, ownerId: repositories.ownerId })
2654 .from(repositories)
2655 .where(
2656 and(
2657 eq(repositories.ownerId, ownerUser.id),
2658 eq(repositories.name, repo)
2659 )
2660 )
2661 .limit(1);
2662 repoRow = r ?? null;
2663 } catch {
2664 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2665 }
2666 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2667 if (repoRow.ownerId !== user.id) {
2668 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2669 }
2670
2671 // Fetch the onboarding data
2672 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2673 try {
2674 const [r] = await db
2675 .select()
2676 .from(repoOnboardingData)
2677 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2678 .limit(1);
2679 obRow = r ?? null;
2680 } catch {
2681 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2682 }
2683 if (!obRow) {
2684 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2685 }
2686
2687 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2688 name: string;
2689 color: string;
2690 description: string;
2691 }>;
2692
2693 // Create labels — import the labels table which was already imported
2694 let created = 0;
2695 for (const l of suggestedLabels) {
2696 try {
2697 await db
2698 .insert(labels)
2699 .values({
2700 repositoryId: repoRow.id,
2701 name: l.name,
2702 color: l.color,
2703 description: l.description ?? "",
2704 })
2705 .onConflictDoNothing();
2706 created++;
2707 } catch {
2708 /* skip duplicates */
2709 }
2710 }
2711
2712 // Mark onboarding dismissed
2713 try {
2714 await db
2715 .update(repositories)
2716 .set({ onboardingShown: true })
2717 .where(eq(repositories.id, repoRow.id));
2718 } catch {
2719 /* ignore */
2720 }
2721
2722 return c.redirect(
2723 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2724 );
2725});
2726
2727// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2728web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2729 const { owner, repo } = c.req.param();
2730 const user = c.get("user")!;
2731
2732 try {
2733 const [ownerUser] = await db
2734 .select({ id: users.id })
2735 .from(users)
2736 .where(eq(users.username, owner))
2737 .limit(1);
2738 if (ownerUser) {
2739 const [r] = await db
2740 .select({ id: repositories.id, ownerId: repositories.ownerId })
2741 .from(repositories)
2742 .where(
2743 and(
2744 eq(repositories.ownerId, ownerUser.id),
2745 eq(repositories.name, repo)
2746 )
2747 )
2748 .limit(1);
2749 if (r && r.ownerId === user.id) {
2750 await db
2751 .update(repositories)
2752 .set({ onboardingShown: true })
2753 .where(eq(repositories.id, r.id));
2754 }
2755 }
2756 } catch {
2757 /* swallow — dismiss should never fail visibly */
2758 }
2759
2760 return c.redirect(`/${owner}/${repo}`);
2761});
2762
11c3ab6ccanty labs2763// Agent workspace — GET /:owner/:repo/workspace
2764web.get("/:owner/:repo/workspace", (c) => {
2765 const { owner, repo } = c.req.param();
2766 const user = c.get("user");
2767 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2768});
2769
2770// Org memory — GET /:owner/:repo/memory
2771web.get("/:owner/:repo/memory", (c) => {
2772 const { owner, repo } = c.req.param();
2773 const user = c.get("user");
2774 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2775});
2776
fc1817aClaude2777// Repository overview — file tree at HEAD
2778web.get("/:owner/:repo", async (c) => {
2779 const { owner, repo } = c.req.param();
06d5ffeClaude2780 const user = c.get("user");
fc1817aClaude2781
f1dc7c7Claude2782 // ── Loading skeleton (flag-gated) ──
2783 // Renders an SSR'd shell with file-tree + README placeholders when
2784 // `?skeleton=1` is set. Lets the user see the page structure before
2785 // git ops finish. Behind a flag for now so we never flash before the
2786 // real content lands.
2787 if (c.req.query("skeleton") === "1") {
2788 return c.html(
2789 <Layout title={`${owner}/${repo}`} user={user}>
2790 <style
2791 dangerouslySetInnerHTML={{
2792 __html: `
2793 .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; }
2794 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2795 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2796 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2797 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2798 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2799 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2800 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2801 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2802 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2803 .repo-skel-readme { height: 320px; border-radius: 12px; }
2804 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2805 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2806 `,
2807 }}
2808 />
2809 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2810 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2811 <div class="repo-skel-grid" aria-hidden="true">
2812 <div>
2813 <div class="repo-skel repo-skel-branch" />
2814 <div class="repo-skel-tree">
2815 {Array.from({ length: 8 }).map(() => (
2816 <div class="repo-skel repo-skel-tree-row" />
2817 ))}
2818 </div>
2819 <div class="repo-skel repo-skel-readme" />
2820 </div>
2821 <aside class="repo-skel-side">
2822 <div class="repo-skel repo-skel-side-card" />
2823 <div class="repo-skel repo-skel-side-card" />
2824 </aside>
2825 </div>
2826 <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">
2827 Loading {owner}/{repo}…
2828 </span>
2829 </Layout>
2830 );
2831 }
2832
8f50ed0Claude2833 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2834 trackByName(owner, repo, "view", {
2835 userId: user?.id || null,
2836 path: `/${owner}/${repo}`,
2837 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2838 userAgent: c.req.header("user-agent") || null,
2839 referer: c.req.header("referer") || null,
a28cedeClaude2840 }).catch((err) => {
2841 console.warn(
2842 `[web] view tracking failed for ${owner}/${repo}:`,
2843 err instanceof Error ? err.message : err
2844 );
2845 });
8f50ed0Claude2846
fc1817aClaude2847 if (!(await repoExists(owner, repo))) {
2848 return c.html(
06d5ffeClaude2849 <Layout title="Not Found" user={user}>
fc1817aClaude2850 <div class="empty-state">
2851 <h2>Repository not found</h2>
2852 <p>
2853 {owner}/{repo} does not exist.
2854 </p>
2855 </div>
2856 </Layout>,
2857 404
2858 );
2859 }
2860
05b973eClaude2861 // Parallelize all independent operations
2862 const [defaultBranch, branches] = await Promise.all([
2863 getDefaultBranch(owner, repo).then((b) => b || "main"),
2864 listBranches(owner, repo),
2865 ]);
2866 const [tree, starInfo] = await Promise.all([
2867 getTree(owner, repo, defaultBranch),
2868 // Star info fetched in parallel with tree
2869 (async () => {
2870 try {
2871 const [ownerUser] = await db
2872 .select()
2873 .from(users)
2874 .where(eq(users.username, owner))
2875 .limit(1);
71cd5ecClaude2876 if (!ownerUser)
2877 return {
2878 starCount: 0,
2879 starred: false,
2880 archived: false,
2881 isTemplate: false,
544d842Claude2882 forkCount: 0,
2883 description: null as string | null,
2884 pushedAt: null as Date | null,
2885 createdAt: null as Date | null,
cb5a796Claude2886 repoId: null as string | null,
2887 repoOwnerId: null as string | null,
71cd5ecClaude2888 };
05b973eClaude2889 const [repoRow] = await db
2890 .select()
2891 .from(repositories)
2892 .where(
2893 and(
2894 eq(repositories.ownerId, ownerUser.id),
2895 eq(repositories.name, repo)
2896 )
06d5ffeClaude2897 )
05b973eClaude2898 .limit(1);
71cd5ecClaude2899 if (!repoRow)
2900 return {
2901 starCount: 0,
2902 starred: false,
2903 archived: false,
2904 isTemplate: false,
544d842Claude2905 forkCount: 0,
2906 description: null as string | null,
2907 pushedAt: null as Date | null,
2908 createdAt: null as Date | null,
cb5a796Claude2909 repoId: null as string | null,
2910 repoOwnerId: null as string | null,
71cd5ecClaude2911 };
05b973eClaude2912 let starred = false;
06d5ffeClaude2913 if (user) {
2914 const [star] = await db
2915 .select()
2916 .from(stars)
2917 .where(
2918 and(
2919 eq(stars.userId, user.id),
2920 eq(stars.repositoryId, repoRow.id)
2921 )
2922 )
2923 .limit(1);
2924 starred = !!star;
2925 }
71cd5ecClaude2926 return {
2927 starCount: repoRow.starCount,
2928 starred,
2929 archived: repoRow.isArchived,
2930 isTemplate: repoRow.isTemplate,
544d842Claude2931 forkCount: repoRow.forkCount,
2932 description: repoRow.description as string | null,
2933 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2934 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2935 repoId: repoRow.id as string,
2936 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2937 };
05b973eClaude2938 } catch {
71cd5ecClaude2939 return {
2940 starCount: 0,
2941 starred: false,
2942 archived: false,
2943 isTemplate: false,
544d842Claude2944 forkCount: 0,
2945 description: null as string | null,
2946 pushedAt: null as Date | null,
2947 createdAt: null as Date | null,
cb5a796Claude2948 repoId: null as string | null,
2949 repoOwnerId: null as string | null,
71cd5ecClaude2950 };
06d5ffeClaude2951 }
05b973eClaude2952 })(),
2953 ]);
544d842Claude2954 const {
2955 starCount,
2956 starred,
2957 archived,
2958 isTemplate,
2959 forkCount,
2960 description,
2961 pushedAt,
2962 createdAt,
cb5a796Claude2963 repoId,
2964 repoOwnerId,
544d842Claude2965 } = starInfo;
2966
91a0204Claude2967 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2968 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2969 // and the badge simply doesn't render.
2970 let healthScore: HealthScore | null = null;
2971 if (repoId) {
2972 try {
2973 healthScore = await computeHealthScore(repoId);
2974 } catch {
2975 // swallow — badge is optional
2976 }
2977 }
2978
cb5a796Claude2979 // Pending-comments banner data (lazy + best-effort). Only the repo
2980 // owner sees the banner, so non-owner views skip the DB hit entirely.
2981 let repoHomePendingCount = 0;
2982 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2983 try {
2984 const { countPendingForRepo } = await import(
2985 "../lib/comment-moderation"
2986 );
2987 repoHomePendingCount = await countPendingForRepo(repoId);
2988 } catch {
2989 /* swallow */
2990 }
2991 }
2992
8c790e0Claude2993 // Push Watch discoverability — fetch the most recent push within 24 h.
2994 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2995 const recentPush: RecentPush | null = repoId
2996 ? await getRecentPush(repoId)
2997 : null;
2998
ebaae0fClaude2999 // AI stats strip — best-effort, fail-open to zero values.
3000 let aiStats: RepoAiStats = {
3001 mergedCount: 0,
3002 reviewCount: 0,
3003 securityAlertCount: 0,
3004 hoursSaved: "0.0",
3005 };
3006 if (repoId) {
3007 try {
3008 aiStats = await getRepoAiStats(repoId);
3009 } catch {
3010 /* swallow — show zero values */
3011 }
3012 }
3013
641aa42Claude3014 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3015 // if the DB is down the card simply won't appear. Only the owner sees it.
3016 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3017 let showOnboarding = false;
3018 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3019 try {
3020 // Check if onboarding_shown is still false (not yet dismissed)
3021 const [repoRow2] = await db
3022 .select({ onboardingShown: repositories.onboardingShown })
3023 .from(repositories)
3024 .where(eq(repositories.id, repoId))
3025 .limit(1);
3026 if (repoRow2 && !repoRow2.onboardingShown) {
3027 const [obRow] = await db
3028 .select()
3029 .from(repoOnboardingData)
3030 .where(eq(repoOnboardingData.repositoryId, repoId))
3031 .limit(1);
3032 onboardingRow = obRow ?? null;
3033 showOnboarding = !!onboardingRow;
3034 }
3035 } catch {
3036 /* swallow — onboarding is optional */
3037 }
3038 }
3039
544d842Claude3040 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3041 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3042 const repoHomeCss = `
3043 .repo-home-hero {
3044 position: relative;
3045 margin-bottom: var(--space-5);
3046 padding: var(--space-5) var(--space-6);
3047 background: var(--bg-elevated);
3048 border: 1px solid var(--border);
3049 border-radius: 16px;
3050 overflow: hidden;
3051 }
3052 .repo-home-hero::before {
3053 content: '';
3054 position: absolute;
3055 top: 0; left: 0; right: 0;
3056 height: 2px;
6fd5915Claude3057 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3058 opacity: 0.7;
3059 pointer-events: none;
3060 }
3061 .repo-home-hero-orb-wrap {
3062 position: absolute;
3063 inset: -25% -10% auto auto;
3064 width: 360px;
3065 height: 360px;
3066 pointer-events: none;
3067 z-index: 0;
3068 }
3069 .repo-home-hero-orb {
3070 position: absolute;
3071 inset: 0;
6fd5915Claude3072 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3073 filter: blur(80px);
3074 opacity: 0.7;
3075 animation: repoHomeOrb 14s ease-in-out infinite;
3076 }
3077 @keyframes repoHomeOrb {
3078 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3079 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3080 }
3081 @media (prefers-reduced-motion: reduce) {
3082 .repo-home-hero-orb { animation: none; }
3083 }
3084 .repo-home-hero-inner {
3085 position: relative;
3086 z-index: 1;
3087 }
3088 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3089 .repo-home-eyebrow {
3090 font-size: 12px;
3091 font-family: var(--font-mono);
3092 color: var(--text-muted);
3093 letter-spacing: 0.1em;
3094 text-transform: uppercase;
3095 margin-bottom: var(--space-2);
3096 }
3097 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3098 .repo-home-description {
3099 font-size: 15px;
3100 line-height: 1.55;
3101 color: var(--text);
3102 margin: 0;
3103 max-width: 720px;
3104 }
3105 .repo-home-description-empty {
3106 font-size: 14px;
3107 color: var(--text-muted);
3108 font-style: italic;
3109 margin: 0;
3110 }
3111 .repo-home-stat-row {
3112 display: flex;
3113 flex-wrap: wrap;
3114 gap: var(--space-4);
3115 margin-top: var(--space-3);
3116 font-size: 13px;
3117 color: var(--text-muted);
3118 }
3119 .repo-home-stat {
3120 display: inline-flex;
3121 align-items: center;
3122 gap: 6px;
3123 }
3124 .repo-home-stat strong {
3125 color: var(--text-strong);
3126 font-weight: 600;
3127 font-variant-numeric: tabular-nums;
3128 }
3129 .repo-home-stat .repo-home-stat-icon {
3130 color: var(--text-faint);
3131 font-size: 14px;
3132 line-height: 1;
3133 }
3134 .repo-home-stat a {
3135 color: var(--text-muted);
3136 transition: color var(--t-fast) var(--ease);
3137 }
3138 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3139
3140 /* Two-column layout: file tree + sidebar */
3141 .repo-home-grid {
3142 display: grid;
3143 grid-template-columns: minmax(0, 1fr) 280px;
3144 gap: var(--space-5);
3145 align-items: start;
3146 }
3147 @media (max-width: 960px) {
3148 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3149 }
3150 .repo-home-main { min-width: 0; }
3151
3152 /* Sidebar card */
3153 .repo-home-side {
3154 display: flex;
3155 flex-direction: column;
3156 gap: var(--space-4);
3157 }
3158 .repo-home-side-card {
3159 background: var(--bg-elevated);
3160 border: 1px solid var(--border);
3161 border-radius: 12px;
3162 padding: var(--space-4);
3163 }
3164 .repo-home-side-title {
3165 font-size: 11px;
3166 font-family: var(--font-mono);
3167 letter-spacing: 0.12em;
3168 text-transform: uppercase;
3169 color: var(--text-muted);
3170 margin: 0 0 var(--space-3);
3171 font-weight: 600;
3172 }
3173 .repo-home-side-row {
3174 display: flex;
3175 justify-content: space-between;
3176 align-items: center;
3177 gap: var(--space-2);
3178 font-size: 13px;
3179 padding: 6px 0;
3180 border-top: 1px solid var(--border);
3181 }
3182 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3183 .repo-home-side-key {
3184 color: var(--text-muted);
3185 display: inline-flex;
3186 align-items: center;
3187 gap: 6px;
3188 }
3189 .repo-home-side-val {
3190 color: var(--text-strong);
3191 font-weight: 500;
3192 font-variant-numeric: tabular-nums;
3193 max-width: 60%;
3194 text-align: right;
3195 overflow: hidden;
3196 text-overflow: ellipsis;
3197 white-space: nowrap;
3198 }
3199 .repo-home-side-val a { color: var(--text-strong); }
3200 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3201
3202 /* Clone / Code tabs */
3203 .repo-home-clone {
3204 background: var(--bg-elevated);
3205 border: 1px solid var(--border);
3206 border-radius: 12px;
3207 overflow: hidden;
3208 }
3209 .repo-home-clone-tabs {
3210 display: flex;
3211 gap: 0;
3212 background: var(--bg-secondary);
3213 border-bottom: 1px solid var(--border);
3214 padding: 0 var(--space-2);
3215 }
3216 .repo-home-clone-tab {
3217 appearance: none;
3218 background: transparent;
3219 border: 0;
3220 border-bottom: 2px solid transparent;
3221 padding: 9px 12px;
3222 font-size: 12px;
3223 font-weight: 500;
3224 color: var(--text-muted);
3225 cursor: pointer;
3226 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3227 font-family: inherit;
3228 margin-bottom: -1px;
3229 }
3230 .repo-home-clone-tab:hover { color: var(--text-strong); }
3231 .repo-home-clone-tab[aria-selected="true"] {
3232 color: var(--text-strong);
3233 border-bottom-color: var(--accent);
3234 }
3235 .repo-home-clone-body {
3236 padding: var(--space-3);
3237 display: flex;
3238 align-items: center;
3239 gap: var(--space-2);
3240 }
3241 .repo-home-clone-input {
3242 flex: 1;
3243 min-width: 0;
3244 font-family: var(--font-mono);
3245 font-size: 12px;
3246 background: var(--bg);
3247 border: 1px solid var(--border);
3248 border-radius: 8px;
3249 padding: 8px 10px;
3250 color: var(--text-strong);
3251 overflow: hidden;
3252 text-overflow: ellipsis;
3253 white-space: nowrap;
3254 }
3255 .repo-home-clone-copy {
3256 appearance: none;
3257 background: var(--bg-secondary);
3258 border: 1px solid var(--border);
3259 color: var(--text-strong);
3260 border-radius: 8px;
3261 padding: 8px 12px;
3262 font-size: 12px;
3263 font-weight: 600;
3264 cursor: pointer;
3265 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3266 font-family: inherit;
3267 }
3268 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3269 .repo-home-clone-pane { display: none; }
3270 .repo-home-clone-pane[data-active="true"] { display: flex; }
3271
3272 /* README card */
3273 .repo-home-readme {
3274 margin-top: var(--space-5);
3275 background: var(--bg-elevated);
3276 border: 1px solid var(--border);
3277 border-radius: 12px;
3278 overflow: hidden;
3279 }
3280 .repo-home-readme-head {
3281 display: flex;
3282 align-items: center;
3283 gap: 8px;
3284 padding: 10px 16px;
3285 background: var(--bg-secondary);
3286 border-bottom: 1px solid var(--border);
3287 font-size: 13px;
3288 color: var(--text-muted);
3289 }
3290 .repo-home-readme-head .repo-home-readme-icon {
3291 color: var(--accent);
3292 font-size: 14px;
3293 }
3294 .repo-home-readme-body {
3295 padding: var(--space-5) var(--space-6);
3296 }
3297
3298 /* Empty-state CTA */
3299 .repo-home-empty {
3300 position: relative;
3301 margin-top: var(--space-4);
3302 background: var(--bg-elevated);
3303 border: 1px solid var(--border);
3304 border-radius: 16px;
3305 padding: var(--space-6);
3306 overflow: hidden;
3307 }
3308 .repo-home-empty::before {
3309 content: '';
3310 position: absolute;
3311 top: 0; left: 0; right: 0;
3312 height: 2px;
6fd5915Claude3313 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3314 opacity: 0.7;
3315 pointer-events: none;
3316 }
3317 .repo-home-empty-eyebrow {
3318 font-size: 12px;
3319 font-family: var(--font-mono);
3320 color: var(--accent);
3321 letter-spacing: 0.12em;
3322 text-transform: uppercase;
3323 margin-bottom: var(--space-2);
3324 font-weight: 600;
3325 }
3326 .repo-home-empty-title {
3327 font-family: var(--font-display);
3328 font-weight: 800;
3329 letter-spacing: -0.025em;
3330 font-size: clamp(22px, 3vw, 30px);
3331 line-height: 1.1;
3332 margin: 0 0 var(--space-2);
3333 color: var(--text-strong);
3334 }
3335 .repo-home-empty-sub {
3336 color: var(--text-muted);
3337 font-size: 14px;
3338 line-height: 1.55;
3339 max-width: 640px;
3340 margin: 0 0 var(--space-4);
3341 }
3342 .repo-home-empty-snippet {
3343 background: var(--bg);
3344 border: 1px solid var(--border);
3345 border-radius: 10px;
3346 padding: var(--space-3) var(--space-4);
3347 font-family: var(--font-mono);
3348 font-size: 12.5px;
3349 line-height: 1.7;
3350 color: var(--text-strong);
3351 overflow-x: auto;
3352 white-space: pre;
3353 margin: 0;
3354 }
3355 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3356 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3357
91a0204Claude3358 /* Health score badge */
3359 .repo-health-badge {
3360 display: inline-flex;
3361 align-items: center;
3362 gap: 5px;
3363 padding: 3px 10px;
3364 border-radius: 999px;
3365 font-size: 11.5px;
3366 font-weight: 700;
3367 font-family: var(--font-mono);
3368 text-decoration: none;
3369 border: 1px solid transparent;
3370 transition: filter 120ms ease, opacity 120ms ease;
3371 vertical-align: middle;
3372 }
3373 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3374 .repo-health-badge-elite {
3375 background: rgba(52,211,153,0.15);
3376 color: #6ee7b7;
3377 border-color: rgba(52,211,153,0.30);
3378 }
3379 .repo-health-badge-strong {
3380 background: rgba(96,165,250,0.15);
3381 color: #93c5fd;
3382 border-color: rgba(96,165,250,0.30);
3383 }
3384 .repo-health-badge-improving {
3385 background: rgba(251,191,36,0.15);
3386 color: #fde68a;
3387 border-color: rgba(251,191,36,0.30);
3388 }
3389 .repo-health-badge-needs-attention {
3390 background: rgba(248,113,113,0.15);
3391 color: #fca5a5;
3392 border-color: rgba(248,113,113,0.30);
3393 }
3394
3395 /* Three-option empty-state panel */
3396 .repo-empty-options {
3397 display: grid;
3398 grid-template-columns: repeat(3, 1fr);
3399 gap: var(--space-3);
3400 margin-top: var(--space-5);
3401 }
3402 @media (max-width: 760px) {
3403 .repo-empty-options { grid-template-columns: 1fr; }
3404 }
3405 .repo-empty-option {
3406 position: relative;
3407 background: var(--bg-elevated);
3408 border: 1px solid var(--border);
3409 border-radius: 14px;
3410 padding: var(--space-4) var(--space-4);
3411 display: flex;
3412 flex-direction: column;
3413 gap: var(--space-2);
3414 overflow: hidden;
3415 transition: border-color 140ms ease, background 140ms ease;
3416 }
3417 .repo-empty-option:hover {
6fd5915Claude3418 border-color: rgba(91,110,232,0.45);
3419 background: rgba(91,110,232,0.04);
91a0204Claude3420 }
3421 .repo-empty-option::before {
3422 content: '';
3423 position: absolute;
3424 top: 0; left: 0; right: 0;
3425 height: 2px;
6fd5915Claude3426 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3427 opacity: 0.55;
3428 pointer-events: none;
3429 }
3430 .repo-empty-option-label {
3431 font-size: 10px;
3432 font-family: var(--font-mono);
3433 font-weight: 700;
3434 letter-spacing: 0.12em;
3435 text-transform: uppercase;
3436 color: var(--accent);
3437 margin-bottom: 2px;
3438 }
3439 .repo-empty-option-title {
3440 font-family: var(--font-display);
3441 font-weight: 700;
3442 font-size: 16px;
3443 letter-spacing: -0.01em;
3444 color: var(--text-strong);
3445 margin: 0;
3446 }
3447 .repo-empty-option-sub {
3448 font-size: 13px;
3449 color: var(--text-muted);
3450 line-height: 1.5;
3451 margin: 0;
3452 flex: 1;
3453 }
3454 .repo-empty-option-snippet {
3455 background: var(--bg);
3456 border: 1px solid var(--border);
3457 border-radius: 8px;
3458 padding: var(--space-2) var(--space-3);
3459 font-family: var(--font-mono);
3460 font-size: 11.5px;
3461 line-height: 1.75;
3462 color: var(--text-strong);
3463 overflow-x: auto;
3464 white-space: pre;
3465 margin: var(--space-1) 0 0;
3466 }
3467 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3468 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3469 .repo-empty-option-cta {
3470 display: inline-flex;
3471 align-items: center;
3472 gap: 6px;
3473 margin-top: auto;
3474 padding-top: var(--space-2);
3475 font-size: 13px;
3476 font-weight: 600;
3477 color: var(--accent);
3478 text-decoration: none;
3479 transition: color 120ms ease;
3480 }
3481 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3482
544d842Claude3483 @media (max-width: 720px) {
3484 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3485 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3486 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3487 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3488 .repo-home-side-val { max-width: 55%; }
3489 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3490 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3491 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3492 }
ebaae0fClaude3493
3494 /* AI stats strip */
3495 .repo-ai-stats-strip {
3496 display: flex;
3497 align-items: center;
3498 gap: 6px;
3499 margin-top: 12px;
3500 margin-bottom: 4px;
3501 font-size: 12px;
3502 color: var(--text-muted);
3503 flex-wrap: wrap;
3504 }
3505 .repo-ai-stats-strip a {
3506 color: var(--text-muted);
3507 text-decoration: underline;
3508 text-decoration-color: transparent;
3509 transition: color 120ms ease, text-decoration-color 120ms ease;
3510 }
3511 .repo-ai-stats-strip a:hover {
3512 color: var(--accent);
3513 text-decoration-color: currentColor;
3514 }
3515 .repo-ai-stats-sep {
3516 opacity: 0.4;
3517 user-select: none;
3518 }
544d842Claude3519 `;
3520 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3521 // SSH URL — port-aware:
3522 // Standard port 22 → git@host:owner/repo.git
3523 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3524 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3525 try {
3526 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3527 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3528 ? host
3529 : "localhost";
3530 const sshPort = config.sshPort;
3531 if (sshPort === 22) {
3532 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3533 } else {
3534 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3535 }
3536 } catch {
3537 // Fall through to default.
3538 }
3539 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3540 const formatRelative = (date: Date | null): string => {
3541 if (!date) return "never";
3542 const ms = Date.now() - date.getTime();
3543 const s = Math.max(0, Math.round(ms / 1000));
3544 if (s < 60) return "just now";
3545 const m = Math.round(s / 60);
3546 if (m < 60) return `${m} min ago`;
3547 const h = Math.round(m / 60);
3548 if (h < 24) return `${h}h ago`;
3549 const d = Math.round(h / 24);
3550 if (d < 30) return `${d}d ago`;
3551 const mo = Math.round(d / 30);
3552 if (mo < 12) return `${mo}mo ago`;
3553 const y = Math.round(d / 365);
3554 return `${y}y ago`;
3555 };
06d5ffeClaude3556
fc1817aClaude3557 if (tree.length === 0) {
5acce80Claude3558 const repoOgDesc = description
3559 ? `${owner}/${repo} on Gluecron — ${description}`
3560 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3561 return c.html(
5acce80Claude3562 <Layout
3563 title={`${owner}/${repo}`}
3564 user={user}
3565 description={repoOgDesc}
3566 ogTitle={`${owner}/${repo} — Gluecron`}
3567 ogDescription={repoOgDesc}
3568 twitterCard="summary"
3569 >
544d842Claude3570 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3571 <style dangerouslySetInnerHTML={{ __html: `
3572 .empty-options-grid {
3573 display: grid;
3574 grid-template-columns: repeat(3, 1fr);
3575 gap: var(--space-4);
3576 margin-top: var(--space-4);
3577 }
3578 @media (max-width: 800px) {
3579 .empty-options-grid { grid-template-columns: 1fr; }
3580 }
3581 .empty-option-card {
3582 position: relative;
3583 background: var(--bg-elevated);
3584 border: 1px solid var(--border);
3585 border-radius: 14px;
3586 padding: var(--space-5);
3587 display: flex;
3588 flex-direction: column;
3589 gap: var(--space-3);
3590 overflow: hidden;
3591 transition: border-color 140ms ease, box-shadow 140ms ease;
3592 }
3593 .empty-option-card:hover {
6fd5915Claude3594 border-color: rgba(91,110,232,0.45);
3595 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3596 }
3597 .empty-option-card::before {
3598 content: '';
3599 position: absolute;
3600 top: 0; left: 0; right: 0;
3601 height: 2px;
6fd5915Claude3602 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3603 opacity: 0.55;
3604 pointer-events: none;
3605 }
3606 .empty-option-label {
3607 font-size: 10.5px;
3608 font-family: var(--font-mono);
3609 text-transform: uppercase;
3610 letter-spacing: 0.14em;
3611 color: var(--accent);
3612 font-weight: 700;
3613 }
3614 .empty-option-title {
3615 font-family: var(--font-display);
3616 font-weight: 700;
3617 font-size: 17px;
3618 letter-spacing: -0.01em;
3619 color: var(--text-strong);
3620 margin: 0;
3621 line-height: 1.25;
3622 }
3623 .empty-option-body {
3624 font-size: 13px;
3625 color: var(--text-muted);
3626 line-height: 1.55;
3627 margin: 0;
3628 flex: 1;
3629 }
3630 .empty-option-snippet {
3631 background: var(--bg);
3632 border: 1px solid var(--border);
3633 border-radius: 8px;
3634 padding: var(--space-3) var(--space-4);
3635 font-family: var(--font-mono);
3636 font-size: 11.5px;
3637 line-height: 1.75;
3638 color: var(--text-strong);
3639 overflow-x: auto;
3640 white-space: pre;
3641 margin: 0;
3642 }
3643 .empty-option-snippet .ec { color: var(--text-faint); }
3644 .empty-option-snippet .em { color: var(--accent); }
3645 .empty-option-cta {
3646 display: inline-flex;
3647 align-items: center;
3648 gap: 6px;
3649 padding: 8px 16px;
3650 font-size: 13px;
3651 font-weight: 600;
3652 color: var(--text-strong);
3653 background: var(--bg-secondary);
3654 border: 1px solid var(--border);
3655 border-radius: 9999px;
3656 text-decoration: none;
3657 align-self: flex-start;
3658 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3659 }
3660 .empty-option-cta:hover {
6fd5915Claude3661 border-color: rgba(91,110,232,0.55);
3662 background: rgba(91,110,232,0.08);
91a0204Claude3663 color: var(--accent);
3664 text-decoration: none;
3665 }
3666 .empty-option-cta-accent {
6fd5915Claude3667 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
91a0204Claude3668 border-color: transparent;
3669 color: #fff;
3670 }
3671 .empty-option-cta-accent:hover {
3672 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3673 border-color: transparent;
3674 color: #fff;
6fd5915Claude3675 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.55);
91a0204Claude3676 }
3677 ` }} />
544d842Claude3678 <div class="repo-home-hero">
3679 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3680 <div class="repo-home-hero-orb" />
3681 </div>
3682 <div class="repo-home-hero-inner">
3683 <div class="repo-home-eyebrow">
3684 <strong>Repository</strong> · {owner}
3685 </div>
91a0204Claude3686 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3687 <RepoHeader
3688 owner={owner}
3689 repo={repo}
3690 starCount={starCount}
3691 starred={starred}
3692 forkCount={forkCount}
3693 currentUser={user?.username}
3694 archived={archived}
3695 isTemplate={isTemplate}
8c790e0Claude3696 recentPush={recentPush}
91a0204Claude3697 />
3698 {healthScore && (() => {
3699 const gradeLabel: Record<string, string> = {
3700 elite: "Elite", strong: "Strong",
3701 improving: "Improving", "needs-attention": "Needs Attention",
3702 };
3703 return (
3704 <a
3705 href={`/${owner}/${repo}/insights/health`}
3706 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3707 title={`Health score: ${healthScore!.total}/100`}
3708 >
3709 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3710 </a>
3711 );
3712 })()}
3713 </div>
544d842Claude3714 {description ? (
3715 <p class="repo-home-description">{description}</p>
3716 ) : (
3717 <p class="repo-home-description-empty">
3718 No description yet — push a README to tell the world what this
3719 ships.
3720 </p>
3721 )}
3722 </div>
3723 </div>
fc1817aClaude3724 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3725 <div style="margin-top:var(--space-4)">
3726 <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)">
3727 Getting started
3728 </div>
544d842Claude3729 <h2 class="repo-home-empty-title">
91a0204Claude3730 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3731 </h2>
3732 <p class="repo-home-empty-sub">
91a0204Claude3733 Choose how you want to kick things off. Every push wires up gate
3734 checks and AI review automatically.
544d842Claude3735 </p>
91a0204Claude3736 <div class="empty-options-grid">
3737 <div class="empty-option-card">
3738 <div class="empty-option-label">Option A</div>
3739 <h3 class="empty-option-title">Push your first commit</h3>
3740 <p class="empty-option-body">
3741 Wire up an existing project in seconds. Run these commands from
3742 your project directory.
3743 </p>
3744 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3745<span class="em">git remote add</span> origin {cloneHttpsUrl}
3746<span class="em">git branch</span> -M main
3747<span class="em">git push</span> -u origin main</pre>
3748 </div>
3749 <div class="empty-option-card">
3750 <div class="empty-option-label">Option B</div>
3751 <h3 class="empty-option-title">Import from GitHub</h3>
3752 <p class="empty-option-body">
3753 Mirror an existing GitHub repository here in one click. Gluecron
3754 syncs the full history and branches.
3755 </p>
3756 <a href="/import" class="empty-option-cta">
3757 Import repository
3758 </a>
3759 </div>
3760 <div class="empty-option-card">
3761 <div class="empty-option-label">Option C</div>
3762 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3763 <p class="empty-option-body">
3764 Let AI write your first feature. Describe what you want to build
3765 and Gluecron opens a pull request with the code.
3766 </p>
3767 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3768 Let AI write your first feature
3769 </a>
3770 </div>
3771 </div>
fc1817aClaude3772 </div>
3773 </Layout>
3774 );
3775 }
3776
3777 const readme = await getReadme(owner, repo, defaultBranch);
3778
544d842Claude3779 // Sidebar facts — derived from data we already have.
3780 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3781 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3782
5acce80Claude3783 const repoOgDesc = description
3784 ? `${owner}/${repo} on Gluecron — ${description}`
3785 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3786
fc1817aClaude3787 return c.html(
5acce80Claude3788 <Layout
3789 title={`${owner}/${repo}`}
3790 user={user}
3791 description={repoOgDesc}
3792 ogTitle={`${owner}/${repo} — Gluecron`}
3793 ogDescription={repoOgDesc}
3794 twitterCard="summary"
3795 >
544d842Claude3796 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3797 {/* Repo-context commands for the command palette (Feature 2) */}
3798 <script
3799 id="cmdk-repo-context"
3800 dangerouslySetInnerHTML={{
3801 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3802 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3803 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3804 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3805 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3806 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3807 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3808 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3809 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3810 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3811 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3812 ])};`,
3813 }}
3814 />
544d842Claude3815 <div class="repo-home-hero">
3816 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3817 <div class="repo-home-hero-orb" />
3818 </div>
3819 <div class="repo-home-hero-inner">
3820 <div class="repo-home-eyebrow">
3821 <strong>Repository</strong> · {owner}
3822 </div>
91a0204Claude3823 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3824 <RepoHeader
3825 owner={owner}
3826 repo={repo}
3827 starCount={starCount}
3828 starred={starred}
3829 forkCount={forkCount}
3830 currentUser={user?.username}
3831 archived={archived}
3832 isTemplate={isTemplate}
8c790e0Claude3833 recentPush={recentPush}
91a0204Claude3834 />
3835 {healthScore && (() => {
3836 const gradeLabel: Record<string, string> = {
3837 elite: "Elite", strong: "Strong",
3838 improving: "Improving", "needs-attention": "Needs Attention",
3839 };
3840 return (
3841 <a
3842 href={`/${owner}/${repo}/insights/health`}
3843 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3844 title={`Health score: ${healthScore!.total}/100`}
3845 >
3846 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3847 </a>
3848 );
3849 })()}
3850 </div>
544d842Claude3851 {description ? (
3852 <p class="repo-home-description">{description}</p>
3853 ) : (
3854 <p class="repo-home-description-empty">
3855 No description yet.
3856 </p>
3857 )}
3858 <div class="repo-home-stat-row" aria-label="Repository stats">
3859 <span class="repo-home-stat" title="Default branch">
3860 <span class="repo-home-stat-icon">{"⎇"}</span>
3861 <strong>{defaultBranch}</strong>
3862 </span>
3863 <a
3864 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3865 class="repo-home-stat"
3866 title="Browse all branches"
3867 >
3868 <span class="repo-home-stat-icon">{"⊢"}</span>
3869 <strong>{branches.length}</strong>{" "}
3870 branch{branches.length === 1 ? "" : "es"}
3871 </a>
3872 <span class="repo-home-stat" title="Top-level entries">
3873 <span class="repo-home-stat-icon">{"■"}</span>
3874 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3875 {dirCount > 0 && (
3876 <>
3877 {" · "}
3878 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3879 </>
3880 )}
3881 </span>
3882 {pushedAt && (
3883 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3884 <span class="repo-home-stat-icon">{"↻"}</span>
3885 Updated <strong>{formatRelative(pushedAt)}</strong>
3886 </span>
3887 )}
3888 </div>
3889 </div>
3890 </div>
71cd5ecClaude3891 {isTemplate && user && user.username !== owner && (
3892 <div
3893 class="panel"
dc26881CC LABS App3894 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3895 >
3896 <div style="font-size:13px">
3897 <strong>Template repository.</strong> Create a new repository from
3898 this template's files.
3899 </div>
3900 <form
001af43Claude3901 method="post"
71cd5ecClaude3902 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3903 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3904 >
3905 <input
3906 type="text"
3907 name="name"
3908 placeholder="new-repo-name"
3909 required
2c3ba6ecopilot-swe-agent[bot]3910 aria-label="New repository name"
71cd5ecClaude3911 style="width:200px"
3912 />
3913 <button type="submit" class="btn btn-primary">
3914 Use this template
3915 </button>
3916 </form>
3917 </div>
3918 )}
fc1817aClaude3919 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3920 <RepoHomePendingBanner
3921 owner={owner}
3922 repo={repo}
3923 count={repoHomePendingCount}
3924 />
641aa42Claude3925 {showOnboarding && onboardingRow && (
3926 <RepoOnboardingCard
3927 owner={owner}
3928 repo={repo}
3929 data={onboardingRow}
3930 />
3931 )}
c6018a5Claude3932 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3933 row sits just below the nav as a slim CTA strip. Scoped under
3934 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3935 <style
3936 dangerouslySetInnerHTML={{
3937 __html: `
3938 .repo-ai-cta-row {
3939 display: flex;
3940 flex-wrap: wrap;
3941 gap: 8px;
3942 margin: 12px 0 18px;
3943 padding: 10px 14px;
6fd5915Claude3944 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude3945 border: 1px solid var(--border);
3946 border-radius: 10px;
3947 position: relative;
3948 overflow: hidden;
3949 }
3950 .repo-ai-cta-row::before {
3951 content: '';
3952 position: absolute;
3953 top: 0; left: 0; right: 0;
3954 height: 1px;
6fd5915Claude3955 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude3956 opacity: 0.7;
3957 pointer-events: none;
3958 }
3959 .repo-ai-cta-label {
3960 font-size: 11px;
3961 font-weight: 600;
3962 letter-spacing: 0.06em;
3963 text-transform: uppercase;
3964 color: var(--accent);
3965 align-self: center;
3966 padding-right: 8px;
3967 border-right: 1px solid var(--border);
3968 margin-right: 4px;
3969 }
3970 .repo-ai-cta {
3971 display: inline-flex;
3972 align-items: center;
3973 gap: 6px;
3974 padding: 5px 10px;
3975 font-size: 12.5px;
3976 font-weight: 500;
3977 color: var(--text);
3978 background: var(--bg);
3979 border: 1px solid var(--border);
3980 border-radius: 7px;
3981 text-decoration: none;
3982 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3983 }
3984 .repo-ai-cta:hover {
6fd5915Claude3985 border-color: rgba(91,110,232,0.45);
c6018a5Claude3986 color: var(--text-strong);
6fd5915Claude3987 background: rgba(91,110,232,0.06);
c6018a5Claude3988 text-decoration: none;
3989 }
3990 .repo-ai-cta-icon {
3991 opacity: 0.75;
3992 font-size: 12px;
3993 }
3994 @media (max-width: 640px) {
3995 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3996 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3997 }
3998 `,
3999 }}
4000 />
4001 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4002 <span class="repo-ai-cta-label">AI surfaces</span>
4003 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4004 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4005 Chat
4006 </a>
4007 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4008 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4009 Previews
4010 </a>
4011 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4012 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4013 Migrations
4014 </a>
53c9249Claude4015 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4016 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4017 AI Search
c6018a5Claude4018 </a>
4019 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4020 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4021 AI release notes
4022 </a>
3646bfeClaude4023 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4024 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4025 Dev environment
4026 </a>
c6018a5Claude4027 </nav>
544d842Claude4028 <div class="repo-home-grid">
4029 <div class="repo-home-main">
4030 <BranchSwitcher
4031 owner={owner}
4032 repo={repo}
4033 currentRef={defaultBranch}
4034 branches={branches}
4035 pathType="tree"
4036 />
4037 <FileTable
4038 entries={tree}
4039 owner={owner}
4040 repo={repo}
4041 ref={defaultBranch}
4042 path=""
4043 />
ebaae0fClaude4044 {/* AI stats strip — one-line summary of AI value for this repo */}
4045 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4046 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4047 <span>{"⚡"}</span>
4048 {aiStats.mergedCount > 0 ? (
4049 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4050 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4051 </a>
4052 ) : (
4053 <span>0 AI merges this week</span>
4054 )}
4055 <span class="repo-ai-stats-sep">{"·"}</span>
4056 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4057 <span class="repo-ai-stats-sep">{"·"}</span>
4058 {aiStats.securityAlertCount > 0 ? (
4059 <a href={`/${owner}/${repo}/issues?label=security`}>
4060 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4061 </a>
4062 ) : (
4063 <span>0 open security alerts</span>
4064 )}
4065 </div>
4066 ) : (
4067 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4068 <span>{"⚡"}</span>
4069 <span>AI is watching — no activity this week</span>
4070 </div>
4071 )}
544d842Claude4072 {readme && (() => {
4073 const readmeHtml = renderMarkdown(readme);
4074 return (
4075 <div class="repo-home-readme">
4076 <div class="repo-home-readme-head">
4077 <span class="repo-home-readme-icon">{"☰"}</span>
4078 <span>README.md</span>
4079 </div>
4080 <style>{markdownCss}</style>
4081 <div class="markdown-body repo-home-readme-body">
4082 {html([readmeHtml] as unknown as TemplateStringsArray)}
4083 </div>
4084 </div>
4085 );
4086 })()}
4087 </div>
4088 <aside class="repo-home-side" aria-label="Repository details">
4089 <div class="repo-home-clone">
4090 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4091 <button
4092 type="button"
4093 class="repo-home-clone-tab"
4094 role="tab"
4095 aria-selected="true"
4096 data-pane="https"
4097 data-repo-home-clone-tab
4098 >
4099 HTTPS
4100 </button>
4101 <button
4102 type="button"
4103 class="repo-home-clone-tab"
4104 role="tab"
4105 aria-selected="false"
4106 data-pane="ssh"
4107 data-repo-home-clone-tab
4108 >
4109 SSH
4110 </button>
4111 <button
4112 type="button"
4113 class="repo-home-clone-tab"
4114 role="tab"
4115 aria-selected="false"
4116 data-pane="cli"
4117 data-repo-home-clone-tab
4118 >
4119 CLI
4120 </button>
4121 </div>
4122 <div
4123 class="repo-home-clone-pane"
4124 data-pane="https"
4125 data-active="true"
4126 role="tabpanel"
4127 >
4128 <div class="repo-home-clone-body">
4129 <input
4130 class="repo-home-clone-input"
4131 type="text"
4132 value={cloneHttpsUrl}
4133 readonly
4134 aria-label="HTTPS clone URL"
4135 data-repo-home-clone-input
4136 />
4137 <button
4138 type="button"
4139 class="repo-home-clone-copy"
4140 data-repo-home-copy={cloneHttpsUrl}
4141 >
4142 Copy
4143 </button>
4144 </div>
4145 </div>
4146 <div
4147 class="repo-home-clone-pane"
4148 data-pane="ssh"
4149 data-active="false"
4150 role="tabpanel"
4151 >
4152 <div class="repo-home-clone-body">
4153 <input
4154 class="repo-home-clone-input"
4155 type="text"
4156 value={cloneSshUrl}
4157 readonly
4158 aria-label="SSH clone URL"
4159 data-repo-home-clone-input
4160 />
4161 <button
4162 type="button"
4163 class="repo-home-clone-copy"
4164 data-repo-home-copy={cloneSshUrl}
4165 >
4166 Copy
4167 </button>
4168 </div>
4169 </div>
4170 <div
4171 class="repo-home-clone-pane"
4172 data-pane="cli"
4173 data-active="false"
4174 role="tabpanel"
4175 >
4176 <div class="repo-home-clone-body">
4177 <input
4178 class="repo-home-clone-input"
4179 type="text"
4180 value={cloneCliCmd}
4181 readonly
4182 aria-label="Gluecron CLI clone command"
4183 data-repo-home-clone-input
4184 />
4185 <button
4186 type="button"
4187 class="repo-home-clone-copy"
4188 data-repo-home-copy={cloneCliCmd}
4189 >
4190 Copy
4191 </button>
4192 </div>
79136bbClaude4193 </div>
fc1817aClaude4194 </div>
544d842Claude4195 <div class="repo-home-side-card">
4196 <h3 class="repo-home-side-title">About</h3>
4197 <div class="repo-home-side-row">
4198 <span class="repo-home-side-key">Default branch</span>
4199 <span class="repo-home-side-val">{defaultBranch}</span>
4200 </div>
4201 <div class="repo-home-side-row">
4202 <span class="repo-home-side-key">Branches</span>
4203 <span class="repo-home-side-val">
4204 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4205 {branches.length}
4206 </a>
4207 </span>
4208 </div>
4209 <div class="repo-home-side-row">
4210 <span class="repo-home-side-key">Stars</span>
4211 <span class="repo-home-side-val">{starCount}</span>
4212 </div>
4213 <div class="repo-home-side-row">
4214 <span class="repo-home-side-key">Forks</span>
4215 <span class="repo-home-side-val">{forkCount}</span>
4216 </div>
4217 {pushedAt && (
4218 <div class="repo-home-side-row">
4219 <span class="repo-home-side-key">Last push</span>
4220 <span class="repo-home-side-val">
4221 {formatRelative(pushedAt)}
4222 </span>
4223 </div>
4224 )}
4225 {createdAt && (
4226 <div class="repo-home-side-row">
4227 <span class="repo-home-side-key">Created</span>
4228 <span class="repo-home-side-val">
4229 {formatRelative(createdAt)}
4230 </span>
4231 </div>
4232 )}
4233 {(archived || isTemplate) && (
4234 <div class="repo-home-side-row">
4235 <span class="repo-home-side-key">State</span>
4236 <span class="repo-home-side-val">
4237 {archived ? "Archived" : "Template"}
4238 </span>
4239 </div>
4240 )}
4241 </div>
f1dc38bClaude4242 {/* Claude AI — quick-access card for authenticated users */}
4243 {user && (
4244 <div class="repo-home-side-card" style="margin-top:12px">
4245 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4246 ✨ Claude AI
4247 </h3>
4248 <a
4249 href={`/${owner}/${repo}/claude`}
4250 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"
4251 >
4252 Claude Code sessions
4253 </a>
4254 <a
4255 href={`/${owner}/${repo}/ask`}
4256 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4257 >
4258 Ask AI about this repo
4259 </a>
4260 </div>
4261 )}
544d842Claude4262 </aside>
4263 </div>
4264 <script
4265 dangerouslySetInnerHTML={{
4266 __html: `
4267 (function(){
4268 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4269 tabs.forEach(function(tab){
4270 tab.addEventListener('click', function(){
4271 var target = tab.getAttribute('data-pane');
4272 tabs.forEach(function(t){
4273 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4274 });
4275 var panes = document.querySelectorAll('.repo-home-clone-pane');
4276 panes.forEach(function(p){
4277 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4278 });
4279 });
4280 });
4281 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4282 copyBtns.forEach(function(btn){
4283 btn.addEventListener('click', function(){
4284 var text = btn.getAttribute('data-repo-home-copy') || '';
4285 var done = function(){
4286 var prev = btn.textContent;
4287 btn.textContent = 'Copied';
4288 setTimeout(function(){ btn.textContent = prev; }, 1200);
4289 };
4290 if (navigator.clipboard && navigator.clipboard.writeText) {
4291 navigator.clipboard.writeText(text).then(done, done);
4292 } else {
4293 var ta = document.createElement('textarea');
4294 ta.value = text;
4295 document.body.appendChild(ta);
4296 ta.select();
4297 try { document.execCommand('copy'); } catch (e) {}
4298 document.body.removeChild(ta);
4299 done();
4300 }
4301 });
4302 });
4303 })();
4304 `,
4305 }}
4306 />
fc1817aClaude4307 </Layout>
4308 );
4309});
4310
4311// Browse tree at ref/path
4312web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4313 const { owner, repo } = c.req.param();
06d5ffeClaude4314 const user = c.get("user");
fc1817aClaude4315 const refAndPath = c.req.param("ref");
4316
4317 const branches = await listBranches(owner, repo);
4318 let ref = "";
4319 let treePath = "";
4320
4321 for (const branch of branches) {
4322 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4323 ref = branch;
4324 treePath = refAndPath.slice(branch.length + 1);
4325 break;
4326 }
4327 }
4328
4329 if (!ref) {
4330 const slashIdx = refAndPath.indexOf("/");
4331 if (slashIdx === -1) {
4332 ref = refAndPath;
4333 } else {
4334 ref = refAndPath.slice(0, slashIdx);
4335 treePath = refAndPath.slice(slashIdx + 1);
4336 }
4337 }
4338
4339 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4340 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4341 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4342
4343 return c.html(
06d5ffeClaude4344 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4345 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4346 <RepoHeader owner={owner} repo={repo} />
4347 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4348 <div class="tree-header">
4349 <div class="tree-header-row">
4350 <BranchSwitcher
4351 owner={owner}
4352 repo={repo}
4353 currentRef={ref}
4354 branches={branches}
4355 pathType="tree"
4356 subPath={treePath}
4357 />
4358 <div class="tree-header-stats">
4359 <span class="tree-stat" title="Entries in this directory">
4360 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4361 {dirCount > 0 && (
4362 <>
4363 {" · "}
4364 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4365 </>
4366 )}
4367 </span>
4368 <a
4369 href={`/${owner}/${repo}/search`}
4370 class="tree-stat tree-stat-link"
4371 title="Search code in this repository"
4372 >
4373 {"⌕"} Search
4374 </a>
4375 </div>
4376 </div>
4377 <div class="tree-breadcrumb-row">
4378 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4379 </div>
4380 </div>
fc1817aClaude4381 <FileTable
4382 entries={tree}
4383 owner={owner}
4384 repo={repo}
4385 ref={ref}
4386 path={treePath}
4387 />
4388 </Layout>
4389 );
4390});
4391
06d5ffeClaude4392// View file blob with syntax highlighting
fc1817aClaude4393web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4394 const { owner, repo } = c.req.param();
06d5ffeClaude4395 const user = c.get("user");
fc1817aClaude4396 const refAndPath = c.req.param("ref");
4397
4398 const branches = await listBranches(owner, repo);
4399 let ref = "";
4400 let filePath = "";
4401
4402 for (const branch of branches) {
4403 if (refAndPath.startsWith(branch + "/")) {
4404 ref = branch;
4405 filePath = refAndPath.slice(branch.length + 1);
4406 break;
4407 }
4408 }
4409
4410 if (!ref) {
4411 const slashIdx = refAndPath.indexOf("/");
4412 if (slashIdx === -1) return c.text("Not found", 404);
4413 ref = refAndPath.slice(0, slashIdx);
4414 filePath = refAndPath.slice(slashIdx + 1);
4415 }
4416
4417 const blob = await getBlob(owner, repo, ref, filePath);
4418 if (!blob) {
4419 return c.html(
06d5ffeClaude4420 <Layout title="Not Found" user={user}>
fc1817aClaude4421 <div class="empty-state">
4422 <h2>File not found</h2>
4423 </div>
4424 </Layout>,
4425 404
4426 );
4427 }
4428
06d5ffeClaude4429 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4430 const lineCount = blob.isBinary
4431 ? 0
4432 : (blob.content.endsWith("\n")
4433 ? blob.content.split("\n").length - 1
4434 : blob.content.split("\n").length);
4435 const formatBytes = (n: number): string => {
4436 if (n < 1024) return `${n} B`;
4437 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4438 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4439 };
fc1817aClaude4440
4441 return c.html(
06d5ffeClaude4442 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4443 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4444 <RepoHeader owner={owner} repo={repo} />
4445 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4446 <div class="blob-toolbar">
4447 <BranchSwitcher
4448 owner={owner}
4449 repo={repo}
4450 currentRef={ref}
4451 branches={branches}
4452 pathType="blob"
4453 subPath={filePath}
4454 />
4455 <div class="blob-breadcrumb">
4456 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4457 </div>
4458 </div>
4459 <div class="blob-view blob-card">
4460 <div class="blob-header blob-header-polished">
4461 <div class="blob-header-meta">
4462 <span class="blob-header-icon" aria-hidden="true">
4463 {"📄"}
4464 </span>
4465 <span class="blob-header-name">{fileName}</span>
4466 <span class="blob-header-size">
4467 {formatBytes(blob.size)}
4468 {!blob.isBinary && (
4469 <>
4470 {" · "}
4471 {lineCount} line{lineCount === 1 ? "" : "s"}
4472 </>
4473 )}
4474 </span>
4475 </div>
4476 <div class="blob-header-actions">
4477 <a
4478 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4479 class="blob-pill"
4480 >
79136bbClaude4481 Raw
4482 </a>
efb11c5Claude4483 <a
4484 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4485 class="blob-pill"
4486 >
79136bbClaude4487 Blame
4488 </a>
efb11c5Claude4489 <a
4490 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4491 class="blob-pill"
4492 >
16b325cClaude4493 History
4494 </a>
0074234Claude4495 {user && (
efb11c5Claude4496 <a
4497 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4498 class="blob-pill blob-pill-accent"
4499 >
0074234Claude4500 Edit
4501 </a>
4502 )}
efb11c5Claude4503 </div>
fc1817aClaude4504 </div>
4505 {blob.isBinary ? (
efb11c5Claude4506 <div class="blob-binary">
fc1817aClaude4507 Binary file not shown.
4508 </div>
06d5ffeClaude4509 ) : (() => {
4510 const { html: highlighted, language } = highlightCode(
4511 blob.content,
4512 fileName
4513 );
4514 if (language) {
4515 return (
4516 <HighlightedCode
4517 highlightedHtml={highlighted}
efb11c5Claude4518 lineCount={lineCount}
06d5ffeClaude4519 />
4520 );
4521 }
4522 const lines = blob.content.split("\n");
4523 if (lines[lines.length - 1] === "") lines.pop();
4524 return <PlainCode lines={lines} />;
4525 })()}
fc1817aClaude4526 </div>
4527 </Layout>
4528 );
4529});
4530
398a10cClaude4531// ─── Branches list ────────────────────────────────────────────────────────
4532// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4533// author + relative time + ahead/behind vs the default branch. No DB. All
4534// data comes from git plumbing; failures degrade gracefully (counts omitted).
4535web.get("/:owner/:repo/branches", async (c) => {
4536 const { owner, repo } = c.req.param();
4537 const user = c.get("user");
4538
4539 if (!(await repoExists(owner, repo))) return c.notFound();
4540
4541 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4542 const branches = await listBranches(owner, repo);
4543 const repoDir = getRepoPath(owner, repo);
4544
4545 type BranchRow = {
4546 name: string;
4547 isDefault: boolean;
4548 sha: string;
4549 subject: string;
4550 author: string;
4551 date: string;
4552 ahead: number;
4553 behind: number;
4554 };
4555
4556 const runGit = async (args: string[]): Promise<string> => {
4557 try {
4558 const proc = Bun.spawn(["git", ...args], {
4559 cwd: repoDir,
4560 stdout: "pipe",
4561 stderr: "pipe",
4562 });
4563 const out = await new Response(proc.stdout).text();
4564 await proc.exited;
4565 return out;
4566 } catch {
4567 return "";
4568 }
4569 };
4570
4571 const meta = await runGit([
4572 "for-each-ref",
4573 "--sort=-committerdate",
4574 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4575 "refs/heads/",
4576 ]);
4577 const metaByName: Record<
4578 string,
4579 { sha: string; subject: string; author: string; date: string }
4580 > = {};
4581 for (const line of meta.split("\n").filter(Boolean)) {
4582 const [name, sha, subject, author, date] = line.split("\0");
4583 metaByName[name] = { sha, subject, author, date };
4584 }
4585
4586 const branchOrder = [...branches].sort((a, b) => {
4587 if (a === defaultBranch) return -1;
4588 if (b === defaultBranch) return 1;
4589 const aDate = metaByName[a]?.date || "";
4590 const bDate = metaByName[b]?.date || "";
4591 return bDate.localeCompare(aDate);
4592 });
4593
4594 const rows: BranchRow[] = [];
4595 for (const name of branchOrder) {
4596 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4597 let ahead = 0;
4598 let behind = 0;
4599 if (name !== defaultBranch && metaByName[defaultBranch]) {
4600 const out = await runGit([
4601 "rev-list",
4602 "--left-right",
4603 "--count",
4604 `${defaultBranch}...${name}`,
4605 ]);
4606 const parts = out.trim().split(/\s+/);
4607 if (parts.length === 2) {
4608 behind = parseInt(parts[0], 10) || 0;
4609 ahead = parseInt(parts[1], 10) || 0;
4610 }
4611 }
4612 rows.push({
4613 name,
4614 isDefault: name === defaultBranch,
4615 sha: m.sha,
4616 subject: m.subject,
4617 author: m.author,
4618 date: m.date,
4619 ahead,
4620 behind,
4621 });
4622 }
4623
4624 const relative = (iso: string): string => {
4625 if (!iso) return "—";
4626 const d = new Date(iso);
4627 if (Number.isNaN(d.getTime())) return "—";
4628 const diff = Date.now() - d.getTime();
4629 const m = Math.floor(diff / 60000);
4630 if (m < 1) return "just now";
4631 if (m < 60) return `${m} min ago`;
4632 const h = Math.floor(m / 60);
4633 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4634 const dd = Math.floor(h / 24);
4635 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4636 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4637 };
4638
4639 const success = c.req.query("success");
4640 const error = c.req.query("error");
4641
4642 return c.html(
4643 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4644 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4645 <RepoHeader owner={owner} repo={repo} />
4646 <RepoNav owner={owner} repo={repo} active="code" />
4647 <div
4648 class="branches-wrap"
eed4684Claude4649 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4650 >
4651 <header class="branches-head" style="margin-bottom:var(--space-5)">
4652 <div
4653 class="branches-eyebrow"
4654 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"
4655 >
4656 <span
4657 class="branches-eyebrow-dot"
4658 aria-hidden="true"
6fd5915Claude4659 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)"
398a10cClaude4660 />
4661 Repository · Branches
4662 </div>
4663 <h1
4664 class="branches-title"
4665 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)"
4666 >
4667 <span class="gradient-text">{rows.length}</span>{" "}
4668 branch{rows.length === 1 ? "" : "es"}
4669 </h1>
4670 <p
4671 class="branches-sub"
4672 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4673 >
4674 All work-in-progress lines for{" "}
4675 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4676 counts are relative to{" "}
4677 <code style="font-size:12.5px">{defaultBranch}</code>.
4678 </p>
4679 </header>
4680
4681 {success && (
4682 <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">
4683 {decodeURIComponent(success)}
4684 </div>
4685 )}
4686 {error && (
4687 <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">
4688 {decodeURIComponent(error)}
4689 </div>
4690 )}
4691
4692 {rows.length === 0 ? (
4693 <div class="commits-empty">
4694 <div class="commits-empty-orb" aria-hidden="true" />
4695 <div class="commits-empty-inner">
4696 <div class="commits-empty-icon" aria-hidden="true">
4697 <svg
4698 width="22"
4699 height="22"
4700 viewBox="0 0 24 24"
4701 fill="none"
4702 stroke="currentColor"
4703 stroke-width="2"
4704 stroke-linecap="round"
4705 stroke-linejoin="round"
4706 >
4707 <line x1="6" y1="3" x2="6" y2="15" />
4708 <circle cx="18" cy="6" r="3" />
4709 <circle cx="6" cy="18" r="3" />
4710 <path d="M18 9a9 9 0 0 1-9 9" />
4711 </svg>
4712 </div>
4713 <h3 class="commits-empty-title">No branches yet</h3>
4714 <p class="commits-empty-sub">
4715 Push your first commit to create the default branch.
4716 </p>
4717 </div>
4718 </div>
4719 ) : (
4720 <div class="branches-list">
4721 {rows.map((r) => (
4722 <div class="branches-row">
4723 <div class="branches-row-icon" aria-hidden="true">
4724 <svg
4725 width="14"
4726 height="14"
4727 viewBox="0 0 24 24"
4728 fill="none"
4729 stroke="currentColor"
4730 stroke-width="2"
4731 stroke-linecap="round"
4732 stroke-linejoin="round"
4733 >
4734 <line x1="6" y1="3" x2="6" y2="15" />
4735 <circle cx="18" cy="6" r="3" />
4736 <circle cx="6" cy="18" r="3" />
4737 <path d="M18 9a9 9 0 0 1-9 9" />
4738 </svg>
4739 </div>
4740 <div class="branches-row-main">
4741 <div class="branches-row-name">
4742 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4743 {r.isDefault && (
4744 <span
4745 class="branches-row-default"
4746 title="Default branch"
4747 >
4748 Default
4749 </span>
4750 )}
4751 </div>
4752 <div class="branches-row-meta">
4753 {r.subject ? (
4754 <>
4755 <strong>{r.author || "—"}</strong>
4756 <span class="sep">·</span>
4757 <span
4758 title={r.date ? new Date(r.date).toISOString() : ""}
4759 >
4760 updated {relative(r.date)}
4761 </span>
4762 {r.sha && (
4763 <>
4764 <span class="sep">·</span>
4765 <a
4766 href={`/${owner}/${repo}/commit/${r.sha}`}
4767 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4768 >
4769 {r.sha.slice(0, 7)}
4770 </a>
4771 </>
4772 )}
4773 </>
4774 ) : (
4775 <span>No commit metadata</span>
4776 )}
4777 </div>
4778 </div>
4779 <div class="branches-row-side">
4780 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4781 <span
4782 class="branches-row-divergence"
4783 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4784 >
4785 <span class="ahead">{r.ahead} ahead</span>
4786 <span style="opacity:0.4">|</span>
4787 <span class="behind">{r.behind} behind</span>
4788 </span>
4789 )}
4790 <div class="branches-row-actions">
4791 <a
4792 href={`/${owner}/${repo}/commits/${r.name}`}
4793 class="branches-btn"
4794 title="View commits on this branch"
4795 >
4796 Commits
4797 </a>
4798 {!r.isDefault &&
4799 user &&
4800 user.username === owner && (
4801 <form
4802 method="post"
4803 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4804 style="margin:0"
4805 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4806 >
4807 <button
4808 type="submit"
4809 class="branches-btn branches-btn-danger"
4810 >
4811 Delete
4812 </button>
4813 </form>
4814 )}
4815 </div>
4816 </div>
4817 </div>
4818 ))}
4819 </div>
4820 )}
4821 </div>
4822 </Layout>
4823 );
4824});
4825
4826// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4827// that are not merged into the default branch — matches the explicit
4828// confirmation on the row's delete button.
4829web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4830 const { owner, repo } = c.req.param();
4831 const branchName = decodeURIComponent(c.req.param("name"));
4832 const user = c.get("user")!;
4833
4834 // Owner-only check (mirrors collaborators.tsx pattern).
4835 const [ownerRow] = await db
4836 .select()
4837 .from(users)
4838 .where(eq(users.username, owner))
4839 .limit(1);
4840 if (!ownerRow || ownerRow.id !== user.id) {
4841 return c.redirect(
4842 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4843 );
4844 }
4845
4846 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4847 if (branchName === defaultBranch) {
4848 return c.redirect(
4849 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4850 );
4851 }
4852
4853 const branches = await listBranches(owner, repo);
4854 if (!branches.includes(branchName)) {
4855 return c.redirect(
4856 `/${owner}/${repo}/branches?error=Branch+not+found`
4857 );
4858 }
4859
4860 try {
4861 const repoDir = getRepoPath(owner, repo);
4862 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4863 cwd: repoDir,
4864 stdout: "pipe",
4865 stderr: "pipe",
4866 });
4867 await proc.exited;
4868 if (proc.exitCode !== 0) {
4869 return c.redirect(
4870 `/${owner}/${repo}/branches?error=Delete+failed`
4871 );
4872 }
4873 } catch {
4874 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4875 }
4876
4877 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4878});
4879
4880// ─── Tags list ────────────────────────────────────────────────────────────
4881// Pulls from `listTags` (sorted newest first). For each tag we look up an
4882// associated release row so the "View release" CTA can deep-link directly
4883// without making the user hunt for it.
4884web.get("/:owner/:repo/tags", async (c) => {
4885 const { owner, repo } = c.req.param();
4886 const user = c.get("user");
4887
4888 if (!(await repoExists(owner, repo))) return c.notFound();
4889
4890 const tags = await listTags(owner, repo);
4891
4892 // Map tags -> releases. Best-effort; releases table may not exist in
4893 // every test setup, so any error falls through with an empty set.
4894 const tagsWithReleases = new Set<string>();
4895 try {
4896 const [ownerRow] = await db
4897 .select()
4898 .from(users)
4899 .where(eq(users.username, owner))
4900 .limit(1);
4901 if (ownerRow) {
4902 const [repoRow] = await db
4903 .select()
4904 .from(repositories)
4905 .where(
4906 and(
4907 eq(repositories.ownerId, ownerRow.id),
4908 eq(repositories.name, repo)
4909 )
4910 )
4911 .limit(1);
4912 if (repoRow) {
4913 // Raw SQL so we don't need to import the releases schema here.
4914 const result = await db.execute(
4915 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4916 );
4917 const rows: any[] = (result as any).rows || (result as any) || [];
4918 for (const row of rows) {
4919 const tag = row?.tag;
4920 if (typeof tag === "string") tagsWithReleases.add(tag);
4921 }
4922 }
4923 }
4924 } catch {
4925 // No releases table or DB error — leave set empty.
4926 }
4927
4928 const relative = (iso: string): string => {
4929 if (!iso) return "—";
4930 const d = new Date(iso);
4931 if (Number.isNaN(d.getTime())) return "—";
4932 const diff = Date.now() - d.getTime();
4933 const m = Math.floor(diff / 60000);
4934 if (m < 1) return "just now";
4935 if (m < 60) return `${m} min ago`;
4936 const h = Math.floor(m / 60);
4937 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4938 const dd = Math.floor(h / 24);
4939 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4940 return d.toLocaleDateString("en-US", {
4941 month: "short",
4942 day: "numeric",
4943 year: "numeric",
4944 });
4945 };
4946
4947 return c.html(
4948 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4949 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4950 <RepoHeader owner={owner} repo={repo} />
4951 <RepoNav owner={owner} repo={repo} active="code" />
4952 <div
4953 class="tags-wrap"
eed4684Claude4954 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4955 >
4956 <header class="tags-head" style="margin-bottom:var(--space-5)">
4957 <div
4958 class="tags-eyebrow"
4959 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"
4960 >
4961 <span
4962 class="tags-eyebrow-dot"
4963 aria-hidden="true"
6fd5915Claude4964 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)"
398a10cClaude4965 />
4966 Repository · Tags
4967 </div>
4968 <h1
4969 class="tags-title"
4970 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)"
4971 >
4972 <span class="gradient-text">{tags.length}</span>{" "}
4973 tag{tags.length === 1 ? "" : "s"}
4974 </h1>
4975 <p
4976 class="tags-sub"
4977 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4978 >
4979 Named points in the history — typically releases, milestones,
4980 or shipped versions. Click a tag to browse the tree at that
4981 revision.
4982 </p>
4983 </header>
4984
4985 {tags.length === 0 ? (
4986 <div class="commits-empty">
4987 <div class="commits-empty-orb" aria-hidden="true" />
4988 <div class="commits-empty-inner">
4989 <div class="commits-empty-icon" aria-hidden="true">
4990 <svg
4991 width="22"
4992 height="22"
4993 viewBox="0 0 24 24"
4994 fill="none"
4995 stroke="currentColor"
4996 stroke-width="2"
4997 stroke-linecap="round"
4998 stroke-linejoin="round"
4999 >
5000 <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" />
5001 <line x1="7" y1="7" x2="7.01" y2="7" />
5002 </svg>
5003 </div>
5004 <h3 class="commits-empty-title">No tags yet</h3>
5005 <p class="commits-empty-sub">
5006 Tag a commit to mark a release or milestone. From the CLI:{" "}
5007 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5008 </p>
5009 </div>
5010 </div>
5011 ) : (
5012 <div class="tags-list">
5013 {tags.map((t) => {
5014 const hasRelease = tagsWithReleases.has(t.name);
5015 return (
5016 <div class="tags-row">
5017 <div class="tags-row-icon" aria-hidden="true">
5018 <svg
5019 width="14"
5020 height="14"
5021 viewBox="0 0 24 24"
5022 fill="none"
5023 stroke="currentColor"
5024 stroke-width="2"
5025 stroke-linecap="round"
5026 stroke-linejoin="round"
5027 >
5028 <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" />
5029 <line x1="7" y1="7" x2="7.01" y2="7" />
5030 </svg>
5031 </div>
5032 <div class="tags-row-main">
5033 <div class="tags-row-name">
5034 <a
5035 href={`/${owner}/${repo}/tree/${t.name}`}
5036 style="text-decoration:none"
5037 >
5038 <span class="tags-row-version">{t.name}</span>
5039 </a>
5040 </div>
5041 <div class="tags-row-meta">
5042 <span
5043 title={t.date ? new Date(t.date).toISOString() : ""}
5044 >
5045 Tagged {relative(t.date)}
5046 </span>
5047 <span class="sep">·</span>
5048 <a
5049 href={`/${owner}/${repo}/commit/${t.sha}`}
5050 class="tags-row-sha"
5051 title={t.sha}
5052 >
5053 {t.sha.slice(0, 7)}
5054 </a>
5055 </div>
5056 </div>
5057 <div class="tags-row-side">
5058 <a
5059 href={`/${owner}/${repo}/tree/${t.name}`}
5060 class="tags-row-link"
5061 >
5062 Browse files
5063 </a>
5064 {hasRelease && (
5065 <a
5066 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5067 class="tags-row-link"
6fd5915Claude5068 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5069 >
5070 View release
5071 </a>
5072 )}
5073 </div>
5074 </div>
5075 );
5076 })}
5077 </div>
5078 )}
5079 </div>
5080 </Layout>
5081 );
5082});
5083
fc1817aClaude5084// Commit log
5085web.get("/:owner/:repo/commits/:ref?", async (c) => {
5086 const { owner, repo } = c.req.param();
06d5ffeClaude5087 const user = c.get("user");
fc1817aClaude5088 const ref =
5089 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5090 const branches = await listBranches(owner, repo);
fc1817aClaude5091
5092 const commits = await listCommits(owner, repo, ref, 50);
5093
3951454Claude5094 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5095 // Also resolve repoId here so we can query the recent push for the
5096 // Push Watch live/watch indicator in the header.
3951454Claude5097 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5098 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5099 try {
5100 const [ownerRow] = await db
5101 .select()
5102 .from(users)
5103 .where(eq(users.username, owner))
5104 .limit(1);
5105 if (ownerRow) {
5106 const [repoRow] = await db
5107 .select()
5108 .from(repositories)
5109 .where(
5110 and(
5111 eq(repositories.ownerId, ownerRow.id),
5112 eq(repositories.name, repo)
5113 )
5114 )
5115 .limit(1);
8c790e0Claude5116 if (repoRow) {
5117 // Fetch verifications and recent push in parallel.
5118 const [verRows, rp] = await Promise.all([
5119 commits.length > 0
5120 ? db
5121 .select()
5122 .from(commitVerifications)
5123 .where(
5124 and(
5125 eq(commitVerifications.repositoryId, repoRow.id),
5126 inArray(
5127 commitVerifications.commitSha,
5128 commits.map((c) => c.sha)
5129 )
5130 )
5131 )
5132 : Promise.resolve([]),
5133 getRecentPush(repoRow.id),
5134 ]);
5135 for (const r of verRows) {
3951454Claude5136 verifications[r.commitSha] = {
5137 verified: r.verified,
5138 reason: r.reason,
5139 };
5140 }
8c790e0Claude5141 commitsPageRecentPush = rp;
3951454Claude5142 }
5143 }
5144 } catch {
5145 // DB unavailable — skip the badges gracefully.
5146 }
5147
fc1817aClaude5148 return c.html(
06d5ffeClaude5149 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5150 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5151 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5152 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5153 <div class="commits-hero">
5154 <div class="commits-hero-orb-wrap" aria-hidden="true">
5155 <div class="commits-hero-orb" />
fc1817aClaude5156 </div>
efb11c5Claude5157 <div class="commits-hero-inner">
5158 <div class="commits-eyebrow">
5159 <strong>History</strong> · {owner}/{repo}
5160 </div>
5161 <h1 class="commits-title">
5162 <span class="gradient-text">{commits.length}</span> recent commit
5163 {commits.length === 1 ? "" : "s"} on{" "}
5164 <span class="commits-branch">{ref}</span>
5165 </h1>
5166 <p class="commits-sub">
5167 Browse the project's history. Click any commit to see the full
5168 diff, AI review notes, and signature status.
5169 </p>
5170 </div>
5171 </div>
5172 <div class="commits-toolbar">
5173 <BranchSwitcher
3951454Claude5174 owner={owner}
5175 repo={repo}
efb11c5Claude5176 currentRef={ref}
5177 branches={branches}
5178 pathType="commits"
3951454Claude5179 />
398a10cClaude5180 <div class="commits-toolbar-actions">
5181 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5182 {"⊢"} Branches
5183 </a>
5184 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5185 {"#"} Tags
5186 </a>
5187 </div>
efb11c5Claude5188 </div>
5189 {commits.length === 0 ? (
5190 <div class="commits-empty">
398a10cClaude5191 <div class="commits-empty-orb" aria-hidden="true" />
5192 <div class="commits-empty-inner">
5193 <div class="commits-empty-icon" aria-hidden="true">
5194 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5195 <circle cx="12" cy="12" r="4" />
5196 <line x1="1.05" y1="12" x2="7" y2="12" />
5197 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5198 </svg>
5199 </div>
5200 <h3 class="commits-empty-title">No commits yet</h3>
5201 <p class="commits-empty-sub">
5202 This branch is empty. Push your first commit, or use the
5203 web editor to create a file.
5204 </p>
5205 </div>
efb11c5Claude5206 </div>
5207 ) : (
5208 <div class="commits-list-wrap">
398a10cClaude5209 {(() => {
5210 // Group commits by day for the section headers.
5211 const dayLabel = (iso: string): string => {
5212 const d = new Date(iso);
5213 if (Number.isNaN(d.getTime())) return "Unknown";
5214 const today = new Date();
5215 const yesterday = new Date(today);
5216 yesterday.setDate(today.getDate() - 1);
5217 const sameDay = (a: Date, b: Date) =>
5218 a.getFullYear() === b.getFullYear() &&
5219 a.getMonth() === b.getMonth() &&
5220 a.getDate() === b.getDate();
5221 if (sameDay(d, today)) return "Today";
5222 if (sameDay(d, yesterday)) return "Yesterday";
5223 return d.toLocaleDateString("en-US", {
5224 month: "long",
5225 day: "numeric",
5226 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5227 });
5228 };
5229 const relative = (iso: string): string => {
5230 const d = new Date(iso);
5231 if (Number.isNaN(d.getTime())) return "";
5232 const diff = Date.now() - d.getTime();
5233 const m = Math.floor(diff / 60000);
5234 if (m < 1) return "just now";
5235 if (m < 60) return `${m} min ago`;
5236 const h = Math.floor(m / 60);
5237 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5238 const dd = Math.floor(h / 24);
5239 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5240 return d.toLocaleDateString("en-US", {
5241 month: "short",
5242 day: "numeric",
5243 });
5244 };
5245 const initial = (name: string): string =>
5246 (name || "?").trim().charAt(0).toUpperCase() || "?";
5247 // Build groups preserving order.
5248 const groups: Array<{ label: string; items: typeof commits }> = [];
5249 let lastLabel = "";
5250 for (const cm of commits) {
5251 const label = dayLabel(cm.date);
5252 if (label !== lastLabel) {
5253 groups.push({ label, items: [] });
5254 lastLabel = label;
5255 }
5256 groups[groups.length - 1].items.push(cm);
5257 }
5258 return groups.map((g) => (
5259 <>
5260 <div class="commits-day-head">
5261 <span class="commits-day-head-dot" aria-hidden="true" />
5262 Commits on {g.label}
5263 </div>
5264 {g.items.map((cm) => {
5265 const v = verifications[cm.sha];
5266 return (
5267 <div class="commits-row">
5268 <div class="commits-avatar" aria-hidden="true">
5269 {initial(cm.author)}
5270 </div>
5271 <div class="commits-row-body">
5272 <div class="commits-row-msg">
5273 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5274 {cm.message}
5275 </a>
5276 {v?.verified && (
5277 <span
5278 class="commits-row-verified"
5279 title="Signed with a registered key"
5280 >
5281 Verified
5282 </span>
5283 )}
5284 </div>
5285 <div class="commits-row-meta">
5286 <strong>{cm.author}</strong>
5287 <span class="sep">·</span>
5288 <span
5289 class="commits-row-time"
5290 title={new Date(cm.date).toISOString()}
5291 >
5292 committed {relative(cm.date)}
5293 </span>
5294 {cm.parentShas.length > 1 && (
5295 <>
5296 <span class="sep">·</span>
5297 <span>merge of {cm.parentShas.length} parents</span>
5298 </>
5299 )}
5300 </div>
5301 </div>
5302 <div class="commits-row-side">
5303 <a
5304 href={`/${owner}/${repo}/commit/${cm.sha}`}
5305 class="commits-row-sha"
5306 title={cm.sha}
5307 >
5308 {cm.sha.slice(0, 7)}
5309 </a>
8c790e0Claude5310 <a
5311 href={`/${owner}/${repo}/push/${cm.sha}`}
5312 class="commits-row-watch"
5313 title="Watch gate + deploy results for this push"
5314 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5315 >
5316 <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">
5317 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5318 <circle cx="12" cy="12" r="3" />
5319 </svg>
5320 </a>
398a10cClaude5321 <button
5322 type="button"
5323 class="commits-row-copy"
5324 data-copy-sha={cm.sha}
5325 title="Copy full SHA"
5326 aria-label={`Copy SHA ${cm.sha}`}
5327 >
5328 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5329 <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" />
5330 <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" />
5331 </svg>
5332 </button>
5333 </div>
5334 </div>
5335 );
5336 })}
5337 </>
5338 ));
5339 })()}
efb11c5Claude5340 </div>
fc1817aClaude5341 )}
398a10cClaude5342 <script
5343 dangerouslySetInnerHTML={{
5344 __html: `
5345 (function(){
5346 document.addEventListener('click', function(e){
5347 var t = e.target; if (!t) return;
5348 var btn = t.closest && t.closest('[data-copy-sha]');
5349 if (!btn) return;
5350 e.preventDefault();
5351 var sha = btn.getAttribute('data-copy-sha') || '';
5352 if (!navigator.clipboard) return;
5353 navigator.clipboard.writeText(sha).then(function(){
5354 btn.classList.add('is-copied');
5355 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5356 }).catch(function(){});
5357 });
5358 })();
5359 `,
5360 }}
5361 />
fc1817aClaude5362 </Layout>
5363 );
5364});
5365
5366// Single commit with diff
5367web.get("/:owner/:repo/commit/:sha", async (c) => {
5368 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5369 const user = c.get("user");
fc1817aClaude5370
05b973eClaude5371 // Fetch commit, full message, and diff in parallel
5372 const [commit, fullMessage, diffResult] = await Promise.all([
5373 getCommit(owner, repo, sha),
5374 getCommitFullMessage(owner, repo, sha),
5375 getDiff(owner, repo, sha),
5376 ]);
fc1817aClaude5377 if (!commit) {
5378 return c.html(
06d5ffeClaude5379 <Layout title="Not Found" user={user}>
fc1817aClaude5380 <div class="empty-state">
5381 <h2>Commit not found</h2>
5382 </div>
5383 </Layout>,
5384 404
5385 );
5386 }
5387
3951454Claude5388 // Block J3 — try to verify this commit's signature.
5389 let verification:
5390 | { verified: boolean; reason: string; signatureType: string | null }
5391 | null = null;
0cdfd89Claude5392 // Block J8 — external CI commit statuses rollup.
5393 let statusCombined:
5394 | {
5395 state: "pending" | "success" | "failure";
5396 total: number;
5397 contexts: Array<{
5398 context: string;
5399 state: string;
5400 description: string | null;
5401 targetUrl: string | null;
5402 }>;
5403 }
5404 | null = null;
3951454Claude5405 try {
5406 const [ownerRow] = await db
5407 .select()
5408 .from(users)
5409 .where(eq(users.username, owner))
5410 .limit(1);
5411 if (ownerRow) {
5412 const [repoRow] = await db
5413 .select()
5414 .from(repositories)
5415 .where(
5416 and(
5417 eq(repositories.ownerId, ownerRow.id),
5418 eq(repositories.name, repo)
5419 )
5420 )
5421 .limit(1);
5422 if (repoRow) {
5423 const { verifyCommit } = await import("../lib/signatures");
5424 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5425 verification = {
5426 verified: v.verified,
5427 reason: v.reason,
5428 signatureType: v.signatureType,
5429 };
0cdfd89Claude5430 try {
5431 const { combinedStatus } = await import("../lib/commit-statuses");
5432 const combined = await combinedStatus(repoRow.id, commit.sha);
5433 if (combined.total > 0) {
5434 statusCombined = {
5435 state: combined.state as any,
5436 total: combined.total,
5437 contexts: combined.contexts.map((c) => ({
5438 context: c.context,
5439 state: c.state,
5440 description: c.description,
5441 targetUrl: c.targetUrl,
5442 })),
5443 };
5444 }
5445 } catch {
5446 statusCombined = null;
5447 }
3951454Claude5448 }
5449 }
5450 } catch {
5451 verification = null;
5452 }
5453
05b973eClaude5454 const { files, raw } = diffResult;
fc1817aClaude5455
efb11c5Claude5456 // Diff stats: count additions / deletions across all files for the
5457 // header summary bar. Computed here from the parsed diff so we don't
5458 // touch the DiffView component.
5459 let additions = 0;
5460 let deletions = 0;
5461 for (const f of files) {
5462 const hunks = (f as any).hunks as Array<any> | undefined;
5463 if (Array.isArray(hunks)) {
5464 for (const h of hunks) {
5465 const lines = (h?.lines || []) as Array<any>;
5466 for (const ln of lines) {
5467 const t = ln?.type || ln?.kind;
5468 if (t === "add" || t === "added" || t === "+") additions += 1;
5469 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5470 deletions += 1;
5471 }
5472 }
5473 }
5474 }
5475 // Fall back: scan raw if file-level counting yielded zero (it's just a
5476 // header polish — never let a parsing miss break the page).
5477 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5478 for (const line of raw.split("\n")) {
5479 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5480 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5481 }
5482 }
5483 const fileCount = files.length;
5484
fc1817aClaude5485 return c.html(
06d5ffeClaude5486 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5487 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5488 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5489 <div class="commit-detail-card">
5490 <div class="commit-detail-eyebrow">
5491 <strong>Commit</strong>
5492 <span class="commit-detail-sha-pill" title={commit.sha}>
5493 {commit.sha.slice(0, 7)}
5494 </span>
3951454Claude5495 {verification && verification.reason !== "unsigned" && (
5496 <span
efb11c5Claude5497 class={`commit-detail-verify ${
3951454Claude5498 verification.verified
efb11c5Claude5499 ? "commit-detail-verify-ok"
5500 : "commit-detail-verify-warn"
3951454Claude5501 }`}
5502 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5503 >
5504 {verification.verified ? "Verified" : verification.reason}
5505 </span>
5506 )}
fc1817aClaude5507 </div>
efb11c5Claude5508 <h1 class="commit-detail-title">{commit.message}</h1>
5509 {fullMessage !== commit.message && (
5510 <pre class="commit-detail-body">{fullMessage}</pre>
5511 )}
5512 <div class="commit-detail-meta">
5513 <span class="commit-detail-author">
5514 <strong>{commit.author}</strong> committed on{" "}
5515 {new Date(commit.date).toLocaleDateString("en-US", {
5516 month: "long",
5517 day: "numeric",
5518 year: "numeric",
5519 })}
5520 </span>
fc1817aClaude5521 {commit.parentShas.length > 0 && (
efb11c5Claude5522 <span class="commit-detail-parents">
5523 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5524 {commit.parentShas.map((p, idx) => (
5525 <>
5526 {idx > 0 && " "}
5527 <a
5528 href={`/${owner}/${repo}/commit/${p}`}
5529 class="commit-detail-sha-link"
5530 >
5531 {p.slice(0, 7)}
5532 </a>
5533 </>
fc1817aClaude5534 ))}
5535 </span>
5536 )}
5537 </div>
efb11c5Claude5538 <div class="commit-detail-stats">
5539 <span class="commit-detail-stat">
5540 <strong>{fileCount}</strong>{" "}
5541 file{fileCount === 1 ? "" : "s"} changed
5542 </span>
5543 <span class="commit-detail-stat commit-detail-stat-add">
5544 <span class="commit-detail-stat-mark">+</span>
5545 <strong>{additions}</strong>
5546 </span>
5547 <span class="commit-detail-stat commit-detail-stat-del">
5548 <span class="commit-detail-stat-mark">−</span>
5549 <strong>{deletions}</strong>
5550 </span>
5551 <span class="commit-detail-sha-full" title="Full SHA">
5552 {commit.sha}
5553 </span>
5554 </div>
0cdfd89Claude5555 {statusCombined && (
efb11c5Claude5556 <div class="commit-detail-checks">
5557 <div class="commit-detail-checks-head">
5558 <strong>Checks</strong>
5559 <span class="commit-detail-checks-summary">
5560 {statusCombined.total} total ·{" "}
5561 <span
5562 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5563 >
5564 {statusCombined.state}
5565 </span>
0cdfd89Claude5566 </span>
efb11c5Claude5567 </div>
5568 <div class="commit-detail-check-row">
0cdfd89Claude5569 {statusCombined.contexts.map((cx) => (
5570 <span
efb11c5Claude5571 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5572 title={cx.description || cx.context}
5573 >
5574 {cx.targetUrl ? (
efb11c5Claude5575 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5576 {cx.context}: {cx.state}
5577 </a>
5578 ) : (
5579 <>
5580 {cx.context}: {cx.state}
5581 </>
5582 )}
5583 </span>
5584 ))}
5585 </div>
5586 </div>
5587 )}
fc1817aClaude5588 </div>
ea9ed4cClaude5589 <DiffView
5590 raw={raw}
5591 files={files}
5592 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5593 />
fc1817aClaude5594 </Layout>
5595 );
5596});
5597
79136bbClaude5598// Raw file download
5599web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5600 const { owner, repo } = c.req.param();
5601 const refAndPath = c.req.param("ref");
5602
5603 const branches = await listBranches(owner, repo);
5604 let ref = "";
5605 let filePath = "";
5606
5607 for (const branch of branches) {
5608 if (refAndPath.startsWith(branch + "/")) {
5609 ref = branch;
5610 filePath = refAndPath.slice(branch.length + 1);
5611 break;
5612 }
5613 }
5614
5615 if (!ref) {
5616 const slashIdx = refAndPath.indexOf("/");
5617 if (slashIdx === -1) return c.text("Not found", 404);
5618 ref = refAndPath.slice(0, slashIdx);
5619 filePath = refAndPath.slice(slashIdx + 1);
5620 }
5621
5622 const data = await getRawBlob(owner, repo, ref, filePath);
5623 if (!data) return c.text("Not found", 404);
5624
5625 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5626 return new Response(data as BodyInit, {
79136bbClaude5627 headers: {
5628 "Content-Type": "application/octet-stream",
5629 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5630 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5631 },
5632 });
5633});
5634
5635// Blame view
5636web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5637 const { owner, repo } = c.req.param();
5638 const user = c.get("user");
5639 const refAndPath = c.req.param("ref");
5640
5641 const branches = await listBranches(owner, repo);
5642 let ref = "";
5643 let filePath = "";
5644
5645 for (const branch of branches) {
5646 if (refAndPath.startsWith(branch + "/")) {
5647 ref = branch;
5648 filePath = refAndPath.slice(branch.length + 1);
5649 break;
5650 }
5651 }
5652
5653 if (!ref) {
5654 const slashIdx = refAndPath.indexOf("/");
5655 if (slashIdx === -1) return c.text("Not found", 404);
5656 ref = refAndPath.slice(0, slashIdx);
5657 filePath = refAndPath.slice(slashIdx + 1);
5658 }
5659
5660 const blameLines = await getBlame(owner, repo, ref, filePath);
5661 if (blameLines.length === 0) {
5662 return c.html(
5663 <Layout title="Not Found" user={user}>
5664 <div class="empty-state">
5665 <h2>File not found</h2>
5666 </div>
5667 </Layout>,
5668 404
5669 );
5670 }
5671
5672 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5673 // Unique contributors (by author) tracked once for the header chip.
5674 const blameAuthors = new Set<string>();
5675 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5676
5677 return c.html(
5678 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5679 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5680 <RepoHeader owner={owner} repo={repo} />
5681 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5682 <header class="blame-head">
5683 <div class="blame-eyebrow">
5684 <span class="blame-eyebrow-dot" aria-hidden="true" />
5685 Blame · Line-by-line history
5686 </div>
5687 <h1 class="blame-title">
5688 <code>{fileName}</code>
5689 </h1>
5690 <p class="blame-sub">
5691 Each line is annotated with the commit that last touched it.
5692 Click any SHA to jump to that commit and see the surrounding
5693 change.
5694 </p>
5695 </header>
efb11c5Claude5696 <div class="blame-toolbar">
5697 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5698 </div>
398a10cClaude5699 <div class="blame-card">
5700 <div class="blame-header">
efb11c5Claude5701 <div class="blame-header-meta">
5702 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5703 <span class="blame-header-name">{fileName}</span>
5704 <span class="blame-header-tag">Blame</span>
5705 <span class="blame-header-stats">
5706 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5707 {blameAuthors.size} contributor
5708 {blameAuthors.size === 1 ? "" : "s"}
5709 </span>
5710 </div>
5711 <div class="blame-header-actions">
5712 <a
5713 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5714 class="blob-pill"
5715 >
5716 Normal view
5717 </a>
5718 <a
5719 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5720 class="blob-pill"
5721 >
5722 Raw
5723 </a>
5724 </div>
79136bbClaude5725 </div>
398a10cClaude5726 <div style="overflow-x:auto">
5727 <table class="blame-table">
79136bbClaude5728 <tbody>
5729 {blameLines.map((line, i) => {
5730 const showInfo =
5731 i === 0 || blameLines[i - 1].sha !== line.sha;
5732 return (
398a10cClaude5733 <tr class={showInfo ? "blame-row-first" : ""}>
5734 <td class="blame-gutter">
79136bbClaude5735 {showInfo && (
398a10cClaude5736 <span class="blame-gutter-inner">
79136bbClaude5737 <a
5738 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5739 class="blame-gutter-sha"
5740 title={`Commit ${line.sha}`}
79136bbClaude5741 >
5742 {line.sha.slice(0, 7)}
398a10cClaude5743 </a>
5744 <span class="blame-gutter-author" title={line.author}>
5745 {line.author}
5746 </span>
5747 </span>
79136bbClaude5748 )}
5749 </td>
398a10cClaude5750 <td class="blame-line-num">{line.lineNum}</td>
5751 <td class="blame-line-content">{line.content}</td>
79136bbClaude5752 </tr>
5753 );
5754 })}
5755 </tbody>
5756 </table>
5757 </div>
5758 </div>
5759 </Layout>
5760 );
5761});
5762
53c9249Claude5763// Search — keyword + optional Claude semantic mode
79136bbClaude5764web.get("/:owner/:repo/search", async (c) => {
5765 const { owner, repo } = c.req.param();
5766 const user = c.get("user");
5767 const q = c.req.query("q") || "";
53c9249Claude5768 const aiAvailable = isAiAvailable();
5769 // Default to semantic when Claude is available and no explicit mode set.
5770 const modeParam = c.req.query("mode");
5771 const mode: "semantic" | "keyword" =
5772 modeParam === "keyword"
5773 ? "keyword"
5774 : modeParam === "semantic"
5775 ? "semantic"
5776 : aiAvailable
5777 ? "semantic"
5778 : "keyword";
5779 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5780
5781 if (!(await repoExists(owner, repo))) return c.notFound();
5782
5783 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5784
5785 // Keyword results (always available as fallback / when in keyword mode)
5786 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5787 // Semantic results (Claude-powered)
5788 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5789 let semanticHits: SemanticHit[] = [];
5790 let semanticMode: "semantic" | "keyword" = "semantic";
5791 let quotaExceeded = false;
79136bbClaude5792
5793 if (q.trim()) {
53c9249Claude5794 if (isSemantic) {
5795 // Resolve repo DB id for caching
5796 const [ownerRow] = await db
5797 .select({ id: users.id })
5798 .from(users)
5799 .where(eq(users.username, owner))
5800 .limit(1);
5801 const [repoRow] = ownerRow
5802 ? await db
5803 .select({ id: repositories.id })
5804 .from(repositories)
5805 .where(
5806 and(
5807 eq(repositories.ownerId, ownerRow.id),
5808 eq(repositories.name, repo)
5809 )
5810 )
5811 .limit(1)
5812 : [];
5813
5814 const rateLimitKey = user
5815 ? `user:${user.id}`
5816 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5817
5818 const searchResult = await claudeSemanticSearch(
5819 owner,
5820 repo,
5821 repoRow?.id ?? `${owner}/${repo}`,
5822 q.trim(),
5823 { branch: defaultBranch, rateLimitKey }
5824 );
5825 semanticHits = searchResult.results;
5826 semanticMode = searchResult.mode;
5827 quotaExceeded = searchResult.quotaExceeded;
5828 } else {
5829 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5830 }
79136bbClaude5831 }
5832
53c9249Claude5833 const aiSearchCss = `
5834 /* ─── Semantic search mode toggle ─── */
5835 .search-mode-bar {
5836 display: flex;
5837 align-items: center;
5838 gap: 8px;
5839 margin-bottom: 14px;
5840 flex-wrap: wrap;
5841 }
5842 .search-mode-label {
5843 font-size: 12.5px;
5844 color: var(--text-muted);
5845 font-weight: 500;
5846 }
5847 .search-mode-toggle {
5848 display: inline-flex;
5849 background: var(--bg-elevated);
5850 border: 1px solid var(--border);
5851 border-radius: 9999px;
5852 padding: 3px;
5853 gap: 2px;
5854 }
5855 .search-mode-btn {
5856 display: inline-flex;
5857 align-items: center;
5858 gap: 5px;
5859 padding: 5px 12px;
5860 border-radius: 9999px;
5861 font-size: 12.5px;
5862 font-weight: 500;
5863 color: var(--text-muted);
5864 text-decoration: none;
5865 transition: color 120ms ease, background 120ms ease;
5866 cursor: pointer;
5867 border: none;
5868 background: none;
5869 font: inherit;
5870 }
5871 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5872 .search-mode-btn.active {
6fd5915Claude5873 background: rgba(91,110,232,0.15);
53c9249Claude5874 color: var(--text-strong);
5875 }
5876 .search-mode-ai-badge {
5877 display: inline-flex;
5878 align-items: center;
5879 gap: 4px;
5880 padding: 2px 8px;
5881 border-radius: 9999px;
5882 font-size: 11px;
5883 font-weight: 600;
6fd5915Claude5884 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5885 color: #c4b5fd;
6fd5915Claude5886 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5887 margin-left: 2px;
5888 }
5889 .search-quota-warn {
5890 font-size: 12.5px;
5891 color: var(--text-muted);
5892 padding: 6px 12px;
5893 background: rgba(255,200,0,0.06);
5894 border: 1px solid rgba(255,200,0,0.20);
5895 border-radius: 8px;
5896 }
5897
5898 /* ─── Semantic result cards ─── */
5899 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5900 .sem-result {
5901 padding: 14px 16px;
5902 background: var(--bg-elevated);
5903 border: 1px solid var(--border);
5904 border-radius: 12px;
5905 transition: border-color 120ms ease;
5906 }
5907 .sem-result:hover { border-color: var(--border-strong); }
5908 .sem-result-head {
5909 display: flex;
5910 align-items: flex-start;
5911 justify-content: space-between;
5912 gap: 10px;
5913 flex-wrap: wrap;
5914 margin-bottom: 6px;
5915 }
5916 .sem-result-path {
5917 font-family: var(--font-mono);
5918 font-size: 13px;
5919 font-weight: 600;
5920 color: var(--text-strong);
5921 text-decoration: none;
5922 word-break: break-all;
5923 }
5924 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5925 .sem-result-conf {
5926 display: inline-flex;
5927 align-items: center;
5928 gap: 4px;
5929 padding: 2px 9px;
5930 border-radius: 9999px;
5931 font-size: 11.5px;
5932 font-weight: 600;
5933 white-space: nowrap;
5934 flex-shrink: 0;
5935 }
5936 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
6fd5915Claude5937 .sem-conf-possible { background: rgba(91,110,232,0.12); color: #5b6ee8; border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude5938 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5939 .sem-result-reason {
5940 font-size: 12.5px;
5941 color: var(--text-muted);
5942 margin-bottom: 8px;
5943 line-height: 1.5;
5944 }
5945 .sem-result-snippet {
5946 margin: 0;
5947 padding: 10px 12px;
5948 background: rgba(0,0,0,0.22);
5949 border: 1px solid var(--border);
5950 border-radius: 8px;
5951 font-family: var(--font-mono);
5952 font-size: 12px;
5953 line-height: 1.55;
5954 color: var(--text);
5955 overflow-x: auto;
5956 white-space: pre-wrap;
5957 word-break: break-word;
5958 max-height: 240px;
5959 overflow-y: auto;
5960 }
5961 `;
5962
5963 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5964 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5965
79136bbClaude5966 return c.html(
5967 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude5968 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude5969 <RepoHeader owner={owner} repo={repo} />
5970 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5971 <div class="search-hero">
5972 <div class="search-eyebrow">
5973 <strong>Search</strong> · {owner}/{repo}
5974 </div>
5975 <h1 class="search-title">
53c9249Claude5976 {isSemantic ? (
5977 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5978 ) : (
5979 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5980 )}
efb11c5Claude5981 </h1>
5982 <form
5983 method="get"
5984 action={`/${owner}/${repo}/search`}
5985 class="search-form"
5986 role="search"
5987 >
53c9249Claude5988 <input type="hidden" name="mode" value={mode} />
efb11c5Claude5989 <div class="search-input-wrap">
5990 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5991 <input
5992 type="text"
5993 name="q"
5994 value={q}
53c9249Claude5995 placeholder={
5996 isSemantic
5997 ? "Ask a question or describe what you're looking for…"
5998 : "Search code on the default branch…"
5999 }
efb11c5Claude6000 aria-label="Search code"
6001 class="search-input"
6002 autocomplete="off"
6003 autofocus
6004 />
6005 </div>
6006 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6007 Search
6008 </button>
efb11c5Claude6009 </form>
6010 </div>
53c9249Claude6011
6012 {/* Mode toggle */}
6013 <div class="search-mode-bar">
6014 <span class="search-mode-label">Mode:</span>
6015 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6016 {aiAvailable ? (
6017 <a
6018 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6019 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6020 aria-pressed={isSemantic ? "true" : "false"}
6021 >
6022 {"✨"} Semantic AI
6023 <span class="search-mode-ai-badge">AI-powered</span>
6024 </a>
6025 ) : null}
6026 <a
6027 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6028 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6029 aria-pressed={!isSemantic ? "true" : "false"}
6030 >
6031 {"⌕"} Keyword
6032 </a>
6033 </div>
6034 {isSemantic && aiAvailable && (
6035 <span style="font-size:12px;color:var(--text-muted)">
6036 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6037 </span>
53c9249Claude6038 )}
6039 {quotaExceeded && (
6040 <span class="search-quota-warn">
6041 Semantic search quota reached — showing keyword results
efb11c5Claude6042 </span>
53c9249Claude6043 )}
6044 </div>
6045
6046 {/* ─── Semantic results ─── */}
6047 {isSemantic && q && (
6048 <>
6049 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6050 <div class="search-quota-warn" style="margin-bottom:10px">
6051 Falling back to keyword search — Claude found no relevant files.
6052 </div>
6053 )}
6054 {semanticHits.length === 0 ? (
6055 <div class="search-empty">
6056 <p>
6057 No matches for <strong>"{q}"</strong>.{" "}
6058 Try a different phrasing or{" "}
6059 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6060 switch to keyword search
6061 </a>.
6062 </p>
6063 </div>
6064 ) : (
6065 <>
6066 <div class="search-results-head">
6067 <span class="search-results-count">
6068 <strong>{semanticHits.length}</strong> result
6069 {semanticHits.length !== 1 ? "s" : ""}
6070 </span>
6071 <span class="search-results-query">
6072 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6073 <span class="search-results-q">"{q}"</span>
6074 </span>
6075 </div>
6076 <div class="sem-results">
6077 {semanticHits.map((h) => {
6078 const confClass =
6079 h.confidence > 0.7
6080 ? "sem-conf-strong"
6081 : h.confidence > 0.4
6082 ? "sem-conf-possible"
6083 : "sem-conf-weak";
6084 const confLabel =
6085 h.confidence > 0.7
6086 ? "Strong match"
6087 : h.confidence > 0.4
6088 ? "Possible match"
6089 : "Weak match";
6090 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6091 const preview =
6092 h.snippet.length > 800
6093 ? h.snippet.slice(0, 800) + "\n…"
6094 : h.snippet;
6095 return (
6096 <div class="sem-result">
6097 <div class="sem-result-head">
6098 <a href={href} class="sem-result-path">
6099 {h.file}
6100 {h.lineNumber ? (
6101 <span style="color:var(--text-muted);font-weight:500">
6102 :{h.lineNumber}
6103 </span>
6104 ) : null}
6105 </a>
6106 <span class={`sem-result-conf ${confClass}`}>
6107 {confLabel}
6108 </span>
6109 </div>
6110 {h.reason && (
6111 <p class="sem-result-reason">{h.reason}</p>
6112 )}
6113 {preview && (
6114 <pre class="sem-result-snippet">{preview}</pre>
6115 )}
6116 </div>
6117 );
6118 })}
6119 </div>
6120 </>
6121 )}
6122 </>
79136bbClaude6123 )}
53c9249Claude6124
6125 {/* ─── Keyword results ─── */}
6126 {!isSemantic && q && (
6127 <>
6128 <div class="search-results-head">
6129 <span class="search-results-count">
6130 <strong>{keywordResults.length}</strong> result
6131 {keywordResults.length !== 1 ? "s" : ""}
6132 </span>
6133 <span class="search-results-query">
6134 for <span class="search-results-q">"{q}"</span> on{" "}
6135 <code>{defaultBranch}</code>
6136 </span>
6137 </div>
6138 {keywordResults.length === 0 ? (
6139 <div class="search-empty">
6140 <p>
6141 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6142 {aiAvailable && (
6143 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6144 try AI semantic search
79136bbClaude6145 </a>
53c9249Claude6146 )}.
6147 </p>
6148 </div>
6149 ) : (
6150 <div class="search-results">
6151 {(() => {
6152 const grouped: Record<
6153 string,
6154 Array<{ lineNum: number; line: string }>
6155 > = {};
6156 for (const r of keywordResults) {
6157 if (!grouped[r.file]) grouped[r.file] = [];
6158 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6159 }
6160 return Object.entries(grouped).map(([file, matches]) => (
6161 <div class="search-file diff-file">
6162 <div class="search-file-head diff-file-header">
6163 <a
6164 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6165 class="search-file-link"
6166 >
6167 {file}
6168 </a>
6169 <span class="search-file-count">
6170 {matches.length} match{matches.length === 1 ? "" : "es"}
6171 </span>
6172 </div>
6173 <div class="blob-code">
6174 <table>
6175 <tbody>
6176 {matches.map((m) => (
6177 <tr>
6178 <td class="line-num">{m.lineNum}</td>
6179 <td class="line-content">{m.line}</td>
6180 </tr>
6181 ))}
6182 </tbody>
6183 </table>
6184 </div>
6185 </div>
6186 ));
6187 })()}
6188 </div>
6189 )}
6190 </>
6191 )}
79136bbClaude6192 </Layout>
6193 );
6194});
6195
fc1817aClaude6196export default web;