Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

web.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

web.tsxBlame5401 lines · 3 contributors
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
79136bbClaude7import { html } from "hono/html";
ebaae0fClaude8import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
06d5ffeClaude9import { db } from "../db";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
8c790e0Claude16 activityFeed,
ebaae0fClaude17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
3951454Claude22} from "../db/schema";
fc1817aClaude23import { Layout } from "../views/layout";
cb5a796Claude24import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude25import {
26 RepoHeader,
27 RepoNav,
28 Breadcrumb,
29 FileTable,
06d5ffeClaude30 RepoCard,
31 BranchSwitcher,
32 HighlightedCode,
33 PlainCode,
8c790e0Claude34 type RecentPush,
fc1817aClaude35} from "../views/components";
ea9ed4cClaude36import { DiffView } from "../views/diff-view";
fc1817aClaude37import {
38 getTree,
39 getBlob,
40 listCommits,
41 getCommit,
42 getCommitFullMessage,
43 getDiff,
44 getReadme,
45 getDefaultBranch,
46 listBranches,
398a10cClaude47 listTags,
fc1817aClaude48 repoExists,
06d5ffeClaude49 initBareRepo,
79136bbClaude50 getBlame,
51 getRawBlob,
52 searchCode,
398a10cClaude53 getRepoPath,
fc1817aClaude54} from "../git/repository";
79136bbClaude55import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude56import { highlightCode } from "../lib/highlight";
91a0204Claude57import { computeHealthScore } from "../lib/health-score";
58import type { HealthScore } from "../lib/health-score";
06d5ffeClaude59import { softAuth, requireAuth } from "../middleware/auth";
60import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude61import { trackByName } from "../lib/traffic";
534f04aClaude62import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude63import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude64import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude65import {
66 listQueuedAiBuildIssues,
67 listRecentAutoMerges,
68 listRecentAiReviews,
69 countAiReviewsSince,
70 listDemoActivityFeed,
71} from "../lib/demo-activity";
fc1817aClaude72
06d5ffeClaude73const web = new Hono<AuthEnv>();
74
75// Soft auth on all web routes — c.get("user") available but may be null
76web.use("*", softAuth);
fc1817aClaude77
ebaae0fClaude78// ---------------------------------------------------------------------------
79// Repo AI stats — computed once per repo home page load, best-effort.
80// Fast because every WHERE clause is bounded to a 7-day window.
81// ---------------------------------------------------------------------------
82interface RepoAiStats {
83 mergedCount: number;
84 reviewCount: number;
85 securityAlertCount: number;
86 hoursSaved: string;
87}
88
89async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
90 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
91
92 // 1. AI-merged PRs this week: activity_feed entries with action =
93 // 'auto_merge.merged' in the last 7 days for this repo.
94 // This is cheaper than a JOIN on pull_requests and covers both the
95 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
96 const [mergedRow] = await db
97 .select({ n: count() })
98 .from(activityFeed)
99 .where(
100 and(
101 eq(activityFeed.repositoryId, repoId),
102 eq(activityFeed.action, "auto_merge.merged"),
103 gte(activityFeed.createdAt, since)
104 )
105 );
106 const mergedCount = Number(mergedRow?.n ?? 0);
107
108 // 2. AI reviews this week: pr_comments where is_ai_review = true in
109 // the last 7 days, joined through pull_requests to scope to this repo.
110 const [reviewRow] = await db
111 .select({ n: count() })
112 .from(prComments)
113 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
114 .where(
115 and(
116 eq(pullRequests.repositoryId, repoId),
117 eq(prComments.isAiReview, true),
118 gte(prComments.createdAt, since)
119 )
120 );
121 const reviewCount = Number(reviewRow?.n ?? 0);
122
123 // 3. Open security alerts: open issues that carry a label whose name
124 // contains 'security' (case-insensitive) for this repo.
125 const [secRow] = await db
126 .select({ n: count() })
127 .from(issues)
128 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
129 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
130 .where(
131 and(
132 eq(issues.repositoryId, repoId),
133 eq(issues.state, "open"),
134 sql`lower(${labels.name}) like '%security%'`
135 )
136 );
137 const securityAlertCount = Number(secRow?.n ?? 0);
138
139 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
140 const hours = mergedCount * 1.5 + reviewCount * 0.5;
141 const hoursSaved = hours.toFixed(1);
142
143 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
144}
145
8c790e0Claude146/**
147 * Query the most recent push to a repo from the activity_feed table.
148 * Returns a RecentPush if there's been a push in the last 24 hours,
149 * or null otherwise. Used by the RepoHeader live/watch indicator.
150 *
151 * Queries activity_feed where action='push' and targetId holds the commit SHA.
152 */
153async function getRecentPush(repoId: string): Promise<RecentPush | null> {
154 try {
155 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
156 const [row] = await db
157 .select({
158 targetId: activityFeed.targetId,
159 createdAt: activityFeed.createdAt,
160 })
161 .from(activityFeed)
162 .where(
163 and(
164 eq(activityFeed.repositoryId, repoId),
165 eq(activityFeed.action, "push"),
166 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
167 )
168 )
169 .orderBy(desc(activityFeed.createdAt))
170 .limit(1);
171 if (!row || !row.targetId) return null;
172 return {
173 sha: row.targetId,
174 ageMs: Date.now() - new Date(row.createdAt).getTime(),
175 };
176 } catch {
177 // DB unavailable or schema mismatch — degrade gracefully.
178 return null;
179 }
180}
181
efb11c5Claude182/**
183 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
184 *
185 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
186 * scope is route-local and `layout.tsx` is locked. Each polished handler
187 * injects this via a `<style>` tag; the rules are namespaced by surface
188 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
189 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
190 * `.repo-home-*` styling Agent A already shipped.
191 */
192const codeBrowseCss = `
193 /* ───────── shared primitives ───────── */
194 .cb-hairline::before,
195 .new-repo-hero::before,
196 .profile-hero::before,
197 .commits-hero::before,
198 .commit-detail-card::before,
199 .search-hero::before {
200 content: '';
201 position: absolute;
202 top: 0; left: 0; right: 0;
203 height: 2px;
204 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
205 opacity: 0.7;
206 pointer-events: none;
207 }
208 @keyframes cbHeroOrb {
209 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
210 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
211 }
212 @media (prefers-reduced-motion: reduce) {
213 .new-repo-hero-orb,
214 .profile-hero-orb,
215 .commits-hero-orb { animation: none; }
216 }
217
218 /* ───────── new-repo ───────── */
219 .new-repo-hero {
220 position: relative;
221 margin-bottom: var(--space-5);
222 padding: var(--space-5) var(--space-6);
223 background: var(--bg-elevated);
224 border: 1px solid var(--border);
225 border-radius: 16px;
226 overflow: hidden;
227 }
228 .new-repo-hero-orb-wrap {
229 position: absolute;
230 inset: -25% -10% auto auto;
231 width: 360px;
232 height: 360px;
233 pointer-events: none;
234 z-index: 0;
235 }
236 .new-repo-hero-orb {
237 position: absolute;
238 inset: 0;
239 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
240 filter: blur(80px);
241 opacity: 0.7;
242 animation: cbHeroOrb 14s ease-in-out infinite;
243 }
244 .new-repo-hero-inner { position: relative; z-index: 1; }
245 .new-repo-eyebrow {
246 font-size: 12px;
247 font-family: var(--font-mono);
248 color: var(--text-muted);
249 letter-spacing: 0.1em;
250 text-transform: uppercase;
251 margin-bottom: var(--space-2);
252 }
253 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
254 .new-repo-title {
255 font-family: var(--font-display);
256 font-weight: 800;
257 letter-spacing: -0.028em;
258 font-size: clamp(28px, 4vw, 40px);
259 line-height: 1.05;
260 margin: 0 0 var(--space-2);
261 color: var(--text-strong);
262 }
263 .new-repo-sub {
264 font-size: 15px;
265 color: var(--text-muted);
266 margin: 0;
267 line-height: 1.55;
268 max-width: 620px;
269 }
270 .new-repo-form {
271 max-width: 680px;
272 }
273 .new-repo-error {
274 background: rgba(218, 54, 51, 0.12);
275 border: 1px solid rgba(218, 54, 51, 0.35);
276 color: #ffb3b3;
277 padding: 10px 14px;
278 border-radius: 10px;
279 margin-bottom: var(--space-4);
280 font-size: 14px;
281 }
282 .new-repo-form-grid {
283 display: flex;
284 flex-direction: column;
285 gap: var(--space-4);
286 }
287 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
288 .new-repo-label {
289 font-size: 13px;
290 color: var(--text-strong);
291 font-weight: 600;
292 }
293 .new-repo-label-optional {
294 color: var(--text-muted);
295 font-weight: 400;
296 font-size: 12px;
297 }
298 .new-repo-input {
299 appearance: none;
300 width: 100%;
301 background: var(--bg);
302 border: 1px solid var(--border);
303 color: var(--text-strong);
304 border-radius: 10px;
305 padding: 10px 12px;
306 font-size: 14px;
307 font-family: inherit;
308 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
309 }
310 .new-repo-input:focus {
311 outline: none;
312 border-color: var(--accent);
313 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
314 }
315 .new-repo-input-disabled {
316 color: var(--text-muted);
317 background: var(--bg-secondary);
318 cursor: not-allowed;
319 }
320 .new-repo-hint {
321 font-size: 12px;
322 color: var(--text-muted);
323 margin: 4px 0 0;
324 }
325 .new-repo-hint code {
326 font-family: var(--font-mono);
327 font-size: 11.5px;
328 background: var(--bg-secondary);
329 border: 1px solid var(--border);
330 border-radius: 5px;
331 padding: 1px 5px;
332 color: var(--text-strong);
333 }
334 .new-repo-visibility {
335 display: grid;
336 grid-template-columns: 1fr 1fr;
337 gap: var(--space-2);
338 }
339 @media (max-width: 600px) {
340 .new-repo-visibility { grid-template-columns: 1fr; }
341 }
342 .new-repo-vis-card {
343 display: flex;
344 gap: 10px;
345 padding: 14px;
346 background: var(--bg);
347 border: 1px solid var(--border);
348 border-radius: 12px;
349 cursor: pointer;
350 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
351 }
352 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
353 .new-repo-vis-card:has(input:checked) {
354 border-color: rgba(140,109,255,0.55);
355 background: rgba(140,109,255,0.06);
356 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
357 }
358 .new-repo-vis-radio {
359 margin-top: 3px;
360 accent-color: var(--accent);
361 }
362 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
363 .new-repo-vis-label {
364 font-size: 14px;
365 font-weight: 600;
366 color: var(--text-strong);
367 }
368 .new-repo-vis-desc {
369 font-size: 12.5px;
370 color: var(--text-muted);
371 line-height: 1.45;
372 }
373 .new-repo-callout {
374 margin-top: var(--space-2);
375 padding: var(--space-3) var(--space-4);
376 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
377 border: 1px solid rgba(140,109,255,0.2);
378 border-radius: 12px;
379 }
380 .new-repo-callout-eyebrow {
381 font-size: 11px;
382 font-family: var(--font-mono);
383 text-transform: uppercase;
384 letter-spacing: 0.12em;
385 color: var(--accent);
386 font-weight: 700;
387 margin-bottom: 4px;
388 }
389 .new-repo-callout-body {
390 font-size: 13px;
391 color: var(--text);
392 line-height: 1.5;
393 margin: 0;
394 }
395 .new-repo-callout-body code {
396 font-family: var(--font-mono);
397 font-size: 12px;
398 color: var(--accent);
399 background: rgba(140,109,255,0.1);
400 border-radius: 4px;
401 padding: 1px 5px;
402 }
398a10cClaude403 .new-repo-templates {
404 display: flex;
405 flex-wrap: wrap;
406 gap: 8px;
407 margin-top: 4px;
408 }
409 .new-repo-template-chip {
410 position: relative;
411 display: inline-flex;
412 align-items: center;
413 gap: 6px;
414 padding: 8px 14px;
415 background: var(--bg);
416 border: 1px solid var(--border);
417 border-radius: 999px;
418 font-size: 13px;
419 color: var(--text);
420 cursor: pointer;
421 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
422 }
423 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
424 .new-repo-template-chip:hover {
425 border-color: var(--border-strong, var(--border));
426 color: var(--text-strong);
427 }
428 .new-repo-template-chip:has(input:checked) {
429 border-color: rgba(140,109,255,0.55);
430 background: rgba(140,109,255,0.10);
431 color: var(--text-strong);
432 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
433 }
434 .new-repo-template-chip-dot {
435 width: 8px; height: 8px;
436 border-radius: 999px;
437 background: var(--text-faint, var(--text-muted));
438 transition: background 140ms ease, box-shadow 140ms ease;
439 }
440 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
441 background: linear-gradient(135deg, #8c6dff, #36c5d6);
442 box-shadow: 0 0 8px rgba(140,109,255,0.6);
443 }
efb11c5Claude444 .new-repo-actions {
445 display: flex;
446 gap: var(--space-2);
447 margin-top: var(--space-2);
398a10cClaude448 align-items: center;
449 }
450 .new-repo-submit {
451 min-width: 180px;
452 border: 1px solid rgba(140,109,255,0.45);
453 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
454 color: #fff;
455 font-weight: 700;
456 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
457 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
458 }
459 .new-repo-submit:hover {
460 transform: translateY(-1px);
461 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
462 filter: brightness(1.06);
463 }
464 .new-repo-submit:focus-visible {
465 outline: 3px solid rgba(140,109,255,0.45);
466 outline-offset: 2px;
efb11c5Claude467 }
468
469 /* ───────── profile ───────── */
470 .profile-hero {
471 position: relative;
472 margin-bottom: var(--space-5);
473 padding: var(--space-5) var(--space-6);
474 background: var(--bg-elevated);
475 border: 1px solid var(--border);
476 border-radius: 16px;
477 overflow: hidden;
478 }
479 .profile-hero-orb-wrap {
480 position: absolute;
481 inset: -25% -10% auto auto;
482 width: 360px;
483 height: 360px;
484 pointer-events: none;
485 z-index: 0;
486 }
487 .profile-hero-orb {
488 position: absolute;
489 inset: 0;
490 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
491 filter: blur(80px);
492 opacity: 0.7;
493 animation: cbHeroOrb 14s ease-in-out infinite;
494 }
495 .profile-hero-inner {
496 position: relative;
497 z-index: 1;
498 display: flex;
499 align-items: flex-start;
500 gap: var(--space-5);
501 }
502 .profile-hero-avatar {
503 flex: 0 0 auto;
504 width: 88px;
505 height: 88px;
506 border-radius: 50%;
507 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
508 color: #fff;
509 display: flex;
510 align-items: center;
511 justify-content: center;
512 font-size: 38px;
513 font-weight: 700;
514 font-family: var(--font-display);
515 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
516 }
517 .profile-hero-text { flex: 1; min-width: 0; }
518 .profile-eyebrow {
519 font-size: 12px;
520 font-family: var(--font-mono);
521 color: var(--text-muted);
522 letter-spacing: 0.1em;
523 text-transform: uppercase;
524 margin-bottom: var(--space-2);
525 }
526 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
527 .profile-name {
528 font-family: var(--font-display);
529 font-weight: 800;
530 letter-spacing: -0.028em;
531 font-size: clamp(28px, 3.6vw, 36px);
532 line-height: 1.05;
533 margin: 0 0 4px;
534 color: var(--text-strong);
535 }
536 .profile-handle {
537 font-family: var(--font-mono);
538 font-size: 13px;
539 color: var(--text-muted);
540 margin-bottom: var(--space-2);
541 }
542 .profile-bio {
543 font-size: 14.5px;
544 color: var(--text);
545 line-height: 1.55;
546 margin: 0 0 var(--space-3);
547 max-width: 640px;
548 }
549 .profile-meta {
550 display: flex;
551 align-items: center;
552 gap: var(--space-4);
553 flex-wrap: wrap;
554 font-size: 13px;
555 }
556 .profile-meta-link {
557 color: var(--text-muted);
558 transition: color var(--t-fast, 0.15s) ease;
559 }
560 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
561 .profile-meta-link strong {
562 color: var(--text-strong);
563 font-weight: 600;
564 font-variant-numeric: tabular-nums;
565 }
566 .profile-follow-form { margin: 0; }
567 @media (max-width: 600px) {
568 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
569 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
570 }
571 .profile-readme {
572 margin-bottom: var(--space-6);
573 background: var(--bg-elevated);
574 border: 1px solid var(--border);
575 border-radius: 12px;
576 overflow: hidden;
577 }
578 .profile-readme-head {
579 display: flex;
580 align-items: center;
581 gap: 8px;
582 padding: 10px 16px;
583 background: var(--bg-secondary);
584 border-bottom: 1px solid var(--border);
585 font-size: 13px;
586 color: var(--text-muted);
587 }
588 .profile-readme-icon { color: var(--accent); font-size: 14px; }
589 .profile-readme-body { padding: var(--space-5) var(--space-6); }
590 .profile-section-head {
591 display: flex;
592 align-items: baseline;
593 gap: var(--space-2);
594 margin-bottom: var(--space-3);
595 }
596 .profile-section-title {
597 font-family: var(--font-display);
598 font-weight: 700;
599 font-size: 20px;
600 letter-spacing: -0.015em;
601 margin: 0;
602 color: var(--text-strong);
603 }
604 .profile-section-count {
605 font-family: var(--font-mono);
606 font-size: 12px;
607 color: var(--text-muted);
608 background: var(--bg-secondary);
609 border: 1px solid var(--border);
610 border-radius: 999px;
611 padding: 2px 10px;
612 font-variant-numeric: tabular-nums;
613 }
614 .profile-empty {
615 display: flex;
616 align-items: center;
617 justify-content: space-between;
618 gap: var(--space-3);
619 padding: var(--space-4) var(--space-5);
620 background: var(--bg-elevated);
621 border: 1px dashed var(--border);
622 border-radius: 12px;
623 }
624 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
625
626 /* ───────── tree (file browser) ───────── */
627 .tree-header {
628 margin-bottom: var(--space-3);
629 display: flex;
630 flex-direction: column;
631 gap: var(--space-2);
632 }
633 .tree-header-row {
634 display: flex;
635 align-items: center;
636 justify-content: space-between;
637 gap: var(--space-3);
638 flex-wrap: wrap;
639 }
640 .tree-header-stats {
641 display: flex;
642 align-items: center;
643 gap: var(--space-3);
644 font-size: 12px;
645 color: var(--text-muted);
646 }
647 .tree-stat strong {
648 color: var(--text-strong);
649 font-weight: 600;
650 font-variant-numeric: tabular-nums;
651 }
652 .tree-stat-link {
653 color: var(--text-muted);
654 padding: 4px 10px;
655 border-radius: 999px;
656 border: 1px solid var(--border);
657 background: var(--bg-elevated);
658 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
659 }
660 .tree-stat-link:hover {
661 color: var(--accent);
662 border-color: rgba(140,109,255,0.45);
663 text-decoration: none;
664 }
665 .tree-breadcrumb-row {
666 font-size: 13px;
667 }
668
669 /* ───────── blob (file viewer) ───────── */
670 .blob-toolbar {
671 margin-bottom: var(--space-3);
672 display: flex;
673 flex-direction: column;
674 gap: var(--space-2);
675 }
676 .blob-breadcrumb { font-size: 13px; }
677 .blob-card {
678 background: var(--bg-elevated);
679 border: 1px solid var(--border);
680 border-radius: 12px;
681 overflow: hidden;
682 }
683 .blob-header-polished {
684 display: flex;
685 align-items: center;
686 justify-content: space-between;
687 gap: var(--space-3);
688 padding: 10px 14px;
689 background: var(--bg-secondary);
690 border-bottom: 1px solid var(--border);
691 }
692 .blob-header-meta {
693 display: flex;
694 align-items: center;
695 gap: var(--space-2);
696 min-width: 0;
697 flex: 1;
698 }
699 .blob-header-icon { font-size: 14px; opacity: 0.85; }
700 .blob-header-name {
701 font-family: var(--font-mono);
702 font-size: 13px;
703 color: var(--text-strong);
704 font-weight: 600;
705 overflow: hidden;
706 text-overflow: ellipsis;
707 white-space: nowrap;
708 }
709 .blob-header-size {
710 font-family: var(--font-mono);
711 font-size: 11.5px;
712 color: var(--text-muted);
713 font-variant-numeric: tabular-nums;
714 border-left: 1px solid var(--border);
715 padding-left: var(--space-2);
716 margin-left: 2px;
717 }
718 .blob-header-actions {
719 display: flex;
720 gap: 6px;
721 flex-shrink: 0;
722 }
723 .blob-pill {
724 display: inline-flex;
725 align-items: center;
726 padding: 4px 12px;
727 font-size: 12px;
728 font-weight: 500;
729 color: var(--text);
730 background: var(--bg-elevated);
731 border: 1px solid var(--border);
732 border-radius: 999px;
733 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
734 }
735 .blob-pill:hover {
736 color: var(--accent);
737 border-color: rgba(140,109,255,0.45);
738 text-decoration: none;
739 background: rgba(140,109,255,0.06);
740 }
741 .blob-pill-accent {
742 color: var(--accent);
743 border-color: rgba(140,109,255,0.35);
744 background: rgba(140,109,255,0.08);
745 }
746 .blob-pill-accent:hover {
747 background: rgba(140,109,255,0.14);
748 }
749 .blob-binary {
750 padding: var(--space-5);
751 color: var(--text-muted);
752 text-align: center;
753 font-size: 13px;
754 background: var(--bg);
755 }
756
757 /* ───────── commits list ───────── */
758 .commits-hero {
759 position: relative;
760 margin-bottom: var(--space-4);
761 padding: var(--space-5) var(--space-6);
762 background: var(--bg-elevated);
763 border: 1px solid var(--border);
764 border-radius: 16px;
765 overflow: hidden;
766 }
767 .commits-hero-orb-wrap {
768 position: absolute;
769 inset: -25% -10% auto auto;
770 width: 320px;
771 height: 320px;
772 pointer-events: none;
773 z-index: 0;
774 }
775 .commits-hero-orb {
776 position: absolute;
777 inset: 0;
778 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
779 filter: blur(80px);
780 opacity: 0.7;
781 animation: cbHeroOrb 14s ease-in-out infinite;
782 }
783 .commits-hero-inner { position: relative; z-index: 1; }
784 .commits-eyebrow {
785 font-size: 12px;
786 font-family: var(--font-mono);
787 color: var(--text-muted);
788 letter-spacing: 0.1em;
789 text-transform: uppercase;
790 margin-bottom: var(--space-2);
791 }
792 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
793 .commits-title {
794 font-family: var(--font-display);
795 font-weight: 800;
796 letter-spacing: -0.025em;
797 font-size: clamp(22px, 3vw, 30px);
798 line-height: 1.15;
799 margin: 0 0 var(--space-2);
800 color: var(--text-strong);
801 }
802 .commits-branch {
803 font-family: var(--font-mono);
804 font-size: 0.7em;
805 color: var(--text);
806 background: var(--bg-secondary);
807 border: 1px solid var(--border);
808 border-radius: 6px;
809 padding: 2px 8px;
810 font-weight: 500;
811 vertical-align: middle;
812 }
813 .commits-sub {
814 font-size: 14px;
815 color: var(--text-muted);
816 margin: 0;
817 line-height: 1.55;
818 max-width: 640px;
819 }
398a10cClaude820 .commits-toolbar {
821 display: flex;
822 justify-content: space-between;
823 align-items: center;
824 flex-wrap: wrap;
825 gap: var(--space-2);
826 margin-bottom: var(--space-3);
827 }
828 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
829 .commits-toolbar-link {
830 display: inline-flex;
831 align-items: center;
832 gap: 6px;
833 padding: 6px 12px;
834 font-size: 12.5px;
835 font-weight: 500;
836 color: var(--text-muted);
837 background: rgba(255,255,255,0.025);
838 border: 1px solid var(--border);
839 border-radius: 8px;
840 text-decoration: none;
841 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
842 }
843 .commits-toolbar-link:hover {
844 border-color: var(--border-strong);
845 color: var(--text-strong);
846 background: rgba(255,255,255,0.04);
847 text-decoration: none;
848 }
efb11c5Claude849 .commits-list-wrap {
850 background: var(--bg-elevated);
851 border: 1px solid var(--border);
398a10cClaude852 border-radius: 14px;
efb11c5Claude853 overflow: hidden;
398a10cClaude854 position: relative;
855 }
856 .commits-list-wrap::before {
857 content: '';
858 position: absolute;
859 top: 0; left: 0; right: 0;
860 height: 2px;
861 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
862 opacity: 0.55;
863 pointer-events: none;
864 }
865 .commits-day-head {
866 display: flex;
867 align-items: center;
868 gap: 10px;
869 padding: 10px 18px;
870 font-size: 11.5px;
871 font-family: var(--font-mono);
872 text-transform: uppercase;
873 letter-spacing: 0.08em;
874 color: var(--text-muted);
875 background: var(--bg-secondary);
876 border-bottom: 1px solid var(--border);
877 }
878 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
879 .commits-day-head-dot {
880 width: 6px; height: 6px;
881 border-radius: 9999px;
882 background: linear-gradient(135deg, #8c6dff, #36c5d6);
883 }
884 .commits-row {
885 display: flex;
886 align-items: flex-start;
887 gap: 14px;
888 padding: 14px 18px;
889 border-bottom: 1px solid var(--border-subtle);
890 transition: background 120ms ease;
891 }
892 .commits-row:last-child { border-bottom: none; }
893 .commits-row:hover { background: rgba(255,255,255,0.022); }
894 .commits-avatar {
895 width: 34px; height: 34px;
896 border-radius: 9999px;
897 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
898 color: #fff;
899 display: inline-flex;
900 align-items: center;
901 justify-content: center;
902 font-family: var(--font-display);
903 font-weight: 700;
904 font-size: 13.5px;
905 flex-shrink: 0;
906 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
907 }
908 .commits-row-body { flex: 1; min-width: 0; }
909 .commits-row-msg {
910 display: flex;
911 align-items: center;
912 gap: 8px;
913 flex-wrap: wrap;
914 }
915 .commits-row-msg a {
916 font-family: var(--font-display);
917 font-weight: 600;
918 font-size: 14px;
919 color: var(--text-strong);
920 text-decoration: none;
921 letter-spacing: -0.005em;
922 }
923 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
924 .commits-row-verified {
925 font-size: 9.5px;
926 padding: 1px 7px;
927 border-radius: 9999px;
928 background: rgba(52,211,153,0.16);
929 color: #6ee7b7;
930 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
931 text-transform: uppercase;
932 letter-spacing: 0.06em;
933 font-weight: 700;
934 }
935 .commits-row-meta {
936 margin-top: 4px;
937 display: flex;
938 align-items: center;
939 gap: 8px;
940 flex-wrap: wrap;
941 font-size: 12.5px;
942 color: var(--text-muted);
943 }
944 .commits-row-meta strong { color: var(--text); font-weight: 600; }
945 .commits-row-meta .sep { opacity: 0.4; }
946 .commits-row-time {
947 font-variant-numeric: tabular-nums;
948 }
949 .commits-row-side {
950 display: flex;
951 align-items: center;
952 gap: 8px;
953 flex-shrink: 0;
954 }
955 .commits-row-sha {
956 display: inline-flex;
957 align-items: center;
958 padding: 4px 10px;
959 border-radius: 9999px;
960 font-family: var(--font-mono);
961 font-size: 11.5px;
962 font-weight: 600;
963 color: var(--text-strong);
964 background: rgba(255,255,255,0.04);
965 border: 1px solid var(--border);
966 text-decoration: none;
967 letter-spacing: 0.04em;
968 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
969 }
970 .commits-row-sha:hover {
971 border-color: rgba(140,109,255,0.55);
972 color: var(--accent);
973 background: rgba(140,109,255,0.08);
974 text-decoration: none;
975 }
976 .commits-row-copy {
977 display: inline-flex;
978 align-items: center;
979 justify-content: center;
980 width: 28px; height: 28px;
981 padding: 0;
982 border-radius: 8px;
983 background: transparent;
984 border: 1px solid var(--border);
985 color: var(--text-muted);
986 cursor: pointer;
987 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude988 }
398a10cClaude989 .commits-row-copy:hover {
990 border-color: var(--border-strong);
991 color: var(--text);
992 background: rgba(255,255,255,0.04);
993 }
994 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude995 /* Push Watch link — eye icon next to the SHA chip */
996 .commits-row-watch {
997 display: inline-flex;
998 align-items: center;
999 justify-content: center;
1000 width: 28px;
1001 height: 28px;
1002 border-radius: 6px;
1003 border: 1px solid var(--border);
1004 color: var(--text-muted);
1005 background: transparent;
1006 cursor: pointer;
1007 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1008 text-decoration: none !important;
1009 }
1010 .commits-row-watch:hover {
1011 border-color: rgba(140,109,255,0.45);
1012 color: var(--accent);
1013 background: rgba(140,109,255,0.08);
1014 text-decoration: none !important;
1015 }
efb11c5Claude1016 .commits-empty {
398a10cClaude1017 position: relative;
1018 overflow: hidden;
1019 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1020 text-align: center;
398a10cClaude1021 background: var(--bg-elevated);
1022 border: 1px dashed var(--border-strong);
1023 border-radius: 16px;
1024 }
1025 .commits-empty-orb {
1026 position: absolute;
1027 inset: -40% 30% auto 30%;
1028 height: 280px;
1029 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
1030 filter: blur(70px);
1031 opacity: 0.7;
1032 pointer-events: none;
1033 z-index: 0;
1034 }
1035 .commits-empty-inner { position: relative; z-index: 1; }
1036 .commits-empty-icon {
1037 width: 56px; height: 56px;
1038 border-radius: 9999px;
1039 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
1040 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
1041 display: inline-flex;
1042 align-items: center;
1043 justify-content: center;
1044 color: #c4b5fd;
1045 margin: 0 auto 14px;
1046 }
1047 .commits-empty-title {
1048 font-family: var(--font-display);
1049 font-size: 18px;
1050 font-weight: 700;
1051 margin: 0 0 6px;
1052 color: var(--text-strong);
1053 }
1054 .commits-empty-sub {
1055 margin: 0 auto 0;
1056 font-size: 13.5px;
efb11c5Claude1057 color: var(--text-muted);
398a10cClaude1058 max-width: 420px;
1059 line-height: 1.5;
1060 }
1061
1062 /* ───────── branches list ───────── */
1063 .branches-list {
efb11c5Claude1064 background: var(--bg-elevated);
398a10cClaude1065 border: 1px solid var(--border);
1066 border-radius: 14px;
1067 overflow: hidden;
1068 position: relative;
1069 }
1070 .branches-list::before {
1071 content: '';
1072 position: absolute;
1073 top: 0; left: 0; right: 0;
1074 height: 2px;
1075 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1076 opacity: 0.55;
1077 pointer-events: none;
1078 }
1079 .branches-row {
1080 display: flex;
1081 align-items: center;
1082 gap: var(--space-3);
1083 padding: 14px 18px;
1084 border-bottom: 1px solid var(--border-subtle);
1085 transition: background 120ms ease;
1086 flex-wrap: wrap;
1087 }
1088 .branches-row:last-child { border-bottom: none; }
1089 .branches-row:hover { background: rgba(255,255,255,0.022); }
1090 .branches-row-icon {
1091 width: 32px; height: 32px;
1092 border-radius: 8px;
1093 background: rgba(140,109,255,0.10);
1094 color: #c4b5fd;
1095 display: inline-flex;
1096 align-items: center;
1097 justify-content: center;
1098 flex-shrink: 0;
1099 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1100 }
1101 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1102 .branches-row-name {
1103 display: inline-flex;
1104 align-items: center;
1105 gap: 8px;
1106 flex-wrap: wrap;
1107 }
1108 .branches-row-name a {
1109 font-family: var(--font-mono);
1110 font-size: 13.5px;
1111 color: var(--text-strong);
1112 font-weight: 600;
1113 text-decoration: none;
1114 }
1115 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1116 .branches-row-default {
1117 font-size: 10px;
1118 padding: 2px 8px;
1119 border-radius: 9999px;
1120 background: rgba(140,109,255,0.14);
1121 color: #c4b5fd;
1122 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1123 text-transform: uppercase;
1124 letter-spacing: 0.08em;
1125 font-weight: 700;
1126 font-family: var(--font-mono);
1127 }
1128 .branches-row-meta {
1129 display: flex;
1130 align-items: center;
1131 gap: 8px;
1132 flex-wrap: wrap;
1133 font-size: 12.5px;
1134 color: var(--text-muted);
1135 font-variant-numeric: tabular-nums;
1136 }
1137 .branches-row-meta .sep { opacity: 0.4; }
1138 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1139 .branches-row-side {
1140 display: flex;
1141 align-items: center;
1142 gap: 10px;
1143 flex-shrink: 0;
1144 flex-wrap: wrap;
1145 }
1146 .branches-row-divergence {
1147 display: inline-flex;
1148 align-items: center;
1149 gap: 8px;
1150 font-family: var(--font-mono);
1151 font-size: 11.5px;
1152 color: var(--text-muted);
1153 padding: 4px 10px;
1154 border-radius: 9999px;
1155 background: rgba(255,255,255,0.035);
1156 border: 1px solid var(--border);
1157 font-variant-numeric: tabular-nums;
1158 }
1159 .branches-row-divergence .ahead { color: #6ee7b7; }
1160 .branches-row-divergence .behind { color: #fca5a5; }
1161 .branches-row-actions {
1162 display: flex;
1163 align-items: center;
1164 gap: 6px;
1165 }
1166 .branches-btn {
1167 display: inline-flex;
1168 align-items: center;
1169 gap: 5px;
1170 padding: 6px 12px;
1171 border-radius: 9999px;
1172 font-size: 12px;
1173 font-weight: 600;
1174 text-decoration: none;
1175 border: 1px solid var(--border);
1176 background: transparent;
1177 color: var(--text-muted);
1178 cursor: pointer;
1179 font: inherit;
1180 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1181 }
1182 .branches-btn:hover {
1183 border-color: var(--border-strong);
1184 color: var(--text);
1185 background: rgba(255,255,255,0.04);
1186 text-decoration: none;
1187 }
1188 .branches-btn-danger {
1189 color: #fca5a5;
1190 border-color: rgba(248,113,113,0.30);
1191 }
1192 .branches-btn-danger:hover {
1193 border-style: dashed;
1194 border-color: rgba(248,113,113,0.65);
1195 background: rgba(248,113,113,0.06);
1196 color: #fecaca;
1197 }
1198
1199 /* ───────── tags list ───────── */
1200 .tags-list {
1201 background: var(--bg-elevated);
1202 border: 1px solid var(--border);
1203 border-radius: 14px;
1204 overflow: hidden;
1205 position: relative;
1206 }
1207 .tags-list::before {
1208 content: '';
1209 position: absolute;
1210 top: 0; left: 0; right: 0;
1211 height: 2px;
1212 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1213 opacity: 0.55;
1214 pointer-events: none;
1215 }
1216 .tags-row {
1217 display: flex;
1218 align-items: center;
1219 gap: var(--space-3);
1220 padding: 14px 18px;
1221 border-bottom: 1px solid var(--border-subtle);
1222 transition: background 120ms ease;
1223 flex-wrap: wrap;
1224 }
1225 .tags-row:last-child { border-bottom: none; }
1226 .tags-row:hover { background: rgba(255,255,255,0.022); }
1227 .tags-row-icon {
1228 width: 32px; height: 32px;
1229 border-radius: 8px;
1230 background: rgba(54,197,214,0.10);
1231 color: #67e8f9;
1232 display: inline-flex;
1233 align-items: center;
1234 justify-content: center;
1235 flex-shrink: 0;
1236 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1237 }
1238 .tags-row-main { flex: 1; min-width: 240px; }
1239 .tags-row-name {
1240 display: inline-flex;
1241 align-items: center;
1242 gap: 8px;
1243 flex-wrap: wrap;
1244 }
1245 .tags-row-version {
1246 display: inline-flex;
1247 align-items: center;
1248 padding: 3px 11px;
1249 border-radius: 9999px;
1250 font-family: var(--font-mono);
1251 font-size: 13px;
1252 font-weight: 700;
1253 color: #67e8f9;
1254 background: rgba(54,197,214,0.12);
1255 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1256 letter-spacing: 0.01em;
1257 }
1258 .tags-row-meta {
1259 margin-top: 4px;
1260 display: flex;
1261 align-items: center;
1262 gap: 8px;
1263 flex-wrap: wrap;
1264 font-size: 12.5px;
1265 color: var(--text-muted);
1266 font-variant-numeric: tabular-nums;
1267 }
1268 .tags-row-meta .sep { opacity: 0.4; }
1269 .tags-row-sha {
1270 font-family: var(--font-mono);
1271 font-size: 11.5px;
1272 color: var(--text-strong);
1273 padding: 2px 8px;
1274 border-radius: 6px;
1275 background: rgba(255,255,255,0.04);
1276 border: 1px solid var(--border);
1277 text-decoration: none;
1278 letter-spacing: 0.04em;
1279 }
1280 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1281 .tags-row-side {
1282 display: flex;
1283 align-items: center;
1284 gap: 6px;
1285 flex-shrink: 0;
1286 flex-wrap: wrap;
1287 }
1288 .tags-row-link {
1289 display: inline-flex;
1290 align-items: center;
1291 gap: 5px;
1292 padding: 6px 12px;
1293 border-radius: 9999px;
1294 font-size: 12px;
1295 font-weight: 600;
1296 text-decoration: none;
1297 color: var(--text-muted);
1298 background: rgba(255,255,255,0.025);
1299 border: 1px solid var(--border);
1300 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1301 }
1302 .tags-row-link:hover {
1303 border-color: rgba(140,109,255,0.45);
1304 color: var(--text-strong);
1305 background: rgba(140,109,255,0.06);
1306 text-decoration: none;
efb11c5Claude1307 }
1308
1309 /* ───────── commit detail ───────── */
1310 .commit-detail-card {
1311 position: relative;
1312 margin-bottom: var(--space-5);
1313 padding: var(--space-5) var(--space-5);
1314 background: var(--bg-elevated);
1315 border: 1px solid var(--border);
1316 border-radius: 14px;
1317 overflow: hidden;
1318 }
1319 .commit-detail-eyebrow {
1320 display: flex;
1321 align-items: center;
1322 gap: var(--space-2);
1323 font-size: 12px;
1324 font-family: var(--font-mono);
1325 text-transform: uppercase;
1326 letter-spacing: 0.1em;
1327 color: var(--text-muted);
1328 margin-bottom: var(--space-2);
1329 }
1330 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1331 .commit-detail-sha-pill {
1332 font-family: var(--font-mono);
1333 font-size: 11.5px;
1334 color: var(--text-strong);
1335 background: var(--bg-secondary);
1336 border: 1px solid var(--border);
1337 border-radius: 999px;
1338 padding: 2px 10px;
1339 letter-spacing: 0.04em;
1340 }
1341 .commit-detail-verify {
1342 font-size: 10px;
1343 padding: 2px 8px;
1344 border-radius: 999px;
1345 text-transform: uppercase;
1346 letter-spacing: 0.06em;
1347 font-weight: 700;
1348 color: #fff;
1349 }
1350 .commit-detail-verify-ok {
1351 background: linear-gradient(135deg, #2ea043, #34d399);
1352 }
1353 .commit-detail-verify-warn {
1354 background: linear-gradient(135deg, #d29922, #f59e0b);
1355 }
1356 .commit-detail-title {
1357 font-family: var(--font-display);
1358 font-weight: 700;
1359 letter-spacing: -0.018em;
1360 font-size: clamp(20px, 2.5vw, 26px);
1361 line-height: 1.25;
1362 margin: 0 0 var(--space-2);
1363 color: var(--text-strong);
1364 }
1365 .commit-detail-body {
1366 white-space: pre-wrap;
1367 color: var(--text-muted);
1368 font-size: 14px;
1369 line-height: 1.55;
1370 margin: 0 0 var(--space-3);
1371 font-family: var(--font-mono);
1372 background: var(--bg);
1373 border: 1px solid var(--border);
1374 border-radius: 8px;
1375 padding: var(--space-3) var(--space-4);
1376 max-height: 280px;
1377 overflow: auto;
1378 }
1379 .commit-detail-meta {
1380 display: flex;
1381 flex-wrap: wrap;
1382 gap: var(--space-3);
1383 font-size: 13px;
1384 color: var(--text-muted);
1385 margin-bottom: var(--space-3);
1386 }
1387 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1388 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1389 .commit-detail-sha-link {
1390 font-family: var(--font-mono);
1391 font-size: 12.5px;
1392 color: var(--accent);
1393 background: rgba(140,109,255,0.08);
1394 border-radius: 6px;
1395 padding: 1px 6px;
1396 margin-left: 2px;
1397 }
1398 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1399 .commit-detail-stats {
1400 display: flex;
1401 flex-wrap: wrap;
1402 align-items: center;
1403 gap: var(--space-3);
1404 padding-top: var(--space-3);
1405 border-top: 1px solid var(--border);
1406 font-size: 13px;
1407 color: var(--text-muted);
1408 }
1409 .commit-detail-stat {
1410 display: inline-flex;
1411 align-items: center;
1412 gap: 4px;
1413 font-variant-numeric: tabular-nums;
1414 }
1415 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1416 .commit-detail-stat-add strong { color: #34d399; }
1417 .commit-detail-stat-del strong { color: #f87171; }
1418 .commit-detail-stat-mark {
1419 font-family: var(--font-mono);
1420 font-weight: 700;
1421 font-size: 14px;
1422 }
1423 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1424 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1425 .commit-detail-sha-full {
1426 margin-left: auto;
1427 font-family: var(--font-mono);
1428 font-size: 11.5px;
1429 color: var(--text-faint);
1430 letter-spacing: 0.02em;
1431 overflow: hidden;
1432 text-overflow: ellipsis;
1433 white-space: nowrap;
1434 max-width: 100%;
1435 }
1436 .commit-detail-checks {
1437 margin-top: var(--space-3);
1438 padding-top: var(--space-3);
1439 border-top: 1px solid var(--border);
1440 }
1441 .commit-detail-checks-head {
1442 display: flex;
1443 align-items: center;
1444 gap: var(--space-2);
1445 font-size: 13px;
1446 color: var(--text-muted);
1447 margin-bottom: 8px;
1448 }
1449 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1450 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1451 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1452 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1453 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1454 .commit-detail-check {
1455 font-size: 11px;
1456 padding: 2px 8px;
1457 border-radius: 999px;
1458 color: #fff;
1459 font-weight: 500;
1460 }
398a10cClaude1461 .commit-detail-check a { color: inherit; text-decoration: none; }
1462 .commit-detail-check-success { background: #2ea043; }
1463 .commit-detail-check-pending { background: #d29922; }
1464 .commit-detail-check-failure { background: #da3633; }
1465
1466 /* ───────── blame ───────── */
1467 .blame-head { margin-bottom: var(--space-5); }
1468 .blame-eyebrow {
1469 display: inline-flex;
1470 align-items: center;
1471 gap: 8px;
1472 text-transform: uppercase;
1473 font-family: var(--font-mono);
1474 font-size: 11px;
1475 letter-spacing: 0.16em;
1476 color: var(--text-muted);
1477 font-weight: 600;
1478 margin-bottom: 10px;
1479 }
1480 .blame-eyebrow-dot {
1481 width: 8px; height: 8px;
1482 border-radius: 9999px;
1483 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1484 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1485 }
1486 .blame-title {
1487 font-family: var(--font-display);
1488 font-size: clamp(22px, 3vw, 30px);
1489 font-weight: 800;
1490 letter-spacing: -0.025em;
1491 line-height: 1.15;
1492 margin: 0 0 6px;
1493 color: var(--text-strong);
1494 }
1495 .blame-title code {
1496 font-family: var(--font-mono);
1497 font-size: 0.78em;
1498 color: var(--text-strong);
1499 font-weight: 700;
1500 }
1501 .blame-sub {
1502 margin: 0;
1503 font-size: 14px;
1504 color: var(--text-muted);
1505 line-height: 1.5;
1506 max-width: 700px;
1507 }
efb11c5Claude1508 .blame-toolbar {
398a10cClaude1509 display: flex;
1510 justify-content: space-between;
1511 align-items: center;
1512 gap: var(--space-3);
efb11c5Claude1513 margin-bottom: var(--space-3);
398a10cClaude1514 flex-wrap: wrap;
efb11c5Claude1515 font-size: 13px;
1516 }
398a10cClaude1517 .blame-toolbar-actions { display: flex; gap: 6px; }
1518 .blame-card {
1519 background: var(--bg-elevated);
1520 border: 1px solid var(--border);
1521 border-radius: 14px;
1522 overflow: hidden;
1523 position: relative;
1524 }
1525 .blame-card::before {
1526 content: '';
1527 position: absolute;
1528 top: 0; left: 0; right: 0;
1529 height: 2px;
1530 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1531 opacity: 0.55;
1532 pointer-events: none;
1533 }
efb11c5Claude1534 .blame-header {
1535 display: flex;
1536 align-items: center;
1537 justify-content: space-between;
1538 gap: var(--space-3);
1539 padding: 10px 14px;
1540 background: var(--bg-secondary);
1541 border-bottom: 1px solid var(--border);
1542 }
1543 .blame-header-meta {
1544 display: flex;
1545 align-items: center;
1546 gap: var(--space-2);
1547 min-width: 0;
1548 flex-wrap: wrap;
1549 }
1550 .blame-header-icon { color: var(--accent); font-size: 14px; }
1551 .blame-header-name {
1552 font-family: var(--font-mono);
1553 font-size: 13px;
1554 color: var(--text-strong);
1555 font-weight: 600;
1556 }
1557 .blame-header-tag {
1558 font-size: 10.5px;
1559 text-transform: uppercase;
1560 letter-spacing: 0.08em;
1561 font-family: var(--font-mono);
1562 background: rgba(140,109,255,0.12);
1563 color: var(--accent);
1564 border-radius: 999px;
1565 padding: 2px 8px;
1566 font-weight: 600;
1567 }
1568 .blame-header-stats {
1569 font-family: var(--font-mono);
1570 font-size: 11.5px;
1571 color: var(--text-muted);
1572 font-variant-numeric: tabular-nums;
1573 border-left: 1px solid var(--border);
1574 padding-left: var(--space-2);
1575 }
1576 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1577 .blame-table {
1578 width: 100%;
1579 border-collapse: collapse;
1580 font-family: var(--font-mono);
1581 font-size: 12.5px;
1582 line-height: 1.6;
1583 }
1584 .blame-table tr { border-bottom: 1px solid transparent; }
1585 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1586 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1587 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1588 .blame-gutter {
1589 width: 220px;
1590 min-width: 220px;
1591 padding: 0 12px;
1592 vertical-align: top;
1593 background: rgba(255,255,255,0.012);
1594 border-right: 1px solid var(--border-subtle);
1595 font-variant-numeric: tabular-nums;
1596 color: var(--text-muted);
1597 font-size: 11px;
1598 white-space: nowrap;
1599 overflow: hidden;
1600 text-overflow: ellipsis;
1601 padding-top: 2px;
1602 padding-bottom: 2px;
1603 }
1604 .blame-gutter-inner {
1605 display: inline-flex;
1606 align-items: center;
1607 gap: 7px;
1608 max-width: 100%;
1609 overflow: hidden;
1610 }
1611 .blame-gutter-sha {
1612 font-family: var(--font-mono);
1613 font-size: 10.5px;
1614 font-weight: 600;
1615 color: #c4b5fd;
1616 background: rgba(140,109,255,0.10);
1617 padding: 1px 7px;
1618 border-radius: 9999px;
1619 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1620 text-decoration: none;
1621 letter-spacing: 0.04em;
1622 flex-shrink: 0;
1623 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1624 }
1625 .blame-gutter-sha:hover {
1626 background: rgba(140,109,255,0.22);
1627 color: #ddd6fe;
1628 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1629 text-decoration: none;
1630 }
1631 .blame-gutter-author {
1632 color: var(--text);
1633 overflow: hidden;
1634 text-overflow: ellipsis;
1635 white-space: nowrap;
1636 font-size: 11px;
1637 font-family: var(--font-sans, inherit);
1638 }
1639 .blame-line-num {
1640 width: 1%;
1641 min-width: 50px;
1642 padding: 0 12px;
1643 text-align: right;
1644 color: var(--text-faint);
1645 user-select: none;
1646 border-right: 1px solid var(--border-subtle);
1647 font-variant-numeric: tabular-nums;
1648 }
1649 .blame-line-content {
1650 padding: 0 14px;
1651 white-space: pre;
1652 color: var(--text);
1653 transition: background 120ms ease;
1654 }
efb11c5Claude1655
1656 /* ───────── search ───────── */
1657 .search-hero {
1658 position: relative;
1659 margin-bottom: var(--space-4);
1660 padding: var(--space-5) var(--space-6);
1661 background: var(--bg-elevated);
1662 border: 1px solid var(--border);
1663 border-radius: 16px;
1664 overflow: hidden;
1665 }
1666 .search-eyebrow {
1667 font-size: 12px;
1668 font-family: var(--font-mono);
1669 color: var(--text-muted);
1670 letter-spacing: 0.1em;
1671 text-transform: uppercase;
1672 margin-bottom: var(--space-2);
1673 }
1674 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1675 .search-title {
1676 font-family: var(--font-display);
1677 font-weight: 800;
1678 letter-spacing: -0.025em;
1679 font-size: clamp(22px, 3vw, 30px);
1680 line-height: 1.15;
1681 margin: 0 0 var(--space-3);
1682 color: var(--text-strong);
1683 }
1684 .search-form {
1685 display: flex;
1686 gap: var(--space-2);
1687 align-items: stretch;
1688 }
1689 .search-input-wrap {
1690 position: relative;
1691 flex: 1;
1692 display: flex;
1693 align-items: center;
1694 }
1695 .search-input-icon {
1696 position: absolute;
1697 left: 12px;
1698 color: var(--text-muted);
1699 font-size: 15px;
1700 pointer-events: none;
1701 }
1702 .search-input {
1703 appearance: none;
1704 width: 100%;
1705 padding: 10px 12px 10px 34px;
1706 background: var(--bg);
1707 border: 1px solid var(--border);
1708 border-radius: 10px;
1709 color: var(--text-strong);
1710 font-size: 14px;
1711 font-family: inherit;
1712 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1713 }
1714 .search-input:focus {
1715 outline: none;
1716 border-color: var(--accent);
1717 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1718 }
1719 .search-submit { min-width: 96px; }
1720 .search-results-head {
1721 display: flex;
1722 align-items: baseline;
1723 gap: var(--space-2);
1724 margin-bottom: var(--space-3);
1725 font-size: 13px;
1726 color: var(--text-muted);
1727 }
1728 .search-results-count strong {
1729 color: var(--text-strong);
1730 font-weight: 600;
1731 font-variant-numeric: tabular-nums;
1732 }
1733 .search-results-q { color: var(--text-strong); font-weight: 600; }
1734 .search-results-head code {
1735 font-family: var(--font-mono);
1736 font-size: 12px;
1737 background: var(--bg-secondary);
1738 border: 1px solid var(--border);
1739 border-radius: 5px;
1740 padding: 1px 6px;
1741 color: var(--text);
1742 }
1743 .search-empty {
1744 padding: var(--space-5) var(--space-6);
1745 background: var(--bg-elevated);
1746 border: 1px dashed var(--border);
1747 border-radius: 12px;
1748 color: var(--text-muted);
1749 font-size: 14px;
1750 }
1751 .search-empty strong { color: var(--text-strong); }
1752 .search-results {
1753 display: flex;
1754 flex-direction: column;
1755 gap: var(--space-3);
1756 }
1757 .search-file-head {
1758 display: flex;
1759 align-items: center;
1760 justify-content: space-between;
1761 gap: var(--space-2);
1762 }
1763 .search-file-link {
1764 font-family: var(--font-mono);
1765 font-size: 13px;
1766 color: var(--text-strong);
1767 font-weight: 600;
1768 }
1769 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1770 .search-file-count {
1771 font-family: var(--font-mono);
1772 font-size: 11.5px;
1773 color: var(--text-muted);
1774 font-variant-numeric: tabular-nums;
1775 }
1776`;
1777
fc1817aClaude1778// Home page
06d5ffeClaude1779web.get("/", async (c) => {
1780 const user = c.get("user");
1781
1782 if (user) {
0316dbbClaude1783 return c.redirect("/dashboard");
06d5ffeClaude1784 }
1785
8e9f1d9Claude1786 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1787 let publicStats: PublicStats | null = null;
8e9f1d9Claude1788 try {
1789 const [repoRow] = await db
1790 .select({ n: sql<number>`count(*)::int` })
1791 .from(repositories)
1792 .where(eq(repositories.isPrivate, false));
1793 const [userRow] = await db
1794 .select({ n: sql<number>`count(*)::int` })
1795 .from(users);
1796 stats = {
1797 publicRepos: Number(repoRow?.n ?? 0),
1798 users: Number(userRow?.n ?? 0),
1799 };
1800 } catch {
1801 stats = undefined;
1802 }
1803
52ad8b1Claude1804 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1805 try {
1806 publicStats = await computePublicStats();
1807 } catch {
1808 publicStats = null;
1809 }
1810
534f04aClaude1811 // Block M1 — initial SSR snapshot for the live-now demo feed.
1812 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1813 // in try/catch so a freak module-level explosion can't take down /.
1814 let liveFeed: LandingLiveFeed | null = null;
1815 try {
1816 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1817 listQueuedAiBuildIssues(3),
1818 listRecentAutoMerges(3, 24),
1819 listRecentAiReviews(3, 24),
1820 countAiReviewsSince(24),
1821 listDemoActivityFeed(10),
1822 ]);
1823 liveFeed = {
1824 queued: queued.map((i) => ({
1825 repo: i.repo,
1826 number: i.number,
1827 title: i.title,
1828 createdAt: i.createdAt,
1829 })),
1830 merges: merges.map((m) => ({
1831 repo: m.repo,
1832 number: m.number,
1833 title: m.title,
1834 mergedAt: m.mergedAt,
1835 })),
1836 reviews: reviewList.map((r) => ({
1837 repo: r.repo,
1838 prNumber: r.prNumber,
1839 commentSnippet: r.commentSnippet,
1840 createdAt: r.createdAt,
1841 })),
1842 reviewCount,
1843 feed: feed.map((e) => ({
1844 kind: e.kind,
1845 repo: e.repo,
1846 ref: e.ref,
1847 at: e.at,
1848 })),
1849 };
1850 } catch {
1851 liveFeed = null;
1852 }
1853
29924bcClaude1854 // 2030 reboot — the public landing is a self-contained light marketing
1855 // document (its own shell + design system), rendered directly so it never
1856 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1857 // above for the legacy LandingPage and other surfaces; the new page uses the
1858 // headline counters from `stats`.
1859 void publicStats;
1860 void liveFeed;
1861 void LandingPage;
1862 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1863});
1864
06d5ffeClaude1865// New repository form
1866web.get("/new", requireAuth, (c) => {
1867 const user = c.get("user")!;
1868 const error = c.req.query("error");
1869
1870 return c.html(
1871 <Layout title="New repository" user={user}>
efb11c5Claude1872 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1873 <div class="new-repo-hero">
1874 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1875 <div class="new-repo-hero-orb" />
1876 </div>
1877 <div class="new-repo-hero-inner">
1878 <div class="new-repo-eyebrow">
1879 <strong>Create</strong> · {user.username}
1880 </div>
1881 <h1 class="new-repo-title">
1882 Spin up a <span class="gradient-text">repository</span>.
1883 </h1>
1884 <p class="new-repo-sub">
1885 Push your first commit, and Gluecron wires up gate checks, AI review,
1886 and auto-merge from the moment your branch lands.
1887 </p>
1888 </div>
1889 </div>
06d5ffeClaude1890 <div class="new-repo-form">
efb11c5Claude1891 {error && (
1892 <div class="new-repo-error" role="alert">
1893 {decodeURIComponent(error)}
1894 </div>
1895 )}
1896 <form method="post" action="/new" class="new-repo-form-grid">
1897 <div class="new-repo-row">
1898 <label class="new-repo-label">Owner</label>
1899 <input
1900 type="text"
1901 value={user.username}
1902 disabled
1903 aria-label="Owner"
1904 class="new-repo-input new-repo-input-disabled"
1905 />
06d5ffeClaude1906 </div>
efb11c5Claude1907 <div class="new-repo-row">
1908 <label class="new-repo-label" for="name">
1909 Repository name
1910 </label>
06d5ffeClaude1911 <input
1912 type="text"
1913 id="name"
1914 name="name"
1915 required
1916 pattern="^[a-zA-Z0-9._-]+$"
1917 placeholder="my-project"
1918 autocomplete="off"
efb11c5Claude1919 class="new-repo-input"
06d5ffeClaude1920 />
efb11c5Claude1921 <p class="new-repo-hint">
1922 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1923 <code>{user.username}/&lt;name&gt;</code>.
1924 </p>
06d5ffeClaude1925 </div>
efb11c5Claude1926 <div class="new-repo-row">
1927 <label class="new-repo-label" for="description">
1928 Description{" "}
1929 <span class="new-repo-label-optional">(optional)</span>
1930 </label>
06d5ffeClaude1931 <input
1932 type="text"
1933 id="description"
1934 name="description"
1935 placeholder="A short description of your repository"
efb11c5Claude1936 class="new-repo-input"
06d5ffeClaude1937 />
1938 </div>
efb11c5Claude1939 <div class="new-repo-row">
1940 <span class="new-repo-label">Visibility</span>
1941 <div class="new-repo-visibility">
1942 <label class="new-repo-vis-card">
1943 <input
1944 type="radio"
1945 name="visibility"
1946 value="public"
1947 checked
1948 class="new-repo-vis-radio"
1949 />
1950 <span class="new-repo-vis-body">
1951 <span class="new-repo-vis-label">Public</span>
1952 <span class="new-repo-vis-desc">
1953 Anyone can see this repository. You choose who can commit.
1954 </span>
1955 </span>
1956 </label>
1957 <label class="new-repo-vis-card">
1958 <input
1959 type="radio"
1960 name="visibility"
1961 value="private"
1962 class="new-repo-vis-radio"
1963 />
1964 <span class="new-repo-vis-body">
1965 <span class="new-repo-vis-label">Private</span>
1966 <span class="new-repo-vis-desc">
1967 Only you (and collaborators you invite) can see this
1968 repository.
1969 </span>
1970 </span>
1971 </label>
1972 </div>
1973 </div>
398a10cClaude1974 <div class="new-repo-row">
1975 <span class="new-repo-label">
1976 Starter content{" "}
1977 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1978 </span>
1979 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1980 <label class="new-repo-template-chip">
1981 <input type="radio" name="starter" value="empty" checked />
1982 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1983 Empty
1984 </label>
1985 <label class="new-repo-template-chip">
1986 <input type="radio" name="starter" value="readme" />
1987 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1988 README
1989 </label>
1990 <label class="new-repo-template-chip">
1991 <input type="radio" name="starter" value="readme-mit" />
1992 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1993 README + MIT
1994 </label>
1995 <label class="new-repo-template-chip">
1996 <input type="radio" name="starter" value="node" />
1997 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1998 Node + .gitignore
1999 </label>
2000 </div>
2001 <p class="new-repo-hint">
2002 Just a UI hint — push your own commits to fill the repo.
2003 </p>
2004 </div>
44f1a02Claude2005 <div class="new-repo-row">
2006 <label class="new-repo-label" for="data_region">
2007 Data region
2008 </label>
2009 <select
2010 id="data_region"
2011 name="data_region"
2012 class="new-repo-input"
2013 style="cursor: pointer;"
2014 >
2015 <option value="us" selected>US (default)</option>
2016 <option value="eu">EU (Frankfurt)</option>
2017 </select>
2018 <p class="new-repo-hint">
2019 EU data residency requires a{" "}
2020 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2021 Pro plan or higher
2022 </a>
2023 . Repositories cannot be moved between regions after creation.
2024 </p>
2025 </div>
efb11c5Claude2026 <div class="new-repo-callout">
2027 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2028 <p class="new-repo-callout-body">
2029 Every push is gate-checked and reviewed by Claude automatically.
2030 Label an issue <code>ai-build</code> and Gluecron will open the PR
2031 for you.
2032 </p>
2033 </div>
2034 <div class="new-repo-actions">
398a10cClaude2035 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2036 Create repository
2037 </button>
2038 <a href="/dashboard" class="btn new-repo-cancel">
2039 Cancel
2040 </a>
06d5ffeClaude2041 </div>
2042 </form>
2043 </div>
2044 </Layout>
2045 );
2046});
2047
2048web.post("/new", requireAuth, async (c) => {
2049 const user = c.get("user")!;
2050 const body = await c.req.parseBody();
2051 const name = String(body.name || "").trim();
2052 const description = String(body.description || "").trim();
2053 const isPrivate = body.visibility === "private";
44f1a02Claude2054 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2055
2056 if (!name) {
2057 return c.redirect("/new?error=Repository+name+is+required");
2058 }
2059
c63b860Claude2060 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2061 // outage never blocks repo creation.
2062 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2063 const gate = await checkRepoCreateAllowed(user.id);
2064 if (!gate.ok) {
2065 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2066 }
2067
06d5ffeClaude2068 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2069 return c.redirect("/new?error=Invalid+repository+name");
2070 }
2071
2072 if (await repoExists(user.username, name)) {
2073 return c.redirect("/new?error=Repository+already+exists");
2074 }
2075
2076 const diskPath = await initBareRepo(user.username, name);
2077
3ef4c9dClaude2078 const [newRepo] = await db
2079 .insert(repositories)
2080 .values({
2081 name,
2082 ownerId: user.id,
2083 description: description || null,
2084 isPrivate,
2085 diskPath,
44f1a02Claude2086 dataRegion,
3ef4c9dClaude2087 })
2088 .returning();
2089
2090 if (newRepo) {
2091 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2092 await bootstrapRepository({
2093 repositoryId: newRepo.id,
2094 ownerUserId: user.id,
2095 defaultBranch: "main",
2096 });
2097 }
06d5ffeClaude2098
2099 return c.redirect(`/${user.username}/${name}`);
2100});
2101
2102// User profile
fc1817aClaude2103web.get("/:owner", async (c) => {
06d5ffeClaude2104 const { owner: ownerName } = c.req.param();
2105 const user = c.get("user");
2106
2107 // Avoid clashing with fixed routes
2108 if (
2109 ["login", "register", "logout", "new", "settings", "api"].includes(
2110 ownerName
2111 )
2112 ) {
2113 return c.notFound();
2114 }
2115
2116 let ownerUser;
2117 try {
2118 const [found] = await db
2119 .select()
2120 .from(users)
2121 .where(eq(users.username, ownerName))
2122 .limit(1);
2123 ownerUser = found;
2124 } catch {
2125 // DB not available — check if repos exist on disk
2126 ownerUser = null;
2127 }
2128
2129 // Even without DB, show repos if they exist on disk
2130 let repos: any[] = [];
2131 if (ownerUser) {
2132 const allRepos = await db
2133 .select()
2134 .from(repositories)
2135 .where(eq(repositories.ownerId, ownerUser.id))
2136 .orderBy(desc(repositories.updatedAt));
2137
2138 // Show public repos to everyone, private only to owner
2139 repos =
2140 user?.id === ownerUser.id
2141 ? allRepos
2142 : allRepos.filter((r) => !r.isPrivate);
2143 }
2144
7aa8b99Claude2145 // Block J4 — follow counts + viewer's follow state
2146 let followState = {
2147 followers: 0,
2148 following: 0,
2149 viewerFollows: false,
2150 };
2151 if (ownerUser) {
2152 try {
2153 const { followCounts, isFollowing } = await import("../lib/follows");
2154 const counts = await followCounts(ownerUser.id);
2155 followState.followers = counts.followers;
2156 followState.following = counts.following;
2157 if (user && user.id !== ownerUser.id) {
2158 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2159 }
2160 } catch {
2161 // DB hiccup — fall back to zeros.
2162 }
2163 }
2164 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2165
d412586Claude2166 // Block J5 — profile README. Render owner/owner repo's README on the
2167 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2168 // back to "<user>/.github" for org-style profile repos.
2169 let profileReadmeHtml: string | null = null;
2170 try {
2171 const candidates = [ownerName, ".github"];
2172 for (const rname of candidates) {
2173 if (await repoExists(ownerName, rname)) {
2174 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2175 const md = await getReadme(ownerName, rname, ref);
2176 if (md) {
2177 profileReadmeHtml = renderMarkdown(md);
2178 break;
2179 }
2180 }
2181 }
2182 } catch {
2183 profileReadmeHtml = null;
2184 }
2185
efb11c5Claude2186 const displayName = ownerUser?.displayName || ownerName;
2187 const memberSince = ownerUser?.createdAt
2188 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2189 : null;
2190
fc1817aClaude2191 return c.html(
06d5ffeClaude2192 <Layout title={ownerName} user={user}>
efb11c5Claude2193 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2194 <div class="profile-hero">
2195 <div class="profile-hero-orb-wrap" aria-hidden="true">
2196 <div class="profile-hero-orb" />
06d5ffeClaude2197 </div>
efb11c5Claude2198 <div class="profile-hero-inner">
2199 <div class="profile-hero-avatar" aria-hidden="true">
2200 {displayName[0].toUpperCase()}
2201 </div>
2202 <div class="profile-hero-text">
2203 <div class="profile-eyebrow">
2204 <strong>Developer</strong>
2205 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2206 <>
2207 {" "}· Joined{" "}
2208 {memberSince.toLocaleDateString("en-US", {
2209 month: "short",
2210 year: "numeric",
2211 })}
2212 </>
2213 )}
2214 </div>
2215 <h1 class="profile-name">
2216 <span class="gradient-text">{displayName}</span>
2217 </h1>
2218 <div class="profile-handle">@{ownerName}</div>
2219 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2220 <div class="profile-meta">
2221 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2222 <strong>{followState.followers}</strong> follower
2223 {followState.followers === 1 ? "" : "s"}
2224 </a>
2225 <a href={`/${ownerName}/following`} class="profile-meta-link">
2226 <strong>{followState.following}</strong> following
2227 </a>
2228 <a href={`/${ownerName}`} class="profile-meta-link">
2229 <strong>{repos.length}</strong> repo
2230 {repos.length === 1 ? "" : "s"}
2231 </a>
2232 {canFollow && (
2233 <form
2234 method="post"
2235 action={`/${ownerName}/${
2236 followState.viewerFollows ? "unfollow" : "follow"
2237 }`}
2238 class="profile-follow-form"
7aa8b99Claude2239 >
efb11c5Claude2240 <button
2241 type="submit"
2242 class={`btn ${
2243 followState.viewerFollows ? "" : "btn-primary"
2244 } btn-sm`}
2245 >
2246 {followState.viewerFollows ? "Unfollow" : "Follow"}
2247 </button>
2248 </form>
2249 )}
2250 </div>
7aa8b99Claude2251 </div>
06d5ffeClaude2252 </div>
2253 </div>
d412586Claude2254 {profileReadmeHtml && (
efb11c5Claude2255 <div class="profile-readme">
2256 <div class="profile-readme-head">
2257 <span class="profile-readme-icon">{"☰"}</span>
2258 <span>{ownerName}/{ownerName} README.md</span>
2259 </div>
2260 <div
2261 class="markdown-body profile-readme-body"
2262 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2263 />
2264 </div>
d412586Claude2265 )}
efb11c5Claude2266 <div class="profile-section-head">
2267 <h2 class="profile-section-title">Repositories</h2>
2268 <span class="profile-section-count">{repos.length}</span>
2269 </div>
06d5ffeClaude2270 {repos.length === 0 ? (
efb11c5Claude2271 <div class="profile-empty">
2272 <p class="profile-empty-text">
2273 No repositories yet
2274 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2275 </p>
2276 {user?.id === ownerUser?.id && (
2277 <a href="/new" class="btn btn-primary btn-sm">
2278 + Create your first
2279 </a>
2280 )}
2281 </div>
06d5ffeClaude2282 ) : (
2283 <div class="card-grid">
2284 {repos.map((repo) => (
2285 <RepoCard repo={repo} ownerName={ownerName} />
2286 ))}
2287 </div>
2288 )}
fc1817aClaude2289 </Layout>
2290 );
2291});
2292
06d5ffeClaude2293// Star/unstar a repo
2294web.post("/:owner/:repo/star", requireAuth, async (c) => {
2295 const { owner: ownerName, repo: repoName } = c.req.param();
2296 const user = c.get("user")!;
2297
2298 try {
2299 const [ownerUser] = await db
2300 .select()
2301 .from(users)
2302 .where(eq(users.username, ownerName))
2303 .limit(1);
2304 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2305
2306 const [repo] = await db
2307 .select()
2308 .from(repositories)
2309 .where(
2310 and(
2311 eq(repositories.ownerId, ownerUser.id),
2312 eq(repositories.name, repoName)
2313 )
2314 )
2315 .limit(1);
2316 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2317
2318 // Toggle star
2319 const [existing] = await db
2320 .select()
2321 .from(stars)
2322 .where(
2323 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2324 )
2325 .limit(1);
2326
2327 if (existing) {
2328 await db.delete(stars).where(eq(stars.id, existing.id));
2329 await db
2330 .update(repositories)
2331 .set({ starCount: Math.max(0, repo.starCount - 1) })
2332 .where(eq(repositories.id, repo.id));
2333 } else {
2334 await db.insert(stars).values({
2335 userId: user.id,
2336 repositoryId: repo.id,
2337 });
2338 await db
2339 .update(repositories)
2340 .set({ starCount: repo.starCount + 1 })
2341 .where(eq(repositories.id, repo.id));
2342 }
2343 } catch {
2344 // DB error — ignore
2345 }
2346
2347 return c.redirect(`/${ownerName}/${repoName}`);
2348});
2349
fc1817aClaude2350// Repository overview — file tree at HEAD
2351web.get("/:owner/:repo", async (c) => {
2352 const { owner, repo } = c.req.param();
06d5ffeClaude2353 const user = c.get("user");
fc1817aClaude2354
f1dc7c7Claude2355 // ── Loading skeleton (flag-gated) ──
2356 // Renders an SSR'd shell with file-tree + README placeholders when
2357 // `?skeleton=1` is set. Lets the user see the page structure before
2358 // git ops finish. Behind a flag for now so we never flash before the
2359 // real content lands.
2360 if (c.req.query("skeleton") === "1") {
2361 return c.html(
2362 <Layout title={`${owner}/${repo}`} user={user}>
2363 <style
2364 dangerouslySetInnerHTML={{
2365 __html: `
2366 .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; }
2367 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2368 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2369 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2370 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2371 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2372 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2373 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2374 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2375 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2376 .repo-skel-readme { height: 320px; border-radius: 12px; }
2377 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2378 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2379 `,
2380 }}
2381 />
2382 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2383 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2384 <div class="repo-skel-grid" aria-hidden="true">
2385 <div>
2386 <div class="repo-skel repo-skel-branch" />
2387 <div class="repo-skel-tree">
2388 {Array.from({ length: 8 }).map(() => (
2389 <div class="repo-skel repo-skel-tree-row" />
2390 ))}
2391 </div>
2392 <div class="repo-skel repo-skel-readme" />
2393 </div>
2394 <aside class="repo-skel-side">
2395 <div class="repo-skel repo-skel-side-card" />
2396 <div class="repo-skel repo-skel-side-card" />
2397 </aside>
2398 </div>
2399 <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">
2400 Loading {owner}/{repo}…
2401 </span>
2402 </Layout>
2403 );
2404 }
2405
8f50ed0Claude2406 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2407 trackByName(owner, repo, "view", {
2408 userId: user?.id || null,
2409 path: `/${owner}/${repo}`,
2410 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2411 userAgent: c.req.header("user-agent") || null,
2412 referer: c.req.header("referer") || null,
a28cedeClaude2413 }).catch((err) => {
2414 console.warn(
2415 `[web] view tracking failed for ${owner}/${repo}:`,
2416 err instanceof Error ? err.message : err
2417 );
2418 });
8f50ed0Claude2419
fc1817aClaude2420 if (!(await repoExists(owner, repo))) {
2421 return c.html(
06d5ffeClaude2422 <Layout title="Not Found" user={user}>
fc1817aClaude2423 <div class="empty-state">
2424 <h2>Repository not found</h2>
2425 <p>
2426 {owner}/{repo} does not exist.
2427 </p>
2428 </div>
2429 </Layout>,
2430 404
2431 );
2432 }
2433
05b973eClaude2434 // Parallelize all independent operations
2435 const [defaultBranch, branches] = await Promise.all([
2436 getDefaultBranch(owner, repo).then((b) => b || "main"),
2437 listBranches(owner, repo),
2438 ]);
2439 const [tree, starInfo] = await Promise.all([
2440 getTree(owner, repo, defaultBranch),
2441 // Star info fetched in parallel with tree
2442 (async () => {
2443 try {
2444 const [ownerUser] = await db
2445 .select()
2446 .from(users)
2447 .where(eq(users.username, owner))
2448 .limit(1);
71cd5ecClaude2449 if (!ownerUser)
2450 return {
2451 starCount: 0,
2452 starred: false,
2453 archived: false,
2454 isTemplate: false,
544d842Claude2455 forkCount: 0,
2456 description: null as string | null,
2457 pushedAt: null as Date | null,
2458 createdAt: null as Date | null,
cb5a796Claude2459 repoId: null as string | null,
2460 repoOwnerId: null as string | null,
71cd5ecClaude2461 };
05b973eClaude2462 const [repoRow] = await db
2463 .select()
2464 .from(repositories)
2465 .where(
2466 and(
2467 eq(repositories.ownerId, ownerUser.id),
2468 eq(repositories.name, repo)
2469 )
06d5ffeClaude2470 )
05b973eClaude2471 .limit(1);
71cd5ecClaude2472 if (!repoRow)
2473 return {
2474 starCount: 0,
2475 starred: false,
2476 archived: false,
2477 isTemplate: false,
544d842Claude2478 forkCount: 0,
2479 description: null as string | null,
2480 pushedAt: null as Date | null,
2481 createdAt: null as Date | null,
cb5a796Claude2482 repoId: null as string | null,
2483 repoOwnerId: null as string | null,
71cd5ecClaude2484 };
05b973eClaude2485 let starred = false;
06d5ffeClaude2486 if (user) {
2487 const [star] = await db
2488 .select()
2489 .from(stars)
2490 .where(
2491 and(
2492 eq(stars.userId, user.id),
2493 eq(stars.repositoryId, repoRow.id)
2494 )
2495 )
2496 .limit(1);
2497 starred = !!star;
2498 }
71cd5ecClaude2499 return {
2500 starCount: repoRow.starCount,
2501 starred,
2502 archived: repoRow.isArchived,
2503 isTemplate: repoRow.isTemplate,
544d842Claude2504 forkCount: repoRow.forkCount,
2505 description: repoRow.description as string | null,
2506 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2507 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2508 repoId: repoRow.id as string,
2509 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2510 };
05b973eClaude2511 } catch {
71cd5ecClaude2512 return {
2513 starCount: 0,
2514 starred: false,
2515 archived: false,
2516 isTemplate: false,
544d842Claude2517 forkCount: 0,
2518 description: null as string | null,
2519 pushedAt: null as Date | null,
2520 createdAt: null as Date | null,
cb5a796Claude2521 repoId: null as string | null,
2522 repoOwnerId: null as string | null,
71cd5ecClaude2523 };
06d5ffeClaude2524 }
05b973eClaude2525 })(),
2526 ]);
544d842Claude2527 const {
2528 starCount,
2529 starred,
2530 archived,
2531 isTemplate,
2532 forkCount,
2533 description,
2534 pushedAt,
2535 createdAt,
cb5a796Claude2536 repoId,
2537 repoOwnerId,
544d842Claude2538 } = starInfo;
2539
91a0204Claude2540 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2541 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2542 // and the badge simply doesn't render.
2543 let healthScore: HealthScore | null = null;
2544 if (repoId) {
2545 try {
2546 healthScore = await computeHealthScore(repoId);
2547 } catch {
2548 // swallow — badge is optional
2549 }
2550 }
2551
cb5a796Claude2552 // Pending-comments banner data (lazy + best-effort). Only the repo
2553 // owner sees the banner, so non-owner views skip the DB hit entirely.
2554 let repoHomePendingCount = 0;
2555 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2556 try {
2557 const { countPendingForRepo } = await import(
2558 "../lib/comment-moderation"
2559 );
2560 repoHomePendingCount = await countPendingForRepo(repoId);
2561 } catch {
2562 /* swallow */
2563 }
2564 }
2565
8c790e0Claude2566 // Push Watch discoverability — fetch the most recent push within 24 h.
2567 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2568 const recentPush: RecentPush | null = repoId
2569 ? await getRecentPush(repoId)
2570 : null;
2571
ebaae0fClaude2572 // AI stats strip — best-effort, fail-open to zero values.
2573 let aiStats: RepoAiStats = {
2574 mergedCount: 0,
2575 reviewCount: 0,
2576 securityAlertCount: 0,
2577 hoursSaved: "0.0",
2578 };
2579 if (repoId) {
2580 try {
2581 aiStats = await getRepoAiStats(repoId);
2582 } catch {
2583 /* swallow — show zero values */
2584 }
2585 }
2586
544d842Claude2587 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2588 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2589 const repoHomeCss = `
2590 .repo-home-hero {
2591 position: relative;
2592 margin-bottom: var(--space-5);
2593 padding: var(--space-5) var(--space-6);
2594 background: var(--bg-elevated);
2595 border: 1px solid var(--border);
2596 border-radius: 16px;
2597 overflow: hidden;
2598 }
2599 .repo-home-hero::before {
2600 content: '';
2601 position: absolute;
2602 top: 0; left: 0; right: 0;
2603 height: 2px;
2604 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2605 opacity: 0.7;
2606 pointer-events: none;
2607 }
2608 .repo-home-hero-orb-wrap {
2609 position: absolute;
2610 inset: -25% -10% auto auto;
2611 width: 360px;
2612 height: 360px;
2613 pointer-events: none;
2614 z-index: 0;
2615 }
2616 .repo-home-hero-orb {
2617 position: absolute;
2618 inset: 0;
2619 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
2620 filter: blur(80px);
2621 opacity: 0.7;
2622 animation: repoHomeOrb 14s ease-in-out infinite;
2623 }
2624 @keyframes repoHomeOrb {
2625 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
2626 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
2627 }
2628 @media (prefers-reduced-motion: reduce) {
2629 .repo-home-hero-orb { animation: none; }
2630 }
2631 .repo-home-hero-inner {
2632 position: relative;
2633 z-index: 1;
2634 }
2635 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
2636 .repo-home-eyebrow {
2637 font-size: 12px;
2638 font-family: var(--font-mono);
2639 color: var(--text-muted);
2640 letter-spacing: 0.1em;
2641 text-transform: uppercase;
2642 margin-bottom: var(--space-2);
2643 }
2644 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
2645 .repo-home-description {
2646 font-size: 15px;
2647 line-height: 1.55;
2648 color: var(--text);
2649 margin: 0;
2650 max-width: 720px;
2651 }
2652 .repo-home-description-empty {
2653 font-size: 14px;
2654 color: var(--text-muted);
2655 font-style: italic;
2656 margin: 0;
2657 }
2658 .repo-home-stat-row {
2659 display: flex;
2660 flex-wrap: wrap;
2661 gap: var(--space-4);
2662 margin-top: var(--space-3);
2663 font-size: 13px;
2664 color: var(--text-muted);
2665 }
2666 .repo-home-stat {
2667 display: inline-flex;
2668 align-items: center;
2669 gap: 6px;
2670 }
2671 .repo-home-stat strong {
2672 color: var(--text-strong);
2673 font-weight: 600;
2674 font-variant-numeric: tabular-nums;
2675 }
2676 .repo-home-stat .repo-home-stat-icon {
2677 color: var(--text-faint);
2678 font-size: 14px;
2679 line-height: 1;
2680 }
2681 .repo-home-stat a {
2682 color: var(--text-muted);
2683 transition: color var(--t-fast) var(--ease);
2684 }
2685 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
2686
2687 /* Two-column layout: file tree + sidebar */
2688 .repo-home-grid {
2689 display: grid;
2690 grid-template-columns: minmax(0, 1fr) 280px;
2691 gap: var(--space-5);
2692 align-items: start;
2693 }
2694 @media (max-width: 960px) {
2695 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
2696 }
2697 .repo-home-main { min-width: 0; }
2698
2699 /* Sidebar card */
2700 .repo-home-side {
2701 display: flex;
2702 flex-direction: column;
2703 gap: var(--space-4);
2704 }
2705 .repo-home-side-card {
2706 background: var(--bg-elevated);
2707 border: 1px solid var(--border);
2708 border-radius: 12px;
2709 padding: var(--space-4);
2710 }
2711 .repo-home-side-title {
2712 font-size: 11px;
2713 font-family: var(--font-mono);
2714 letter-spacing: 0.12em;
2715 text-transform: uppercase;
2716 color: var(--text-muted);
2717 margin: 0 0 var(--space-3);
2718 font-weight: 600;
2719 }
2720 .repo-home-side-row {
2721 display: flex;
2722 justify-content: space-between;
2723 align-items: center;
2724 gap: var(--space-2);
2725 font-size: 13px;
2726 padding: 6px 0;
2727 border-top: 1px solid var(--border);
2728 }
2729 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
2730 .repo-home-side-key {
2731 color: var(--text-muted);
2732 display: inline-flex;
2733 align-items: center;
2734 gap: 6px;
2735 }
2736 .repo-home-side-val {
2737 color: var(--text-strong);
2738 font-weight: 500;
2739 font-variant-numeric: tabular-nums;
2740 max-width: 60%;
2741 text-align: right;
2742 overflow: hidden;
2743 text-overflow: ellipsis;
2744 white-space: nowrap;
2745 }
2746 .repo-home-side-val a { color: var(--text-strong); }
2747 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
2748
2749 /* Clone / Code tabs */
2750 .repo-home-clone {
2751 background: var(--bg-elevated);
2752 border: 1px solid var(--border);
2753 border-radius: 12px;
2754 overflow: hidden;
2755 }
2756 .repo-home-clone-tabs {
2757 display: flex;
2758 gap: 0;
2759 background: var(--bg-secondary);
2760 border-bottom: 1px solid var(--border);
2761 padding: 0 var(--space-2);
2762 }
2763 .repo-home-clone-tab {
2764 appearance: none;
2765 background: transparent;
2766 border: 0;
2767 border-bottom: 2px solid transparent;
2768 padding: 9px 12px;
2769 font-size: 12px;
2770 font-weight: 500;
2771 color: var(--text-muted);
2772 cursor: pointer;
2773 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2774 font-family: inherit;
2775 margin-bottom: -1px;
2776 }
2777 .repo-home-clone-tab:hover { color: var(--text-strong); }
2778 .repo-home-clone-tab[aria-selected="true"] {
2779 color: var(--text-strong);
2780 border-bottom-color: var(--accent);
2781 }
2782 .repo-home-clone-body {
2783 padding: var(--space-3);
2784 display: flex;
2785 align-items: center;
2786 gap: var(--space-2);
2787 }
2788 .repo-home-clone-input {
2789 flex: 1;
2790 min-width: 0;
2791 font-family: var(--font-mono);
2792 font-size: 12px;
2793 background: var(--bg);
2794 border: 1px solid var(--border);
2795 border-radius: 8px;
2796 padding: 8px 10px;
2797 color: var(--text-strong);
2798 overflow: hidden;
2799 text-overflow: ellipsis;
2800 white-space: nowrap;
2801 }
2802 .repo-home-clone-copy {
2803 appearance: none;
2804 background: var(--bg-secondary);
2805 border: 1px solid var(--border);
2806 color: var(--text-strong);
2807 border-radius: 8px;
2808 padding: 8px 12px;
2809 font-size: 12px;
2810 font-weight: 600;
2811 cursor: pointer;
2812 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2813 font-family: inherit;
2814 }
2815 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
2816 .repo-home-clone-pane { display: none; }
2817 .repo-home-clone-pane[data-active="true"] { display: flex; }
2818
2819 /* README card */
2820 .repo-home-readme {
2821 margin-top: var(--space-5);
2822 background: var(--bg-elevated);
2823 border: 1px solid var(--border);
2824 border-radius: 12px;
2825 overflow: hidden;
2826 }
2827 .repo-home-readme-head {
2828 display: flex;
2829 align-items: center;
2830 gap: 8px;
2831 padding: 10px 16px;
2832 background: var(--bg-secondary);
2833 border-bottom: 1px solid var(--border);
2834 font-size: 13px;
2835 color: var(--text-muted);
2836 }
2837 .repo-home-readme-head .repo-home-readme-icon {
2838 color: var(--accent);
2839 font-size: 14px;
2840 }
2841 .repo-home-readme-body {
2842 padding: var(--space-5) var(--space-6);
2843 }
2844
2845 /* Empty-state CTA */
2846 .repo-home-empty {
2847 position: relative;
2848 margin-top: var(--space-4);
2849 background: var(--bg-elevated);
2850 border: 1px solid var(--border);
2851 border-radius: 16px;
2852 padding: var(--space-6);
2853 overflow: hidden;
2854 }
2855 .repo-home-empty::before {
2856 content: '';
2857 position: absolute;
2858 top: 0; left: 0; right: 0;
2859 height: 2px;
2860 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2861 opacity: 0.7;
2862 pointer-events: none;
2863 }
2864 .repo-home-empty-eyebrow {
2865 font-size: 12px;
2866 font-family: var(--font-mono);
2867 color: var(--accent);
2868 letter-spacing: 0.12em;
2869 text-transform: uppercase;
2870 margin-bottom: var(--space-2);
2871 font-weight: 600;
2872 }
2873 .repo-home-empty-title {
2874 font-family: var(--font-display);
2875 font-weight: 800;
2876 letter-spacing: -0.025em;
2877 font-size: clamp(22px, 3vw, 30px);
2878 line-height: 1.1;
2879 margin: 0 0 var(--space-2);
2880 color: var(--text-strong);
2881 }
2882 .repo-home-empty-sub {
2883 color: var(--text-muted);
2884 font-size: 14px;
2885 line-height: 1.55;
2886 max-width: 640px;
2887 margin: 0 0 var(--space-4);
2888 }
2889 .repo-home-empty-snippet {
2890 background: var(--bg);
2891 border: 1px solid var(--border);
2892 border-radius: 10px;
2893 padding: var(--space-3) var(--space-4);
2894 font-family: var(--font-mono);
2895 font-size: 12.5px;
2896 line-height: 1.7;
2897 color: var(--text-strong);
2898 overflow-x: auto;
2899 white-space: pre;
2900 margin: 0;
2901 }
2902 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2903 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2904
91a0204Claude2905 /* Health score badge */
2906 .repo-health-badge {
2907 display: inline-flex;
2908 align-items: center;
2909 gap: 5px;
2910 padding: 3px 10px;
2911 border-radius: 999px;
2912 font-size: 11.5px;
2913 font-weight: 700;
2914 font-family: var(--font-mono);
2915 text-decoration: none;
2916 border: 1px solid transparent;
2917 transition: filter 120ms ease, opacity 120ms ease;
2918 vertical-align: middle;
2919 }
2920 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
2921 .repo-health-badge-elite {
2922 background: rgba(52,211,153,0.15);
2923 color: #6ee7b7;
2924 border-color: rgba(52,211,153,0.30);
2925 }
2926 .repo-health-badge-strong {
2927 background: rgba(96,165,250,0.15);
2928 color: #93c5fd;
2929 border-color: rgba(96,165,250,0.30);
2930 }
2931 .repo-health-badge-improving {
2932 background: rgba(251,191,36,0.15);
2933 color: #fde68a;
2934 border-color: rgba(251,191,36,0.30);
2935 }
2936 .repo-health-badge-needs-attention {
2937 background: rgba(248,113,113,0.15);
2938 color: #fca5a5;
2939 border-color: rgba(248,113,113,0.30);
2940 }
2941
2942 /* Three-option empty-state panel */
2943 .repo-empty-options {
2944 display: grid;
2945 grid-template-columns: repeat(3, 1fr);
2946 gap: var(--space-3);
2947 margin-top: var(--space-5);
2948 }
2949 @media (max-width: 760px) {
2950 .repo-empty-options { grid-template-columns: 1fr; }
2951 }
2952 .repo-empty-option {
2953 position: relative;
2954 background: var(--bg-elevated);
2955 border: 1px solid var(--border);
2956 border-radius: 14px;
2957 padding: var(--space-4) var(--space-4);
2958 display: flex;
2959 flex-direction: column;
2960 gap: var(--space-2);
2961 overflow: hidden;
2962 transition: border-color 140ms ease, background 140ms ease;
2963 }
2964 .repo-empty-option:hover {
2965 border-color: rgba(140,109,255,0.45);
2966 background: rgba(140,109,255,0.04);
2967 }
2968 .repo-empty-option::before {
2969 content: '';
2970 position: absolute;
2971 top: 0; left: 0; right: 0;
2972 height: 2px;
2973 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2974 opacity: 0.55;
2975 pointer-events: none;
2976 }
2977 .repo-empty-option-label {
2978 font-size: 10px;
2979 font-family: var(--font-mono);
2980 font-weight: 700;
2981 letter-spacing: 0.12em;
2982 text-transform: uppercase;
2983 color: var(--accent);
2984 margin-bottom: 2px;
2985 }
2986 .repo-empty-option-title {
2987 font-family: var(--font-display);
2988 font-weight: 700;
2989 font-size: 16px;
2990 letter-spacing: -0.01em;
2991 color: var(--text-strong);
2992 margin: 0;
2993 }
2994 .repo-empty-option-sub {
2995 font-size: 13px;
2996 color: var(--text-muted);
2997 line-height: 1.5;
2998 margin: 0;
2999 flex: 1;
3000 }
3001 .repo-empty-option-snippet {
3002 background: var(--bg);
3003 border: 1px solid var(--border);
3004 border-radius: 8px;
3005 padding: var(--space-2) var(--space-3);
3006 font-family: var(--font-mono);
3007 font-size: 11.5px;
3008 line-height: 1.75;
3009 color: var(--text-strong);
3010 overflow-x: auto;
3011 white-space: pre;
3012 margin: var(--space-1) 0 0;
3013 }
3014 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3015 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3016 .repo-empty-option-cta {
3017 display: inline-flex;
3018 align-items: center;
3019 gap: 6px;
3020 margin-top: auto;
3021 padding-top: var(--space-2);
3022 font-size: 13px;
3023 font-weight: 600;
3024 color: var(--accent);
3025 text-decoration: none;
3026 transition: color 120ms ease;
3027 }
3028 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3029
544d842Claude3030 @media (max-width: 720px) {
3031 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3032 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3033 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3034 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3035 .repo-home-side-val { max-width: 55%; }
3036 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3037 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3038 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3039 }
ebaae0fClaude3040
3041 /* AI stats strip */
3042 .repo-ai-stats-strip {
3043 display: flex;
3044 align-items: center;
3045 gap: 6px;
3046 margin-top: 12px;
3047 margin-bottom: 4px;
3048 font-size: 12px;
3049 color: var(--text-muted);
3050 flex-wrap: wrap;
3051 }
3052 .repo-ai-stats-strip a {
3053 color: var(--text-muted);
3054 text-decoration: underline;
3055 text-decoration-color: transparent;
3056 transition: color 120ms ease, text-decoration-color 120ms ease;
3057 }
3058 .repo-ai-stats-strip a:hover {
3059 color: var(--accent);
3060 text-decoration-color: currentColor;
3061 }
3062 .repo-ai-stats-sep {
3063 opacity: 0.4;
3064 user-select: none;
3065 }
544d842Claude3066 `;
3067 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3068 // SSH URL — port-aware:
3069 // Standard port 22 → git@host:owner/repo.git
3070 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3071 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3072 try {
3073 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3074 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3075 ? host
3076 : "localhost";
3077 const sshPort = config.sshPort;
3078 if (sshPort === 22) {
3079 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3080 } else {
3081 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3082 }
3083 } catch {
3084 // Fall through to default.
3085 }
3086 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3087 const formatRelative = (date: Date | null): string => {
3088 if (!date) return "never";
3089 const ms = Date.now() - date.getTime();
3090 const s = Math.max(0, Math.round(ms / 1000));
3091 if (s < 60) return "just now";
3092 const m = Math.round(s / 60);
3093 if (m < 60) return `${m} min ago`;
3094 const h = Math.round(m / 60);
3095 if (h < 24) return `${h}h ago`;
3096 const d = Math.round(h / 24);
3097 if (d < 30) return `${d}d ago`;
3098 const mo = Math.round(d / 30);
3099 if (mo < 12) return `${mo}mo ago`;
3100 const y = Math.round(d / 365);
3101 return `${y}y ago`;
3102 };
06d5ffeClaude3103
fc1817aClaude3104 if (tree.length === 0) {
5acce80Claude3105 const repoOgDesc = description
3106 ? `${owner}/${repo} on Gluecron — ${description}`
3107 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3108 return c.html(
5acce80Claude3109 <Layout
3110 title={`${owner}/${repo}`}
3111 user={user}
3112 description={repoOgDesc}
3113 ogTitle={`${owner}/${repo} — Gluecron`}
3114 ogDescription={repoOgDesc}
3115 twitterCard="summary"
3116 >
544d842Claude3117 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3118 <style dangerouslySetInnerHTML={{ __html: `
3119 .empty-options-grid {
3120 display: grid;
3121 grid-template-columns: repeat(3, 1fr);
3122 gap: var(--space-4);
3123 margin-top: var(--space-4);
3124 }
3125 @media (max-width: 800px) {
3126 .empty-options-grid { grid-template-columns: 1fr; }
3127 }
3128 .empty-option-card {
3129 position: relative;
3130 background: var(--bg-elevated);
3131 border: 1px solid var(--border);
3132 border-radius: 14px;
3133 padding: var(--space-5);
3134 display: flex;
3135 flex-direction: column;
3136 gap: var(--space-3);
3137 overflow: hidden;
3138 transition: border-color 140ms ease, box-shadow 140ms ease;
3139 }
3140 .empty-option-card:hover {
3141 border-color: rgba(140,109,255,0.45);
3142 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3143 }
3144 .empty-option-card::before {
3145 content: '';
3146 position: absolute;
3147 top: 0; left: 0; right: 0;
3148 height: 2px;
3149 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3150 opacity: 0.55;
3151 pointer-events: none;
3152 }
3153 .empty-option-label {
3154 font-size: 10.5px;
3155 font-family: var(--font-mono);
3156 text-transform: uppercase;
3157 letter-spacing: 0.14em;
3158 color: var(--accent);
3159 font-weight: 700;
3160 }
3161 .empty-option-title {
3162 font-family: var(--font-display);
3163 font-weight: 700;
3164 font-size: 17px;
3165 letter-spacing: -0.01em;
3166 color: var(--text-strong);
3167 margin: 0;
3168 line-height: 1.25;
3169 }
3170 .empty-option-body {
3171 font-size: 13px;
3172 color: var(--text-muted);
3173 line-height: 1.55;
3174 margin: 0;
3175 flex: 1;
3176 }
3177 .empty-option-snippet {
3178 background: var(--bg);
3179 border: 1px solid var(--border);
3180 border-radius: 8px;
3181 padding: var(--space-3) var(--space-4);
3182 font-family: var(--font-mono);
3183 font-size: 11.5px;
3184 line-height: 1.75;
3185 color: var(--text-strong);
3186 overflow-x: auto;
3187 white-space: pre;
3188 margin: 0;
3189 }
3190 .empty-option-snippet .ec { color: var(--text-faint); }
3191 .empty-option-snippet .em { color: var(--accent); }
3192 .empty-option-cta {
3193 display: inline-flex;
3194 align-items: center;
3195 gap: 6px;
3196 padding: 8px 16px;
3197 font-size: 13px;
3198 font-weight: 600;
3199 color: var(--text-strong);
3200 background: var(--bg-secondary);
3201 border: 1px solid var(--border);
3202 border-radius: 9999px;
3203 text-decoration: none;
3204 align-self: flex-start;
3205 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3206 }
3207 .empty-option-cta:hover {
3208 border-color: rgba(140,109,255,0.55);
3209 background: rgba(140,109,255,0.08);
3210 color: var(--accent);
3211 text-decoration: none;
3212 }
3213 .empty-option-cta-accent {
3214 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3215 border-color: transparent;
3216 color: #fff;
3217 }
3218 .empty-option-cta-accent:hover {
3219 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3220 border-color: transparent;
3221 color: #fff;
3222 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3223 }
3224 ` }} />
544d842Claude3225 <div class="repo-home-hero">
3226 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3227 <div class="repo-home-hero-orb" />
3228 </div>
3229 <div class="repo-home-hero-inner">
3230 <div class="repo-home-eyebrow">
3231 <strong>Repository</strong> · {owner}
3232 </div>
91a0204Claude3233 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3234 <RepoHeader
3235 owner={owner}
3236 repo={repo}
3237 starCount={starCount}
3238 starred={starred}
3239 forkCount={forkCount}
3240 currentUser={user?.username}
3241 archived={archived}
3242 isTemplate={isTemplate}
8c790e0Claude3243 recentPush={recentPush}
91a0204Claude3244 />
3245 {healthScore && (() => {
3246 const gradeLabel: Record<string, string> = {
3247 elite: "Elite", strong: "Strong",
3248 improving: "Improving", "needs-attention": "Needs Attention",
3249 };
3250 return (
3251 <a
3252 href={`/${owner}/${repo}/insights/health`}
3253 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3254 title={`Health score: ${healthScore!.total}/100`}
3255 >
3256 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3257 </a>
3258 );
3259 })()}
3260 </div>
544d842Claude3261 {description ? (
3262 <p class="repo-home-description">{description}</p>
3263 ) : (
3264 <p class="repo-home-description-empty">
3265 No description yet — push a README to tell the world what this
3266 ships.
3267 </p>
3268 )}
3269 </div>
3270 </div>
fc1817aClaude3271 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3272 <div style="margin-top:var(--space-4)">
3273 <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)">
3274 Getting started
3275 </div>
544d842Claude3276 <h2 class="repo-home-empty-title">
91a0204Claude3277 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3278 </h2>
3279 <p class="repo-home-empty-sub">
91a0204Claude3280 Choose how you want to kick things off. Every push wires up gate
3281 checks and AI review automatically.
544d842Claude3282 </p>
91a0204Claude3283 <div class="empty-options-grid">
3284 <div class="empty-option-card">
3285 <div class="empty-option-label">Option A</div>
3286 <h3 class="empty-option-title">Push your first commit</h3>
3287 <p class="empty-option-body">
3288 Wire up an existing project in seconds. Run these commands from
3289 your project directory.
3290 </p>
3291 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3292<span class="em">git remote add</span> origin {cloneHttpsUrl}
3293<span class="em">git branch</span> -M main
3294<span class="em">git push</span> -u origin main</pre>
3295 </div>
3296 <div class="empty-option-card">
3297 <div class="empty-option-label">Option B</div>
3298 <h3 class="empty-option-title">Import from GitHub</h3>
3299 <p class="empty-option-body">
3300 Mirror an existing GitHub repository here in one click. Gluecron
3301 syncs the full history and branches.
3302 </p>
3303 <a href="/import" class="empty-option-cta">
3304 Import repository
3305 </a>
3306 </div>
3307 <div class="empty-option-card">
3308 <div class="empty-option-label">Option C</div>
3309 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3310 <p class="empty-option-body">
3311 Let AI write your first feature. Describe what you want to build
3312 and Gluecron opens a pull request with the code.
3313 </p>
3314 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3315 Let AI write your first feature
3316 </a>
3317 </div>
3318 </div>
fc1817aClaude3319 </div>
3320 </Layout>
3321 );
3322 }
3323
3324 const readme = await getReadme(owner, repo, defaultBranch);
3325
544d842Claude3326 // Sidebar facts — derived from data we already have.
3327 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3328 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3329
5acce80Claude3330 const repoOgDesc = description
3331 ? `${owner}/${repo} on Gluecron — ${description}`
3332 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3333
fc1817aClaude3334 return c.html(
5acce80Claude3335 <Layout
3336 title={`${owner}/${repo}`}
3337 user={user}
3338 description={repoOgDesc}
3339 ogTitle={`${owner}/${repo} — Gluecron`}
3340 ogDescription={repoOgDesc}
3341 twitterCard="summary"
3342 >
544d842Claude3343 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3344 <div class="repo-home-hero">
3345 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3346 <div class="repo-home-hero-orb" />
3347 </div>
3348 <div class="repo-home-hero-inner">
3349 <div class="repo-home-eyebrow">
3350 <strong>Repository</strong> · {owner}
3351 </div>
91a0204Claude3352 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3353 <RepoHeader
3354 owner={owner}
3355 repo={repo}
3356 starCount={starCount}
3357 starred={starred}
3358 forkCount={forkCount}
3359 currentUser={user?.username}
3360 archived={archived}
3361 isTemplate={isTemplate}
8c790e0Claude3362 recentPush={recentPush}
91a0204Claude3363 />
3364 {healthScore && (() => {
3365 const gradeLabel: Record<string, string> = {
3366 elite: "Elite", strong: "Strong",
3367 improving: "Improving", "needs-attention": "Needs Attention",
3368 };
3369 return (
3370 <a
3371 href={`/${owner}/${repo}/insights/health`}
3372 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3373 title={`Health score: ${healthScore!.total}/100`}
3374 >
3375 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3376 </a>
3377 );
3378 })()}
3379 </div>
544d842Claude3380 {description ? (
3381 <p class="repo-home-description">{description}</p>
3382 ) : (
3383 <p class="repo-home-description-empty">
3384 No description yet.
3385 </p>
3386 )}
3387 <div class="repo-home-stat-row" aria-label="Repository stats">
3388 <span class="repo-home-stat" title="Default branch">
3389 <span class="repo-home-stat-icon">{"⎇"}</span>
3390 <strong>{defaultBranch}</strong>
3391 </span>
3392 <a
3393 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3394 class="repo-home-stat"
3395 title="Browse all branches"
3396 >
3397 <span class="repo-home-stat-icon">{"⊢"}</span>
3398 <strong>{branches.length}</strong>{" "}
3399 branch{branches.length === 1 ? "" : "es"}
3400 </a>
3401 <span class="repo-home-stat" title="Top-level entries">
3402 <span class="repo-home-stat-icon">{"■"}</span>
3403 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3404 {dirCount > 0 && (
3405 <>
3406 {" · "}
3407 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3408 </>
3409 )}
3410 </span>
3411 {pushedAt && (
3412 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3413 <span class="repo-home-stat-icon">{"↻"}</span>
3414 Updated <strong>{formatRelative(pushedAt)}</strong>
3415 </span>
3416 )}
3417 </div>
3418 </div>
3419 </div>
71cd5ecClaude3420 {isTemplate && user && user.username !== owner && (
3421 <div
3422 class="panel"
dc26881CC LABS App3423 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3424 >
3425 <div style="font-size:13px">
3426 <strong>Template repository.</strong> Create a new repository from
3427 this template's files.
3428 </div>
3429 <form
001af43Claude3430 method="post"
71cd5ecClaude3431 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3432 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3433 >
3434 <input
3435 type="text"
3436 name="name"
3437 placeholder="new-repo-name"
3438 required
2c3ba6ecopilot-swe-agent[bot]3439 aria-label="New repository name"
71cd5ecClaude3440 style="width:200px"
3441 />
3442 <button type="submit" class="btn btn-primary">
3443 Use this template
3444 </button>
3445 </form>
3446 </div>
3447 )}
fc1817aClaude3448 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3449 <RepoHomePendingBanner
3450 owner={owner}
3451 repo={repo}
3452 count={repoHomePendingCount}
3453 />
c6018a5Claude3454 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3455 row sits just below the nav as a slim CTA strip. Scoped under
3456 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3457 <style
3458 dangerouslySetInnerHTML={{
3459 __html: `
3460 .repo-ai-cta-row {
3461 display: flex;
3462 flex-wrap: wrap;
3463 gap: 8px;
3464 margin: 12px 0 18px;
3465 padding: 10px 14px;
3466 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3467 border: 1px solid var(--border);
3468 border-radius: 10px;
3469 position: relative;
3470 overflow: hidden;
3471 }
3472 .repo-ai-cta-row::before {
3473 content: '';
3474 position: absolute;
3475 top: 0; left: 0; right: 0;
3476 height: 1px;
3477 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3478 opacity: 0.7;
3479 pointer-events: none;
3480 }
3481 .repo-ai-cta-label {
3482 font-size: 11px;
3483 font-weight: 600;
3484 letter-spacing: 0.06em;
3485 text-transform: uppercase;
3486 color: var(--accent);
3487 align-self: center;
3488 padding-right: 8px;
3489 border-right: 1px solid var(--border);
3490 margin-right: 4px;
3491 }
3492 .repo-ai-cta {
3493 display: inline-flex;
3494 align-items: center;
3495 gap: 6px;
3496 padding: 5px 10px;
3497 font-size: 12.5px;
3498 font-weight: 500;
3499 color: var(--text);
3500 background: var(--bg);
3501 border: 1px solid var(--border);
3502 border-radius: 7px;
3503 text-decoration: none;
3504 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3505 }
3506 .repo-ai-cta:hover {
3507 border-color: rgba(140,109,255,0.45);
3508 color: var(--text-strong);
3509 background: rgba(140,109,255,0.06);
3510 text-decoration: none;
3511 }
3512 .repo-ai-cta-icon {
3513 opacity: 0.75;
3514 font-size: 12px;
3515 }
3516 @media (max-width: 640px) {
3517 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3518 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3519 }
3520 `,
3521 }}
3522 />
3523 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3524 <span class="repo-ai-cta-label">AI surfaces</span>
3525 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3526 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3527 Chat
3528 </a>
3529 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3530 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3531 Previews
3532 </a>
3533 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3534 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3535 Migrations
3536 </a>
3537 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3538 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3539 Semantic search
3540 </a>
3541 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3542 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3543 AI release notes
3544 </a>
3646bfeClaude3545 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3546 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3547 Dev environment
3548 </a>
c6018a5Claude3549 </nav>
544d842Claude3550 <div class="repo-home-grid">
3551 <div class="repo-home-main">
3552 <BranchSwitcher
3553 owner={owner}
3554 repo={repo}
3555 currentRef={defaultBranch}
3556 branches={branches}
3557 pathType="tree"
3558 />
3559 <FileTable
3560 entries={tree}
3561 owner={owner}
3562 repo={repo}
3563 ref={defaultBranch}
3564 path=""
3565 />
ebaae0fClaude3566 {/* AI stats strip — one-line summary of AI value for this repo */}
3567 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3568 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3569 <span>{"⚡"}</span>
3570 {aiStats.mergedCount > 0 ? (
3571 <a href={`/${owner}/${repo}/pulls?state=merged`}>
3572 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
3573 </a>
3574 ) : (
3575 <span>0 AI merges this week</span>
3576 )}
3577 <span class="repo-ai-stats-sep">{"·"}</span>
3578 <span>Saved ~{aiStats.hoursSaved} hrs</span>
3579 <span class="repo-ai-stats-sep">{"·"}</span>
3580 {aiStats.securityAlertCount > 0 ? (
3581 <a href={`/${owner}/${repo}/issues?label=security`}>
3582 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
3583 </a>
3584 ) : (
3585 <span>0 open security alerts</span>
3586 )}
3587 </div>
3588 ) : (
3589 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3590 <span>{"⚡"}</span>
3591 <span>AI is watching — no activity this week</span>
3592 </div>
3593 )}
544d842Claude3594 {readme && (() => {
3595 const readmeHtml = renderMarkdown(readme);
3596 return (
3597 <div class="repo-home-readme">
3598 <div class="repo-home-readme-head">
3599 <span class="repo-home-readme-icon">{"☰"}</span>
3600 <span>README.md</span>
3601 </div>
3602 <style>{markdownCss}</style>
3603 <div class="markdown-body repo-home-readme-body">
3604 {html([readmeHtml] as unknown as TemplateStringsArray)}
3605 </div>
3606 </div>
3607 );
3608 })()}
3609 </div>
3610 <aside class="repo-home-side" aria-label="Repository details">
3611 <div class="repo-home-clone">
3612 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
3613 <button
3614 type="button"
3615 class="repo-home-clone-tab"
3616 role="tab"
3617 aria-selected="true"
3618 data-pane="https"
3619 data-repo-home-clone-tab
3620 >
3621 HTTPS
3622 </button>
3623 <button
3624 type="button"
3625 class="repo-home-clone-tab"
3626 role="tab"
3627 aria-selected="false"
3628 data-pane="ssh"
3629 data-repo-home-clone-tab
3630 >
3631 SSH
3632 </button>
3633 <button
3634 type="button"
3635 class="repo-home-clone-tab"
3636 role="tab"
3637 aria-selected="false"
3638 data-pane="cli"
3639 data-repo-home-clone-tab
3640 >
3641 CLI
3642 </button>
3643 </div>
3644 <div
3645 class="repo-home-clone-pane"
3646 data-pane="https"
3647 data-active="true"
3648 role="tabpanel"
3649 >
3650 <div class="repo-home-clone-body">
3651 <input
3652 class="repo-home-clone-input"
3653 type="text"
3654 value={cloneHttpsUrl}
3655 readonly
3656 aria-label="HTTPS clone URL"
3657 data-repo-home-clone-input
3658 />
3659 <button
3660 type="button"
3661 class="repo-home-clone-copy"
3662 data-repo-home-copy={cloneHttpsUrl}
3663 >
3664 Copy
3665 </button>
3666 </div>
3667 </div>
3668 <div
3669 class="repo-home-clone-pane"
3670 data-pane="ssh"
3671 data-active="false"
3672 role="tabpanel"
3673 >
3674 <div class="repo-home-clone-body">
3675 <input
3676 class="repo-home-clone-input"
3677 type="text"
3678 value={cloneSshUrl}
3679 readonly
3680 aria-label="SSH clone URL"
3681 data-repo-home-clone-input
3682 />
3683 <button
3684 type="button"
3685 class="repo-home-clone-copy"
3686 data-repo-home-copy={cloneSshUrl}
3687 >
3688 Copy
3689 </button>
3690 </div>
3691 </div>
3692 <div
3693 class="repo-home-clone-pane"
3694 data-pane="cli"
3695 data-active="false"
3696 role="tabpanel"
3697 >
3698 <div class="repo-home-clone-body">
3699 <input
3700 class="repo-home-clone-input"
3701 type="text"
3702 value={cloneCliCmd}
3703 readonly
3704 aria-label="Gluecron CLI clone command"
3705 data-repo-home-clone-input
3706 />
3707 <button
3708 type="button"
3709 class="repo-home-clone-copy"
3710 data-repo-home-copy={cloneCliCmd}
3711 >
3712 Copy
3713 </button>
3714 </div>
79136bbClaude3715 </div>
fc1817aClaude3716 </div>
544d842Claude3717 <div class="repo-home-side-card">
3718 <h3 class="repo-home-side-title">About</h3>
3719 <div class="repo-home-side-row">
3720 <span class="repo-home-side-key">Default branch</span>
3721 <span class="repo-home-side-val">{defaultBranch}</span>
3722 </div>
3723 <div class="repo-home-side-row">
3724 <span class="repo-home-side-key">Branches</span>
3725 <span class="repo-home-side-val">
3726 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
3727 {branches.length}
3728 </a>
3729 </span>
3730 </div>
3731 <div class="repo-home-side-row">
3732 <span class="repo-home-side-key">Stars</span>
3733 <span class="repo-home-side-val">{starCount}</span>
3734 </div>
3735 <div class="repo-home-side-row">
3736 <span class="repo-home-side-key">Forks</span>
3737 <span class="repo-home-side-val">{forkCount}</span>
3738 </div>
3739 {pushedAt && (
3740 <div class="repo-home-side-row">
3741 <span class="repo-home-side-key">Last push</span>
3742 <span class="repo-home-side-val">
3743 {formatRelative(pushedAt)}
3744 </span>
3745 </div>
3746 )}
3747 {createdAt && (
3748 <div class="repo-home-side-row">
3749 <span class="repo-home-side-key">Created</span>
3750 <span class="repo-home-side-val">
3751 {formatRelative(createdAt)}
3752 </span>
3753 </div>
3754 )}
3755 {(archived || isTemplate) && (
3756 <div class="repo-home-side-row">
3757 <span class="repo-home-side-key">State</span>
3758 <span class="repo-home-side-val">
3759 {archived ? "Archived" : "Template"}
3760 </span>
3761 </div>
3762 )}
3763 </div>
f1dc38bClaude3764 {/* Claude AI — quick-access card for authenticated users */}
3765 {user && (
3766 <div class="repo-home-side-card" style="margin-top:12px">
3767 <h3 class="repo-home-side-title" style="margin-bottom:10px">
3768 ✨ Claude AI
3769 </h3>
3770 <a
3771 href={`/${owner}/${repo}/claude`}
3772 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"
3773 >
3774 Claude Code sessions
3775 </a>
3776 <a
3777 href={`/${owner}/${repo}/ask`}
3778 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
3779 >
3780 Ask AI about this repo
3781 </a>
3782 </div>
3783 )}
544d842Claude3784 </aside>
3785 </div>
3786 <script
3787 dangerouslySetInnerHTML={{
3788 __html: `
3789 (function(){
3790 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
3791 tabs.forEach(function(tab){
3792 tab.addEventListener('click', function(){
3793 var target = tab.getAttribute('data-pane');
3794 tabs.forEach(function(t){
3795 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
3796 });
3797 var panes = document.querySelectorAll('.repo-home-clone-pane');
3798 panes.forEach(function(p){
3799 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
3800 });
3801 });
3802 });
3803 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
3804 copyBtns.forEach(function(btn){
3805 btn.addEventListener('click', function(){
3806 var text = btn.getAttribute('data-repo-home-copy') || '';
3807 var done = function(){
3808 var prev = btn.textContent;
3809 btn.textContent = 'Copied';
3810 setTimeout(function(){ btn.textContent = prev; }, 1200);
3811 };
3812 if (navigator.clipboard && navigator.clipboard.writeText) {
3813 navigator.clipboard.writeText(text).then(done, done);
3814 } else {
3815 var ta = document.createElement('textarea');
3816 ta.value = text;
3817 document.body.appendChild(ta);
3818 ta.select();
3819 try { document.execCommand('copy'); } catch (e) {}
3820 document.body.removeChild(ta);
3821 done();
3822 }
3823 });
3824 });
3825 })();
3826 `,
3827 }}
3828 />
fc1817aClaude3829 </Layout>
3830 );
3831});
3832
3833// Browse tree at ref/path
3834web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
3835 const { owner, repo } = c.req.param();
06d5ffeClaude3836 const user = c.get("user");
fc1817aClaude3837 const refAndPath = c.req.param("ref");
3838
3839 const branches = await listBranches(owner, repo);
3840 let ref = "";
3841 let treePath = "";
3842
3843 for (const branch of branches) {
3844 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
3845 ref = branch;
3846 treePath = refAndPath.slice(branch.length + 1);
3847 break;
3848 }
3849 }
3850
3851 if (!ref) {
3852 const slashIdx = refAndPath.indexOf("/");
3853 if (slashIdx === -1) {
3854 ref = refAndPath;
3855 } else {
3856 ref = refAndPath.slice(0, slashIdx);
3857 treePath = refAndPath.slice(slashIdx + 1);
3858 }
3859 }
3860
3861 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude3862 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3863 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude3864
3865 return c.html(
06d5ffeClaude3866 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude3867 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3868 <RepoHeader owner={owner} repo={repo} />
3869 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3870 <div class="tree-header">
3871 <div class="tree-header-row">
3872 <BranchSwitcher
3873 owner={owner}
3874 repo={repo}
3875 currentRef={ref}
3876 branches={branches}
3877 pathType="tree"
3878 subPath={treePath}
3879 />
3880 <div class="tree-header-stats">
3881 <span class="tree-stat" title="Entries in this directory">
3882 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3883 {dirCount > 0 && (
3884 <>
3885 {" · "}
3886 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3887 </>
3888 )}
3889 </span>
3890 <a
3891 href={`/${owner}/${repo}/search`}
3892 class="tree-stat tree-stat-link"
3893 title="Search code in this repository"
3894 >
3895 {"⌕"} Search
3896 </a>
3897 </div>
3898 </div>
3899 <div class="tree-breadcrumb-row">
3900 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
3901 </div>
3902 </div>
fc1817aClaude3903 <FileTable
3904 entries={tree}
3905 owner={owner}
3906 repo={repo}
3907 ref={ref}
3908 path={treePath}
3909 />
3910 </Layout>
3911 );
3912});
3913
06d5ffeClaude3914// View file blob with syntax highlighting
fc1817aClaude3915web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
3916 const { owner, repo } = c.req.param();
06d5ffeClaude3917 const user = c.get("user");
fc1817aClaude3918 const refAndPath = c.req.param("ref");
3919
3920 const branches = await listBranches(owner, repo);
3921 let ref = "";
3922 let filePath = "";
3923
3924 for (const branch of branches) {
3925 if (refAndPath.startsWith(branch + "/")) {
3926 ref = branch;
3927 filePath = refAndPath.slice(branch.length + 1);
3928 break;
3929 }
3930 }
3931
3932 if (!ref) {
3933 const slashIdx = refAndPath.indexOf("/");
3934 if (slashIdx === -1) return c.text("Not found", 404);
3935 ref = refAndPath.slice(0, slashIdx);
3936 filePath = refAndPath.slice(slashIdx + 1);
3937 }
3938
3939 const blob = await getBlob(owner, repo, ref, filePath);
3940 if (!blob) {
3941 return c.html(
06d5ffeClaude3942 <Layout title="Not Found" user={user}>
fc1817aClaude3943 <div class="empty-state">
3944 <h2>File not found</h2>
3945 </div>
3946 </Layout>,
3947 404
3948 );
3949 }
3950
06d5ffeClaude3951 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3952 const lineCount = blob.isBinary
3953 ? 0
3954 : (blob.content.endsWith("\n")
3955 ? blob.content.split("\n").length - 1
3956 : blob.content.split("\n").length);
3957 const formatBytes = (n: number): string => {
3958 if (n < 1024) return `${n} B`;
3959 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3960 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3961 };
fc1817aClaude3962
3963 return c.html(
06d5ffeClaude3964 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3965 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3966 <RepoHeader owner={owner} repo={repo} />
3967 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3968 <div class="blob-toolbar">
3969 <BranchSwitcher
3970 owner={owner}
3971 repo={repo}
3972 currentRef={ref}
3973 branches={branches}
3974 pathType="blob"
3975 subPath={filePath}
3976 />
3977 <div class="blob-breadcrumb">
3978 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3979 </div>
3980 </div>
3981 <div class="blob-view blob-card">
3982 <div class="blob-header blob-header-polished">
3983 <div class="blob-header-meta">
3984 <span class="blob-header-icon" aria-hidden="true">
3985 {"📄"}
3986 </span>
3987 <span class="blob-header-name">{fileName}</span>
3988 <span class="blob-header-size">
3989 {formatBytes(blob.size)}
3990 {!blob.isBinary && (
3991 <>
3992 {" · "}
3993 {lineCount} line{lineCount === 1 ? "" : "s"}
3994 </>
3995 )}
3996 </span>
3997 </div>
3998 <div class="blob-header-actions">
3999 <a
4000 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4001 class="blob-pill"
4002 >
79136bbClaude4003 Raw
4004 </a>
efb11c5Claude4005 <a
4006 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4007 class="blob-pill"
4008 >
79136bbClaude4009 Blame
4010 </a>
efb11c5Claude4011 <a
4012 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4013 class="blob-pill"
4014 >
16b325cClaude4015 History
4016 </a>
0074234Claude4017 {user && (
efb11c5Claude4018 <a
4019 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4020 class="blob-pill blob-pill-accent"
4021 >
0074234Claude4022 Edit
4023 </a>
4024 )}
efb11c5Claude4025 </div>
fc1817aClaude4026 </div>
4027 {blob.isBinary ? (
efb11c5Claude4028 <div class="blob-binary">
fc1817aClaude4029 Binary file not shown.
4030 </div>
06d5ffeClaude4031 ) : (() => {
4032 const { html: highlighted, language } = highlightCode(
4033 blob.content,
4034 fileName
4035 );
4036 if (language) {
4037 return (
4038 <HighlightedCode
4039 highlightedHtml={highlighted}
efb11c5Claude4040 lineCount={lineCount}
06d5ffeClaude4041 />
4042 );
4043 }
4044 const lines = blob.content.split("\n");
4045 if (lines[lines.length - 1] === "") lines.pop();
4046 return <PlainCode lines={lines} />;
4047 })()}
fc1817aClaude4048 </div>
4049 </Layout>
4050 );
4051});
4052
398a10cClaude4053// ─── Branches list ────────────────────────────────────────────────────────
4054// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4055// author + relative time + ahead/behind vs the default branch. No DB. All
4056// data comes from git plumbing; failures degrade gracefully (counts omitted).
4057web.get("/:owner/:repo/branches", async (c) => {
4058 const { owner, repo } = c.req.param();
4059 const user = c.get("user");
4060
4061 if (!(await repoExists(owner, repo))) return c.notFound();
4062
4063 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4064 const branches = await listBranches(owner, repo);
4065 const repoDir = getRepoPath(owner, repo);
4066
4067 type BranchRow = {
4068 name: string;
4069 isDefault: boolean;
4070 sha: string;
4071 subject: string;
4072 author: string;
4073 date: string;
4074 ahead: number;
4075 behind: number;
4076 };
4077
4078 const runGit = async (args: string[]): Promise<string> => {
4079 try {
4080 const proc = Bun.spawn(["git", ...args], {
4081 cwd: repoDir,
4082 stdout: "pipe",
4083 stderr: "pipe",
4084 });
4085 const out = await new Response(proc.stdout).text();
4086 await proc.exited;
4087 return out;
4088 } catch {
4089 return "";
4090 }
4091 };
4092
4093 const meta = await runGit([
4094 "for-each-ref",
4095 "--sort=-committerdate",
4096 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4097 "refs/heads/",
4098 ]);
4099 const metaByName: Record<
4100 string,
4101 { sha: string; subject: string; author: string; date: string }
4102 > = {};
4103 for (const line of meta.split("\n").filter(Boolean)) {
4104 const [name, sha, subject, author, date] = line.split("\0");
4105 metaByName[name] = { sha, subject, author, date };
4106 }
4107
4108 const branchOrder = [...branches].sort((a, b) => {
4109 if (a === defaultBranch) return -1;
4110 if (b === defaultBranch) return 1;
4111 const aDate = metaByName[a]?.date || "";
4112 const bDate = metaByName[b]?.date || "";
4113 return bDate.localeCompare(aDate);
4114 });
4115
4116 const rows: BranchRow[] = [];
4117 for (const name of branchOrder) {
4118 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4119 let ahead = 0;
4120 let behind = 0;
4121 if (name !== defaultBranch && metaByName[defaultBranch]) {
4122 const out = await runGit([
4123 "rev-list",
4124 "--left-right",
4125 "--count",
4126 `${defaultBranch}...${name}`,
4127 ]);
4128 const parts = out.trim().split(/\s+/);
4129 if (parts.length === 2) {
4130 behind = parseInt(parts[0], 10) || 0;
4131 ahead = parseInt(parts[1], 10) || 0;
4132 }
4133 }
4134 rows.push({
4135 name,
4136 isDefault: name === defaultBranch,
4137 sha: m.sha,
4138 subject: m.subject,
4139 author: m.author,
4140 date: m.date,
4141 ahead,
4142 behind,
4143 });
4144 }
4145
4146 const relative = (iso: string): string => {
4147 if (!iso) return "—";
4148 const d = new Date(iso);
4149 if (Number.isNaN(d.getTime())) return "—";
4150 const diff = Date.now() - d.getTime();
4151 const m = Math.floor(diff / 60000);
4152 if (m < 1) return "just now";
4153 if (m < 60) return `${m} min ago`;
4154 const h = Math.floor(m / 60);
4155 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4156 const dd = Math.floor(h / 24);
4157 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4158 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4159 };
4160
4161 const success = c.req.query("success");
4162 const error = c.req.query("error");
4163
4164 return c.html(
4165 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4166 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4167 <RepoHeader owner={owner} repo={repo} />
4168 <RepoNav owner={owner} repo={repo} active="code" />
4169 <div
4170 class="branches-wrap"
eed4684Claude4171 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4172 >
4173 <header class="branches-head" style="margin-bottom:var(--space-5)">
4174 <div
4175 class="branches-eyebrow"
4176 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"
4177 >
4178 <span
4179 class="branches-eyebrow-dot"
4180 aria-hidden="true"
4181 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4182 />
4183 Repository · Branches
4184 </div>
4185 <h1
4186 class="branches-title"
4187 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)"
4188 >
4189 <span class="gradient-text">{rows.length}</span>{" "}
4190 branch{rows.length === 1 ? "" : "es"}
4191 </h1>
4192 <p
4193 class="branches-sub"
4194 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4195 >
4196 All work-in-progress lines for{" "}
4197 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4198 counts are relative to{" "}
4199 <code style="font-size:12.5px">{defaultBranch}</code>.
4200 </p>
4201 </header>
4202
4203 {success && (
4204 <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">
4205 {decodeURIComponent(success)}
4206 </div>
4207 )}
4208 {error && (
4209 <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">
4210 {decodeURIComponent(error)}
4211 </div>
4212 )}
4213
4214 {rows.length === 0 ? (
4215 <div class="commits-empty">
4216 <div class="commits-empty-orb" aria-hidden="true" />
4217 <div class="commits-empty-inner">
4218 <div class="commits-empty-icon" aria-hidden="true">
4219 <svg
4220 width="22"
4221 height="22"
4222 viewBox="0 0 24 24"
4223 fill="none"
4224 stroke="currentColor"
4225 stroke-width="2"
4226 stroke-linecap="round"
4227 stroke-linejoin="round"
4228 >
4229 <line x1="6" y1="3" x2="6" y2="15" />
4230 <circle cx="18" cy="6" r="3" />
4231 <circle cx="6" cy="18" r="3" />
4232 <path d="M18 9a9 9 0 0 1-9 9" />
4233 </svg>
4234 </div>
4235 <h3 class="commits-empty-title">No branches yet</h3>
4236 <p class="commits-empty-sub">
4237 Push your first commit to create the default branch.
4238 </p>
4239 </div>
4240 </div>
4241 ) : (
4242 <div class="branches-list">
4243 {rows.map((r) => (
4244 <div class="branches-row">
4245 <div class="branches-row-icon" aria-hidden="true">
4246 <svg
4247 width="14"
4248 height="14"
4249 viewBox="0 0 24 24"
4250 fill="none"
4251 stroke="currentColor"
4252 stroke-width="2"
4253 stroke-linecap="round"
4254 stroke-linejoin="round"
4255 >
4256 <line x1="6" y1="3" x2="6" y2="15" />
4257 <circle cx="18" cy="6" r="3" />
4258 <circle cx="6" cy="18" r="3" />
4259 <path d="M18 9a9 9 0 0 1-9 9" />
4260 </svg>
4261 </div>
4262 <div class="branches-row-main">
4263 <div class="branches-row-name">
4264 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4265 {r.isDefault && (
4266 <span
4267 class="branches-row-default"
4268 title="Default branch"
4269 >
4270 Default
4271 </span>
4272 )}
4273 </div>
4274 <div class="branches-row-meta">
4275 {r.subject ? (
4276 <>
4277 <strong>{r.author || "—"}</strong>
4278 <span class="sep">·</span>
4279 <span
4280 title={r.date ? new Date(r.date).toISOString() : ""}
4281 >
4282 updated {relative(r.date)}
4283 </span>
4284 {r.sha && (
4285 <>
4286 <span class="sep">·</span>
4287 <a
4288 href={`/${owner}/${repo}/commit/${r.sha}`}
4289 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4290 >
4291 {r.sha.slice(0, 7)}
4292 </a>
4293 </>
4294 )}
4295 </>
4296 ) : (
4297 <span>No commit metadata</span>
4298 )}
4299 </div>
4300 </div>
4301 <div class="branches-row-side">
4302 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4303 <span
4304 class="branches-row-divergence"
4305 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4306 >
4307 <span class="ahead">{r.ahead} ahead</span>
4308 <span style="opacity:0.4">|</span>
4309 <span class="behind">{r.behind} behind</span>
4310 </span>
4311 )}
4312 <div class="branches-row-actions">
4313 <a
4314 href={`/${owner}/${repo}/commits/${r.name}`}
4315 class="branches-btn"
4316 title="View commits on this branch"
4317 >
4318 Commits
4319 </a>
4320 {!r.isDefault &&
4321 user &&
4322 user.username === owner && (
4323 <form
4324 method="post"
4325 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4326 style="margin:0"
4327 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4328 >
4329 <button
4330 type="submit"
4331 class="branches-btn branches-btn-danger"
4332 >
4333 Delete
4334 </button>
4335 </form>
4336 )}
4337 </div>
4338 </div>
4339 </div>
4340 ))}
4341 </div>
4342 )}
4343 </div>
4344 </Layout>
4345 );
4346});
4347
4348// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4349// that are not merged into the default branch — matches the explicit
4350// confirmation on the row's delete button.
4351web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4352 const { owner, repo } = c.req.param();
4353 const branchName = decodeURIComponent(c.req.param("name"));
4354 const user = c.get("user")!;
4355
4356 // Owner-only check (mirrors collaborators.tsx pattern).
4357 const [ownerRow] = await db
4358 .select()
4359 .from(users)
4360 .where(eq(users.username, owner))
4361 .limit(1);
4362 if (!ownerRow || ownerRow.id !== user.id) {
4363 return c.redirect(
4364 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4365 );
4366 }
4367
4368 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4369 if (branchName === defaultBranch) {
4370 return c.redirect(
4371 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4372 );
4373 }
4374
4375 const branches = await listBranches(owner, repo);
4376 if (!branches.includes(branchName)) {
4377 return c.redirect(
4378 `/${owner}/${repo}/branches?error=Branch+not+found`
4379 );
4380 }
4381
4382 try {
4383 const repoDir = getRepoPath(owner, repo);
4384 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4385 cwd: repoDir,
4386 stdout: "pipe",
4387 stderr: "pipe",
4388 });
4389 await proc.exited;
4390 if (proc.exitCode !== 0) {
4391 return c.redirect(
4392 `/${owner}/${repo}/branches?error=Delete+failed`
4393 );
4394 }
4395 } catch {
4396 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4397 }
4398
4399 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4400});
4401
4402// ─── Tags list ────────────────────────────────────────────────────────────
4403// Pulls from `listTags` (sorted newest first). For each tag we look up an
4404// associated release row so the "View release" CTA can deep-link directly
4405// without making the user hunt for it.
4406web.get("/:owner/:repo/tags", async (c) => {
4407 const { owner, repo } = c.req.param();
4408 const user = c.get("user");
4409
4410 if (!(await repoExists(owner, repo))) return c.notFound();
4411
4412 const tags = await listTags(owner, repo);
4413
4414 // Map tags -> releases. Best-effort; releases table may not exist in
4415 // every test setup, so any error falls through with an empty set.
4416 const tagsWithReleases = new Set<string>();
4417 try {
4418 const [ownerRow] = await db
4419 .select()
4420 .from(users)
4421 .where(eq(users.username, owner))
4422 .limit(1);
4423 if (ownerRow) {
4424 const [repoRow] = await db
4425 .select()
4426 .from(repositories)
4427 .where(
4428 and(
4429 eq(repositories.ownerId, ownerRow.id),
4430 eq(repositories.name, repo)
4431 )
4432 )
4433 .limit(1);
4434 if (repoRow) {
4435 // Raw SQL so we don't need to import the releases schema here.
4436 const result = await db.execute(
4437 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4438 );
4439 const rows: any[] = (result as any).rows || (result as any) || [];
4440 for (const row of rows) {
4441 const tag = row?.tag;
4442 if (typeof tag === "string") tagsWithReleases.add(tag);
4443 }
4444 }
4445 }
4446 } catch {
4447 // No releases table or DB error — leave set empty.
4448 }
4449
4450 const relative = (iso: string): string => {
4451 if (!iso) return "—";
4452 const d = new Date(iso);
4453 if (Number.isNaN(d.getTime())) return "—";
4454 const diff = Date.now() - d.getTime();
4455 const m = Math.floor(diff / 60000);
4456 if (m < 1) return "just now";
4457 if (m < 60) return `${m} min ago`;
4458 const h = Math.floor(m / 60);
4459 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4460 const dd = Math.floor(h / 24);
4461 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4462 return d.toLocaleDateString("en-US", {
4463 month: "short",
4464 day: "numeric",
4465 year: "numeric",
4466 });
4467 };
4468
4469 return c.html(
4470 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4471 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4472 <RepoHeader owner={owner} repo={repo} />
4473 <RepoNav owner={owner} repo={repo} active="code" />
4474 <div
4475 class="tags-wrap"
eed4684Claude4476 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4477 >
4478 <header class="tags-head" style="margin-bottom:var(--space-5)">
4479 <div
4480 class="tags-eyebrow"
4481 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"
4482 >
4483 <span
4484 class="tags-eyebrow-dot"
4485 aria-hidden="true"
4486 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4487 />
4488 Repository · Tags
4489 </div>
4490 <h1
4491 class="tags-title"
4492 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)"
4493 >
4494 <span class="gradient-text">{tags.length}</span>{" "}
4495 tag{tags.length === 1 ? "" : "s"}
4496 </h1>
4497 <p
4498 class="tags-sub"
4499 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4500 >
4501 Named points in the history — typically releases, milestones,
4502 or shipped versions. Click a tag to browse the tree at that
4503 revision.
4504 </p>
4505 </header>
4506
4507 {tags.length === 0 ? (
4508 <div class="commits-empty">
4509 <div class="commits-empty-orb" aria-hidden="true" />
4510 <div class="commits-empty-inner">
4511 <div class="commits-empty-icon" aria-hidden="true">
4512 <svg
4513 width="22"
4514 height="22"
4515 viewBox="0 0 24 24"
4516 fill="none"
4517 stroke="currentColor"
4518 stroke-width="2"
4519 stroke-linecap="round"
4520 stroke-linejoin="round"
4521 >
4522 <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" />
4523 <line x1="7" y1="7" x2="7.01" y2="7" />
4524 </svg>
4525 </div>
4526 <h3 class="commits-empty-title">No tags yet</h3>
4527 <p class="commits-empty-sub">
4528 Tag a commit to mark a release or milestone. From the CLI:{" "}
4529 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4530 </p>
4531 </div>
4532 </div>
4533 ) : (
4534 <div class="tags-list">
4535 {tags.map((t) => {
4536 const hasRelease = tagsWithReleases.has(t.name);
4537 return (
4538 <div class="tags-row">
4539 <div class="tags-row-icon" aria-hidden="true">
4540 <svg
4541 width="14"
4542 height="14"
4543 viewBox="0 0 24 24"
4544 fill="none"
4545 stroke="currentColor"
4546 stroke-width="2"
4547 stroke-linecap="round"
4548 stroke-linejoin="round"
4549 >
4550 <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" />
4551 <line x1="7" y1="7" x2="7.01" y2="7" />
4552 </svg>
4553 </div>
4554 <div class="tags-row-main">
4555 <div class="tags-row-name">
4556 <a
4557 href={`/${owner}/${repo}/tree/${t.name}`}
4558 style="text-decoration:none"
4559 >
4560 <span class="tags-row-version">{t.name}</span>
4561 </a>
4562 </div>
4563 <div class="tags-row-meta">
4564 <span
4565 title={t.date ? new Date(t.date).toISOString() : ""}
4566 >
4567 Tagged {relative(t.date)}
4568 </span>
4569 <span class="sep">·</span>
4570 <a
4571 href={`/${owner}/${repo}/commit/${t.sha}`}
4572 class="tags-row-sha"
4573 title={t.sha}
4574 >
4575 {t.sha.slice(0, 7)}
4576 </a>
4577 </div>
4578 </div>
4579 <div class="tags-row-side">
4580 <a
4581 href={`/${owner}/${repo}/tree/${t.name}`}
4582 class="tags-row-link"
4583 >
4584 Browse files
4585 </a>
4586 {hasRelease && (
4587 <a
4588 href={`/${owner}/${repo}/releases/tag/${t.name}`}
4589 class="tags-row-link"
4590 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
4591 >
4592 View release
4593 </a>
4594 )}
4595 </div>
4596 </div>
4597 );
4598 })}
4599 </div>
4600 )}
4601 </div>
4602 </Layout>
4603 );
4604});
4605
fc1817aClaude4606// Commit log
4607web.get("/:owner/:repo/commits/:ref?", async (c) => {
4608 const { owner, repo } = c.req.param();
06d5ffeClaude4609 const user = c.get("user");
fc1817aClaude4610 const ref =
4611 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude4612 const branches = await listBranches(owner, repo);
fc1817aClaude4613
4614 const commits = await listCommits(owner, repo, ref, 50);
4615
3951454Claude4616 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude4617 // Also resolve repoId here so we can query the recent push for the
4618 // Push Watch live/watch indicator in the header.
3951454Claude4619 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude4620 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude4621 try {
4622 const [ownerRow] = await db
4623 .select()
4624 .from(users)
4625 .where(eq(users.username, owner))
4626 .limit(1);
4627 if (ownerRow) {
4628 const [repoRow] = await db
4629 .select()
4630 .from(repositories)
4631 .where(
4632 and(
4633 eq(repositories.ownerId, ownerRow.id),
4634 eq(repositories.name, repo)
4635 )
4636 )
4637 .limit(1);
8c790e0Claude4638 if (repoRow) {
4639 // Fetch verifications and recent push in parallel.
4640 const [verRows, rp] = await Promise.all([
4641 commits.length > 0
4642 ? db
4643 .select()
4644 .from(commitVerifications)
4645 .where(
4646 and(
4647 eq(commitVerifications.repositoryId, repoRow.id),
4648 inArray(
4649 commitVerifications.commitSha,
4650 commits.map((c) => c.sha)
4651 )
4652 )
4653 )
4654 : Promise.resolve([]),
4655 getRecentPush(repoRow.id),
4656 ]);
4657 for (const r of verRows) {
3951454Claude4658 verifications[r.commitSha] = {
4659 verified: r.verified,
4660 reason: r.reason,
4661 };
4662 }
8c790e0Claude4663 commitsPageRecentPush = rp;
3951454Claude4664 }
4665 }
4666 } catch {
4667 // DB unavailable — skip the badges gracefully.
4668 }
4669
fc1817aClaude4670 return c.html(
06d5ffeClaude4671 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude4672 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude4673 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude4674 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude4675 <div class="commits-hero">
4676 <div class="commits-hero-orb-wrap" aria-hidden="true">
4677 <div class="commits-hero-orb" />
fc1817aClaude4678 </div>
efb11c5Claude4679 <div class="commits-hero-inner">
4680 <div class="commits-eyebrow">
4681 <strong>History</strong> · {owner}/{repo}
4682 </div>
4683 <h1 class="commits-title">
4684 <span class="gradient-text">{commits.length}</span> recent commit
4685 {commits.length === 1 ? "" : "s"} on{" "}
4686 <span class="commits-branch">{ref}</span>
4687 </h1>
4688 <p class="commits-sub">
4689 Browse the project's history. Click any commit to see the full
4690 diff, AI review notes, and signature status.
4691 </p>
4692 </div>
4693 </div>
4694 <div class="commits-toolbar">
4695 <BranchSwitcher
3951454Claude4696 owner={owner}
4697 repo={repo}
efb11c5Claude4698 currentRef={ref}
4699 branches={branches}
4700 pathType="commits"
3951454Claude4701 />
398a10cClaude4702 <div class="commits-toolbar-actions">
4703 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
4704 {"⊢"} Branches
4705 </a>
4706 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
4707 {"#"} Tags
4708 </a>
4709 </div>
efb11c5Claude4710 </div>
4711 {commits.length === 0 ? (
4712 <div class="commits-empty">
398a10cClaude4713 <div class="commits-empty-orb" aria-hidden="true" />
4714 <div class="commits-empty-inner">
4715 <div class="commits-empty-icon" aria-hidden="true">
4716 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4717 <circle cx="12" cy="12" r="4" />
4718 <line x1="1.05" y1="12" x2="7" y2="12" />
4719 <line x1="17.01" y1="12" x2="22.96" y2="12" />
4720 </svg>
4721 </div>
4722 <h3 class="commits-empty-title">No commits yet</h3>
4723 <p class="commits-empty-sub">
4724 This branch is empty. Push your first commit, or use the
4725 web editor to create a file.
4726 </p>
4727 </div>
efb11c5Claude4728 </div>
4729 ) : (
4730 <div class="commits-list-wrap">
398a10cClaude4731 {(() => {
4732 // Group commits by day for the section headers.
4733 const dayLabel = (iso: string): string => {
4734 const d = new Date(iso);
4735 if (Number.isNaN(d.getTime())) return "Unknown";
4736 const today = new Date();
4737 const yesterday = new Date(today);
4738 yesterday.setDate(today.getDate() - 1);
4739 const sameDay = (a: Date, b: Date) =>
4740 a.getFullYear() === b.getFullYear() &&
4741 a.getMonth() === b.getMonth() &&
4742 a.getDate() === b.getDate();
4743 if (sameDay(d, today)) return "Today";
4744 if (sameDay(d, yesterday)) return "Yesterday";
4745 return d.toLocaleDateString("en-US", {
4746 month: "long",
4747 day: "numeric",
4748 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
4749 });
4750 };
4751 const relative = (iso: string): string => {
4752 const d = new Date(iso);
4753 if (Number.isNaN(d.getTime())) return "";
4754 const diff = Date.now() - d.getTime();
4755 const m = Math.floor(diff / 60000);
4756 if (m < 1) return "just now";
4757 if (m < 60) return `${m} min ago`;
4758 const h = Math.floor(m / 60);
4759 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4760 const dd = Math.floor(h / 24);
4761 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4762 return d.toLocaleDateString("en-US", {
4763 month: "short",
4764 day: "numeric",
4765 });
4766 };
4767 const initial = (name: string): string =>
4768 (name || "?").trim().charAt(0).toUpperCase() || "?";
4769 // Build groups preserving order.
4770 const groups: Array<{ label: string; items: typeof commits }> = [];
4771 let lastLabel = "";
4772 for (const cm of commits) {
4773 const label = dayLabel(cm.date);
4774 if (label !== lastLabel) {
4775 groups.push({ label, items: [] });
4776 lastLabel = label;
4777 }
4778 groups[groups.length - 1].items.push(cm);
4779 }
4780 return groups.map((g) => (
4781 <>
4782 <div class="commits-day-head">
4783 <span class="commits-day-head-dot" aria-hidden="true" />
4784 Commits on {g.label}
4785 </div>
4786 {g.items.map((cm) => {
4787 const v = verifications[cm.sha];
4788 return (
4789 <div class="commits-row">
4790 <div class="commits-avatar" aria-hidden="true">
4791 {initial(cm.author)}
4792 </div>
4793 <div class="commits-row-body">
4794 <div class="commits-row-msg">
4795 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
4796 {cm.message}
4797 </a>
4798 {v?.verified && (
4799 <span
4800 class="commits-row-verified"
4801 title="Signed with a registered key"
4802 >
4803 Verified
4804 </span>
4805 )}
4806 </div>
4807 <div class="commits-row-meta">
4808 <strong>{cm.author}</strong>
4809 <span class="sep">·</span>
4810 <span
4811 class="commits-row-time"
4812 title={new Date(cm.date).toISOString()}
4813 >
4814 committed {relative(cm.date)}
4815 </span>
4816 {cm.parentShas.length > 1 && (
4817 <>
4818 <span class="sep">·</span>
4819 <span>merge of {cm.parentShas.length} parents</span>
4820 </>
4821 )}
4822 </div>
4823 </div>
4824 <div class="commits-row-side">
4825 <a
4826 href={`/${owner}/${repo}/commit/${cm.sha}`}
4827 class="commits-row-sha"
4828 title={cm.sha}
4829 >
4830 {cm.sha.slice(0, 7)}
4831 </a>
8c790e0Claude4832 <a
4833 href={`/${owner}/${repo}/push/${cm.sha}`}
4834 class="commits-row-watch"
4835 title="Watch gate + deploy results for this push"
4836 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
4837 >
4838 <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">
4839 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
4840 <circle cx="12" cy="12" r="3" />
4841 </svg>
4842 </a>
398a10cClaude4843 <button
4844 type="button"
4845 class="commits-row-copy"
4846 data-copy-sha={cm.sha}
4847 title="Copy full SHA"
4848 aria-label={`Copy SHA ${cm.sha}`}
4849 >
4850 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4851 <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" />
4852 <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" />
4853 </svg>
4854 </button>
4855 </div>
4856 </div>
4857 );
4858 })}
4859 </>
4860 ));
4861 })()}
efb11c5Claude4862 </div>
fc1817aClaude4863 )}
398a10cClaude4864 <script
4865 dangerouslySetInnerHTML={{
4866 __html: `
4867 (function(){
4868 document.addEventListener('click', function(e){
4869 var t = e.target; if (!t) return;
4870 var btn = t.closest && t.closest('[data-copy-sha]');
4871 if (!btn) return;
4872 e.preventDefault();
4873 var sha = btn.getAttribute('data-copy-sha') || '';
4874 if (!navigator.clipboard) return;
4875 navigator.clipboard.writeText(sha).then(function(){
4876 btn.classList.add('is-copied');
4877 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
4878 }).catch(function(){});
4879 });
4880 })();
4881 `,
4882 }}
4883 />
fc1817aClaude4884 </Layout>
4885 );
4886});
4887
4888// Single commit with diff
4889web.get("/:owner/:repo/commit/:sha", async (c) => {
4890 const { owner, repo, sha } = c.req.param();
06d5ffeClaude4891 const user = c.get("user");
fc1817aClaude4892
05b973eClaude4893 // Fetch commit, full message, and diff in parallel
4894 const [commit, fullMessage, diffResult] = await Promise.all([
4895 getCommit(owner, repo, sha),
4896 getCommitFullMessage(owner, repo, sha),
4897 getDiff(owner, repo, sha),
4898 ]);
fc1817aClaude4899 if (!commit) {
4900 return c.html(
06d5ffeClaude4901 <Layout title="Not Found" user={user}>
fc1817aClaude4902 <div class="empty-state">
4903 <h2>Commit not found</h2>
4904 </div>
4905 </Layout>,
4906 404
4907 );
4908 }
4909
3951454Claude4910 // Block J3 — try to verify this commit's signature.
4911 let verification:
4912 | { verified: boolean; reason: string; signatureType: string | null }
4913 | null = null;
0cdfd89Claude4914 // Block J8 — external CI commit statuses rollup.
4915 let statusCombined:
4916 | {
4917 state: "pending" | "success" | "failure";
4918 total: number;
4919 contexts: Array<{
4920 context: string;
4921 state: string;
4922 description: string | null;
4923 targetUrl: string | null;
4924 }>;
4925 }
4926 | null = null;
3951454Claude4927 try {
4928 const [ownerRow] = await db
4929 .select()
4930 .from(users)
4931 .where(eq(users.username, owner))
4932 .limit(1);
4933 if (ownerRow) {
4934 const [repoRow] = await db
4935 .select()
4936 .from(repositories)
4937 .where(
4938 and(
4939 eq(repositories.ownerId, ownerRow.id),
4940 eq(repositories.name, repo)
4941 )
4942 )
4943 .limit(1);
4944 if (repoRow) {
4945 const { verifyCommit } = await import("../lib/signatures");
4946 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
4947 verification = {
4948 verified: v.verified,
4949 reason: v.reason,
4950 signatureType: v.signatureType,
4951 };
0cdfd89Claude4952 try {
4953 const { combinedStatus } = await import("../lib/commit-statuses");
4954 const combined = await combinedStatus(repoRow.id, commit.sha);
4955 if (combined.total > 0) {
4956 statusCombined = {
4957 state: combined.state as any,
4958 total: combined.total,
4959 contexts: combined.contexts.map((c) => ({
4960 context: c.context,
4961 state: c.state,
4962 description: c.description,
4963 targetUrl: c.targetUrl,
4964 })),
4965 };
4966 }
4967 } catch {
4968 statusCombined = null;
4969 }
3951454Claude4970 }
4971 }
4972 } catch {
4973 verification = null;
4974 }
4975
05b973eClaude4976 const { files, raw } = diffResult;
fc1817aClaude4977
efb11c5Claude4978 // Diff stats: count additions / deletions across all files for the
4979 // header summary bar. Computed here from the parsed diff so we don't
4980 // touch the DiffView component.
4981 let additions = 0;
4982 let deletions = 0;
4983 for (const f of files) {
4984 const hunks = (f as any).hunks as Array<any> | undefined;
4985 if (Array.isArray(hunks)) {
4986 for (const h of hunks) {
4987 const lines = (h?.lines || []) as Array<any>;
4988 for (const ln of lines) {
4989 const t = ln?.type || ln?.kind;
4990 if (t === "add" || t === "added" || t === "+") additions += 1;
4991 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
4992 deletions += 1;
4993 }
4994 }
4995 }
4996 }
4997 // Fall back: scan raw if file-level counting yielded zero (it's just a
4998 // header polish — never let a parsing miss break the page).
4999 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5000 for (const line of raw.split("\n")) {
5001 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5002 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5003 }
5004 }
5005 const fileCount = files.length;
5006
fc1817aClaude5007 return c.html(
06d5ffeClaude5008 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5009 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5010 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5011 <div class="commit-detail-card">
5012 <div class="commit-detail-eyebrow">
5013 <strong>Commit</strong>
5014 <span class="commit-detail-sha-pill" title={commit.sha}>
5015 {commit.sha.slice(0, 7)}
5016 </span>
3951454Claude5017 {verification && verification.reason !== "unsigned" && (
5018 <span
efb11c5Claude5019 class={`commit-detail-verify ${
3951454Claude5020 verification.verified
efb11c5Claude5021 ? "commit-detail-verify-ok"
5022 : "commit-detail-verify-warn"
3951454Claude5023 }`}
5024 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5025 >
5026 {verification.verified ? "Verified" : verification.reason}
5027 </span>
5028 )}
fc1817aClaude5029 </div>
efb11c5Claude5030 <h1 class="commit-detail-title">{commit.message}</h1>
5031 {fullMessage !== commit.message && (
5032 <pre class="commit-detail-body">{fullMessage}</pre>
5033 )}
5034 <div class="commit-detail-meta">
5035 <span class="commit-detail-author">
5036 <strong>{commit.author}</strong> committed on{" "}
5037 {new Date(commit.date).toLocaleDateString("en-US", {
5038 month: "long",
5039 day: "numeric",
5040 year: "numeric",
5041 })}
5042 </span>
fc1817aClaude5043 {commit.parentShas.length > 0 && (
efb11c5Claude5044 <span class="commit-detail-parents">
5045 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5046 {commit.parentShas.map((p, idx) => (
5047 <>
5048 {idx > 0 && " "}
5049 <a
5050 href={`/${owner}/${repo}/commit/${p}`}
5051 class="commit-detail-sha-link"
5052 >
5053 {p.slice(0, 7)}
5054 </a>
5055 </>
fc1817aClaude5056 ))}
5057 </span>
5058 )}
5059 </div>
efb11c5Claude5060 <div class="commit-detail-stats">
5061 <span class="commit-detail-stat">
5062 <strong>{fileCount}</strong>{" "}
5063 file{fileCount === 1 ? "" : "s"} changed
5064 </span>
5065 <span class="commit-detail-stat commit-detail-stat-add">
5066 <span class="commit-detail-stat-mark">+</span>
5067 <strong>{additions}</strong>
5068 </span>
5069 <span class="commit-detail-stat commit-detail-stat-del">
5070 <span class="commit-detail-stat-mark">−</span>
5071 <strong>{deletions}</strong>
5072 </span>
5073 <span class="commit-detail-sha-full" title="Full SHA">
5074 {commit.sha}
5075 </span>
5076 </div>
0cdfd89Claude5077 {statusCombined && (
efb11c5Claude5078 <div class="commit-detail-checks">
5079 <div class="commit-detail-checks-head">
5080 <strong>Checks</strong>
5081 <span class="commit-detail-checks-summary">
5082 {statusCombined.total} total ·{" "}
5083 <span
5084 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5085 >
5086 {statusCombined.state}
5087 </span>
0cdfd89Claude5088 </span>
efb11c5Claude5089 </div>
5090 <div class="commit-detail-check-row">
0cdfd89Claude5091 {statusCombined.contexts.map((cx) => (
5092 <span
efb11c5Claude5093 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5094 title={cx.description || cx.context}
5095 >
5096 {cx.targetUrl ? (
efb11c5Claude5097 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5098 {cx.context}: {cx.state}
5099 </a>
5100 ) : (
5101 <>
5102 {cx.context}: {cx.state}
5103 </>
5104 )}
5105 </span>
5106 ))}
5107 </div>
5108 </div>
5109 )}
fc1817aClaude5110 </div>
ea9ed4cClaude5111 <DiffView
5112 raw={raw}
5113 files={files}
5114 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5115 />
fc1817aClaude5116 </Layout>
5117 );
5118});
5119
79136bbClaude5120// Raw file download
5121web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5122 const { owner, repo } = c.req.param();
5123 const refAndPath = c.req.param("ref");
5124
5125 const branches = await listBranches(owner, repo);
5126 let ref = "";
5127 let filePath = "";
5128
5129 for (const branch of branches) {
5130 if (refAndPath.startsWith(branch + "/")) {
5131 ref = branch;
5132 filePath = refAndPath.slice(branch.length + 1);
5133 break;
5134 }
5135 }
5136
5137 if (!ref) {
5138 const slashIdx = refAndPath.indexOf("/");
5139 if (slashIdx === -1) return c.text("Not found", 404);
5140 ref = refAndPath.slice(0, slashIdx);
5141 filePath = refAndPath.slice(slashIdx + 1);
5142 }
5143
5144 const data = await getRawBlob(owner, repo, ref, filePath);
5145 if (!data) return c.text("Not found", 404);
5146
5147 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5148 return new Response(data as BodyInit, {
79136bbClaude5149 headers: {
5150 "Content-Type": "application/octet-stream",
5151 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5152 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5153 },
5154 });
5155});
5156
5157// Blame view
5158web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5159 const { owner, repo } = c.req.param();
5160 const user = c.get("user");
5161 const refAndPath = c.req.param("ref");
5162
5163 const branches = await listBranches(owner, repo);
5164 let ref = "";
5165 let filePath = "";
5166
5167 for (const branch of branches) {
5168 if (refAndPath.startsWith(branch + "/")) {
5169 ref = branch;
5170 filePath = refAndPath.slice(branch.length + 1);
5171 break;
5172 }
5173 }
5174
5175 if (!ref) {
5176 const slashIdx = refAndPath.indexOf("/");
5177 if (slashIdx === -1) return c.text("Not found", 404);
5178 ref = refAndPath.slice(0, slashIdx);
5179 filePath = refAndPath.slice(slashIdx + 1);
5180 }
5181
5182 const blameLines = await getBlame(owner, repo, ref, filePath);
5183 if (blameLines.length === 0) {
5184 return c.html(
5185 <Layout title="Not Found" user={user}>
5186 <div class="empty-state">
5187 <h2>File not found</h2>
5188 </div>
5189 </Layout>,
5190 404
5191 );
5192 }
5193
5194 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5195 // Unique contributors (by author) tracked once for the header chip.
5196 const blameAuthors = new Set<string>();
5197 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5198
5199 return c.html(
5200 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5201 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5202 <RepoHeader owner={owner} repo={repo} />
5203 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5204 <header class="blame-head">
5205 <div class="blame-eyebrow">
5206 <span class="blame-eyebrow-dot" aria-hidden="true" />
5207 Blame · Line-by-line history
5208 </div>
5209 <h1 class="blame-title">
5210 <code>{fileName}</code>
5211 </h1>
5212 <p class="blame-sub">
5213 Each line is annotated with the commit that last touched it.
5214 Click any SHA to jump to that commit and see the surrounding
5215 change.
5216 </p>
5217 </header>
efb11c5Claude5218 <div class="blame-toolbar">
5219 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5220 </div>
398a10cClaude5221 <div class="blame-card">
5222 <div class="blame-header">
efb11c5Claude5223 <div class="blame-header-meta">
5224 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5225 <span class="blame-header-name">{fileName}</span>
5226 <span class="blame-header-tag">Blame</span>
5227 <span class="blame-header-stats">
5228 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5229 {blameAuthors.size} contributor
5230 {blameAuthors.size === 1 ? "" : "s"}
5231 </span>
5232 </div>
5233 <div class="blame-header-actions">
5234 <a
5235 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5236 class="blob-pill"
5237 >
5238 Normal view
5239 </a>
5240 <a
5241 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5242 class="blob-pill"
5243 >
5244 Raw
5245 </a>
5246 </div>
79136bbClaude5247 </div>
398a10cClaude5248 <div style="overflow-x:auto">
5249 <table class="blame-table">
79136bbClaude5250 <tbody>
5251 {blameLines.map((line, i) => {
5252 const showInfo =
5253 i === 0 || blameLines[i - 1].sha !== line.sha;
5254 return (
398a10cClaude5255 <tr class={showInfo ? "blame-row-first" : ""}>
5256 <td class="blame-gutter">
79136bbClaude5257 {showInfo && (
398a10cClaude5258 <span class="blame-gutter-inner">
79136bbClaude5259 <a
5260 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5261 class="blame-gutter-sha"
5262 title={`Commit ${line.sha}`}
79136bbClaude5263 >
5264 {line.sha.slice(0, 7)}
398a10cClaude5265 </a>
5266 <span class="blame-gutter-author" title={line.author}>
5267 {line.author}
5268 </span>
5269 </span>
79136bbClaude5270 )}
5271 </td>
398a10cClaude5272 <td class="blame-line-num">{line.lineNum}</td>
5273 <td class="blame-line-content">{line.content}</td>
79136bbClaude5274 </tr>
5275 );
5276 })}
5277 </tbody>
5278 </table>
5279 </div>
5280 </div>
5281 </Layout>
5282 );
5283});
5284
5285// Search
5286web.get("/:owner/:repo/search", async (c) => {
5287 const { owner, repo } = c.req.param();
5288 const user = c.get("user");
5289 const q = c.req.query("q") || "";
5290
5291 if (!(await repoExists(owner, repo))) return c.notFound();
5292
5293 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5294 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5295
5296 if (q.trim()) {
5297 results = await searchCode(owner, repo, defaultBranch, q.trim());
5298 }
5299
5300 return c.html(
5301 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude5302 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5303 <RepoHeader owner={owner} repo={repo} />
5304 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5305 <div class="search-hero">
5306 <div class="search-eyebrow">
5307 <strong>Search</strong> · {owner}/{repo}
5308 </div>
5309 <h1 class="search-title">
5310 Find any line in <span class="gradient-text">{repo}</span>
5311 </h1>
5312 <form
5313 method="get"
5314 action={`/${owner}/${repo}/search`}
5315 class="search-form"
5316 role="search"
5317 >
5318 <div class="search-input-wrap">
5319 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5320 <input
5321 type="text"
5322 name="q"
5323 value={q}
5324 placeholder="Search code on the default branch…"
5325 aria-label="Search code"
5326 class="search-input"
5327 autocomplete="off"
5328 autofocus
5329 />
5330 </div>
5331 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5332 Search
5333 </button>
efb11c5Claude5334 </form>
5335 </div>
79136bbClaude5336 {q && (
efb11c5Claude5337 <div class="search-results-head">
5338 <span class="search-results-count">
5339 <strong>{results.length}</strong> result
5340 {results.length !== 1 ? "s" : ""}
5341 </span>
5342 <span class="search-results-query">
5343 for <span class="search-results-q">"{q}"</span> on{" "}
5344 <code>{defaultBranch}</code>
5345 </span>
5346 </div>
79136bbClaude5347 )}
efb11c5Claude5348 {q && results.length === 0 ? (
5349 <div class="search-empty">
5350 <p>
5351 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5352 you're on the right branch.
5353 </p>
5354 </div>
5355 ) : results.length > 0 ? (
79136bbClaude5356 <div class="search-results">
5357 {(() => {
5358 // Group by file
5359 const grouped: Record<
5360 string,
5361 Array<{ lineNum: number; line: string }>
5362 > = {};
5363 for (const r of results) {
5364 if (!grouped[r.file]) grouped[r.file] = [];
5365 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5366 }
5367 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude5368 <div class="search-file diff-file">
5369 <div class="search-file-head diff-file-header">
79136bbClaude5370 <a
5371 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude5372 class="search-file-link"
79136bbClaude5373 >
5374 {file}
5375 </a>
efb11c5Claude5376 <span class="search-file-count">
5377 {matches.length} match{matches.length === 1 ? "" : "es"}
5378 </span>
79136bbClaude5379 </div>
5380 <div class="blob-code">
5381 <table>
5382 <tbody>
5383 {matches.map((m) => (
5384 <tr>
5385 <td class="line-num">{m.lineNum}</td>
5386 <td class="line-content">{m.line}</td>
5387 </tr>
5388 ))}
5389 </tbody>
5390 </table>
5391 </div>
5392 </div>
5393 ));
5394 })()}
5395 </div>
efb11c5Claude5396 ) : null}
79136bbClaude5397 </Layout>
5398 );
5399});
5400
fc1817aClaude5401export default web;