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

web.tsx

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

web.tsxBlame5830 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,
9c5223fClaude22 repoOnboardingData,
3951454Claude23} from "../db/schema";
fc1817aClaude24import { Layout } from "../views/layout";
cb5a796Claude25import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude26import {
27 RepoHeader,
28 RepoNav,
29 Breadcrumb,
30 FileTable,
06d5ffeClaude31 RepoCard,
32 BranchSwitcher,
33 HighlightedCode,
34 PlainCode,
8c790e0Claude35 type RecentPush,
fc1817aClaude36} from "../views/components";
ea9ed4cClaude37import { DiffView } from "../views/diff-view";
fc1817aClaude38import {
39 getTree,
40 getBlob,
41 listCommits,
42 getCommit,
43 getCommitFullMessage,
44 getDiff,
45 getReadme,
46 getDefaultBranch,
47 listBranches,
398a10cClaude48 listTags,
fc1817aClaude49 repoExists,
06d5ffeClaude50 initBareRepo,
79136bbClaude51 getBlame,
52 getRawBlob,
53 searchCode,
398a10cClaude54 getRepoPath,
fc1817aClaude55} from "../git/repository";
79136bbClaude56import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude57import { highlightCode } from "../lib/highlight";
91a0204Claude58import { computeHealthScore } from "../lib/health-score";
59import type { HealthScore } from "../lib/health-score";
06d5ffeClaude60import { softAuth, requireAuth } from "../middleware/auth";
61import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude62import { trackByName } from "../lib/traffic";
534f04aClaude63import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude64import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude65import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude66import {
67 listQueuedAiBuildIssues,
68 listRecentAutoMerges,
69 listRecentAiReviews,
70 countAiReviewsSince,
71 listDemoActivityFeed,
72} from "../lib/demo-activity";
fc1817aClaude73
06d5ffeClaude74const web = new Hono<AuthEnv>();
75
76// Soft auth on all web routes — c.get("user") available but may be null
77web.use("*", softAuth);
fc1817aClaude78
ebaae0fClaude79// ---------------------------------------------------------------------------
80// Repo AI stats — computed once per repo home page load, best-effort.
81// Fast because every WHERE clause is bounded to a 7-day window.
82// ---------------------------------------------------------------------------
83interface RepoAiStats {
84 mergedCount: number;
85 reviewCount: number;
86 securityAlertCount: number;
87 hoursSaved: string;
88}
89
90async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
91 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
92
93 // 1. AI-merged PRs this week: activity_feed entries with action =
94 // 'auto_merge.merged' in the last 7 days for this repo.
95 // This is cheaper than a JOIN on pull_requests and covers both the
96 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
97 const [mergedRow] = await db
98 .select({ n: count() })
99 .from(activityFeed)
100 .where(
101 and(
102 eq(activityFeed.repositoryId, repoId),
103 eq(activityFeed.action, "auto_merge.merged"),
104 gte(activityFeed.createdAt, since)
105 )
106 );
107 const mergedCount = Number(mergedRow?.n ?? 0);
108
109 // 2. AI reviews this week: pr_comments where is_ai_review = true in
110 // the last 7 days, joined through pull_requests to scope to this repo.
111 const [reviewRow] = await db
112 .select({ n: count() })
113 .from(prComments)
114 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
115 .where(
116 and(
117 eq(pullRequests.repositoryId, repoId),
118 eq(prComments.isAiReview, true),
119 gte(prComments.createdAt, since)
120 )
121 );
122 const reviewCount = Number(reviewRow?.n ?? 0);
123
124 // 3. Open security alerts: open issues that carry a label whose name
125 // contains 'security' (case-insensitive) for this repo.
126 const [secRow] = await db
127 .select({ n: count() })
128 .from(issues)
129 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
130 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
131 .where(
132 and(
133 eq(issues.repositoryId, repoId),
134 eq(issues.state, "open"),
135 sql`lower(${labels.name}) like '%security%'`
136 )
137 );
138 const securityAlertCount = Number(secRow?.n ?? 0);
139
140 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
141 const hours = mergedCount * 1.5 + reviewCount * 0.5;
142 const hoursSaved = hours.toFixed(1);
143
144 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
145}
146
8c790e0Claude147/**
148 * Query the most recent push to a repo from the activity_feed table.
149 * Returns a RecentPush if there's been a push in the last 24 hours,
150 * or null otherwise. Used by the RepoHeader live/watch indicator.
151 *
152 * Queries activity_feed where action='push' and targetId holds the commit SHA.
153 */
154async function getRecentPush(repoId: string): Promise<RecentPush | null> {
155 try {
156 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
157 const [row] = await db
158 .select({
159 targetId: activityFeed.targetId,
160 createdAt: activityFeed.createdAt,
161 })
162 .from(activityFeed)
163 .where(
164 and(
165 eq(activityFeed.repositoryId, repoId),
166 eq(activityFeed.action, "push"),
167 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
168 )
169 )
170 .orderBy(desc(activityFeed.createdAt))
171 .limit(1);
172 if (!row || !row.targetId) return null;
173 return {
174 sha: row.targetId,
175 ageMs: Date.now() - new Date(row.createdAt).getTime(),
176 };
177 } catch {
178 // DB unavailable or schema mismatch — degrade gracefully.
179 return null;
180 }
181}
182
efb11c5Claude183/**
184 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
185 *
186 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
187 * scope is route-local and `layout.tsx` is locked. Each polished handler
188 * injects this via a `<style>` tag; the rules are namespaced by surface
189 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
190 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
191 * `.repo-home-*` styling Agent A already shipped.
192 */
193const codeBrowseCss = `
194 /* ───────── shared primitives ───────── */
195 .cb-hairline::before,
196 .new-repo-hero::before,
197 .profile-hero::before,
198 .commits-hero::before,
199 .commit-detail-card::before,
200 .search-hero::before {
201 content: '';
202 position: absolute;
203 top: 0; left: 0; right: 0;
204 height: 2px;
205 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
206 opacity: 0.7;
207 pointer-events: none;
208 }
209 @keyframes cbHeroOrb {
210 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
211 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
212 }
213 @media (prefers-reduced-motion: reduce) {
214 .new-repo-hero-orb,
215 .profile-hero-orb,
216 .commits-hero-orb { animation: none; }
217 }
218
219 /* ───────── new-repo ───────── */
220 .new-repo-hero {
221 position: relative;
222 margin-bottom: var(--space-5);
223 padding: var(--space-5) var(--space-6);
224 background: var(--bg-elevated);
225 border: 1px solid var(--border);
226 border-radius: 16px;
227 overflow: hidden;
228 }
229 .new-repo-hero-orb-wrap {
230 position: absolute;
231 inset: -25% -10% auto auto;
232 width: 360px;
233 height: 360px;
234 pointer-events: none;
235 z-index: 0;
236 }
237 .new-repo-hero-orb {
238 position: absolute;
239 inset: 0;
240 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
241 filter: blur(80px);
242 opacity: 0.7;
243 animation: cbHeroOrb 14s ease-in-out infinite;
244 }
245 .new-repo-hero-inner { position: relative; z-index: 1; }
246 .new-repo-eyebrow {
247 font-size: 12px;
248 font-family: var(--font-mono);
249 color: var(--text-muted);
250 letter-spacing: 0.1em;
251 text-transform: uppercase;
252 margin-bottom: var(--space-2);
253 }
254 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
255 .new-repo-title {
256 font-family: var(--font-display);
257 font-weight: 800;
258 letter-spacing: -0.028em;
259 font-size: clamp(28px, 4vw, 40px);
260 line-height: 1.05;
261 margin: 0 0 var(--space-2);
262 color: var(--text-strong);
263 }
264 .new-repo-sub {
265 font-size: 15px;
266 color: var(--text-muted);
267 margin: 0;
268 line-height: 1.55;
269 max-width: 620px;
270 }
271 .new-repo-form {
272 max-width: 680px;
273 }
274 .new-repo-error {
275 background: rgba(218, 54, 51, 0.12);
276 border: 1px solid rgba(218, 54, 51, 0.35);
277 color: #ffb3b3;
278 padding: 10px 14px;
279 border-radius: 10px;
280 margin-bottom: var(--space-4);
281 font-size: 14px;
282 }
283 .new-repo-form-grid {
284 display: flex;
285 flex-direction: column;
286 gap: var(--space-4);
287 }
288 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
289 .new-repo-label {
290 font-size: 13px;
291 color: var(--text-strong);
292 font-weight: 600;
293 }
294 .new-repo-label-optional {
295 color: var(--text-muted);
296 font-weight: 400;
297 font-size: 12px;
298 }
299 .new-repo-input {
300 appearance: none;
301 width: 100%;
302 background: var(--bg);
303 border: 1px solid var(--border);
304 color: var(--text-strong);
305 border-radius: 10px;
306 padding: 10px 12px;
307 font-size: 14px;
308 font-family: inherit;
309 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
310 }
311 .new-repo-input:focus {
312 outline: none;
313 border-color: var(--accent);
314 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
315 }
316 .new-repo-input-disabled {
317 color: var(--text-muted);
318 background: var(--bg-secondary);
319 cursor: not-allowed;
320 }
321 .new-repo-hint {
322 font-size: 12px;
323 color: var(--text-muted);
324 margin: 4px 0 0;
325 }
326 .new-repo-hint code {
327 font-family: var(--font-mono);
328 font-size: 11.5px;
329 background: var(--bg-secondary);
330 border: 1px solid var(--border);
331 border-radius: 5px;
332 padding: 1px 5px;
333 color: var(--text-strong);
334 }
335 .new-repo-visibility {
336 display: grid;
337 grid-template-columns: 1fr 1fr;
338 gap: var(--space-2);
339 }
340 @media (max-width: 600px) {
341 .new-repo-visibility { grid-template-columns: 1fr; }
342 }
343 .new-repo-vis-card {
344 display: flex;
345 gap: 10px;
346 padding: 14px;
347 background: var(--bg);
348 border: 1px solid var(--border);
349 border-radius: 12px;
350 cursor: pointer;
351 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
352 }
353 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
354 .new-repo-vis-card:has(input:checked) {
355 border-color: rgba(140,109,255,0.55);
356 background: rgba(140,109,255,0.06);
357 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
358 }
359 .new-repo-vis-radio {
360 margin-top: 3px;
361 accent-color: var(--accent);
362 }
363 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
364 .new-repo-vis-label {
365 font-size: 14px;
366 font-weight: 600;
367 color: var(--text-strong);
368 }
369 .new-repo-vis-desc {
370 font-size: 12.5px;
371 color: var(--text-muted);
372 line-height: 1.45;
373 }
374 .new-repo-callout {
375 margin-top: var(--space-2);
376 padding: var(--space-3) var(--space-4);
377 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
378 border: 1px solid rgba(140,109,255,0.2);
379 border-radius: 12px;
380 }
381 .new-repo-callout-eyebrow {
382 font-size: 11px;
383 font-family: var(--font-mono);
384 text-transform: uppercase;
385 letter-spacing: 0.12em;
386 color: var(--accent);
387 font-weight: 700;
388 margin-bottom: 4px;
389 }
390 .new-repo-callout-body {
391 font-size: 13px;
392 color: var(--text);
393 line-height: 1.5;
394 margin: 0;
395 }
396 .new-repo-callout-body code {
397 font-family: var(--font-mono);
398 font-size: 12px;
399 color: var(--accent);
400 background: rgba(140,109,255,0.1);
401 border-radius: 4px;
402 padding: 1px 5px;
403 }
398a10cClaude404 .new-repo-templates {
405 display: flex;
406 flex-wrap: wrap;
407 gap: 8px;
408 margin-top: 4px;
409 }
410 .new-repo-template-chip {
411 position: relative;
412 display: inline-flex;
413 align-items: center;
414 gap: 6px;
415 padding: 8px 14px;
416 background: var(--bg);
417 border: 1px solid var(--border);
418 border-radius: 999px;
419 font-size: 13px;
420 color: var(--text);
421 cursor: pointer;
422 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
423 }
424 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
425 .new-repo-template-chip:hover {
426 border-color: var(--border-strong, var(--border));
427 color: var(--text-strong);
428 }
429 .new-repo-template-chip:has(input:checked) {
430 border-color: rgba(140,109,255,0.55);
431 background: rgba(140,109,255,0.10);
432 color: var(--text-strong);
433 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
434 }
435 .new-repo-template-chip-dot {
436 width: 8px; height: 8px;
437 border-radius: 999px;
438 background: var(--text-faint, var(--text-muted));
439 transition: background 140ms ease, box-shadow 140ms ease;
440 }
441 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
442 background: linear-gradient(135deg, #8c6dff, #36c5d6);
443 box-shadow: 0 0 8px rgba(140,109,255,0.6);
444 }
efb11c5Claude445 .new-repo-actions {
446 display: flex;
447 gap: var(--space-2);
448 margin-top: var(--space-2);
398a10cClaude449 align-items: center;
450 }
451 .new-repo-submit {
452 min-width: 180px;
453 border: 1px solid rgba(140,109,255,0.45);
454 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
455 color: #fff;
456 font-weight: 700;
457 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
458 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
459 }
460 .new-repo-submit:hover {
461 transform: translateY(-1px);
462 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
463 filter: brightness(1.06);
464 }
465 .new-repo-submit:focus-visible {
466 outline: 3px solid rgba(140,109,255,0.45);
467 outline-offset: 2px;
efb11c5Claude468 }
469
470 /* ───────── profile ───────── */
471 .profile-hero {
472 position: relative;
473 margin-bottom: var(--space-5);
474 padding: var(--space-5) var(--space-6);
475 background: var(--bg-elevated);
476 border: 1px solid var(--border);
477 border-radius: 16px;
478 overflow: hidden;
479 }
480 .profile-hero-orb-wrap {
481 position: absolute;
482 inset: -25% -10% auto auto;
483 width: 360px;
484 height: 360px;
485 pointer-events: none;
486 z-index: 0;
487 }
488 .profile-hero-orb {
489 position: absolute;
490 inset: 0;
491 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
492 filter: blur(80px);
493 opacity: 0.7;
494 animation: cbHeroOrb 14s ease-in-out infinite;
495 }
496 .profile-hero-inner {
497 position: relative;
498 z-index: 1;
499 display: flex;
500 align-items: flex-start;
501 gap: var(--space-5);
502 }
503 .profile-hero-avatar {
504 flex: 0 0 auto;
505 width: 88px;
506 height: 88px;
507 border-radius: 50%;
508 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
509 color: #fff;
510 display: flex;
511 align-items: center;
512 justify-content: center;
513 font-size: 38px;
514 font-weight: 700;
515 font-family: var(--font-display);
516 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
517 }
518 .profile-hero-text { flex: 1; min-width: 0; }
519 .profile-eyebrow {
520 font-size: 12px;
521 font-family: var(--font-mono);
522 color: var(--text-muted);
523 letter-spacing: 0.1em;
524 text-transform: uppercase;
525 margin-bottom: var(--space-2);
526 }
527 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
528 .profile-name {
529 font-family: var(--font-display);
530 font-weight: 800;
531 letter-spacing: -0.028em;
532 font-size: clamp(28px, 3.6vw, 36px);
533 line-height: 1.05;
534 margin: 0 0 4px;
535 color: var(--text-strong);
536 }
537 .profile-handle {
538 font-family: var(--font-mono);
539 font-size: 13px;
540 color: var(--text-muted);
541 margin-bottom: var(--space-2);
542 }
543 .profile-bio {
544 font-size: 14.5px;
545 color: var(--text);
546 line-height: 1.55;
547 margin: 0 0 var(--space-3);
548 max-width: 640px;
549 }
550 .profile-meta {
551 display: flex;
552 align-items: center;
553 gap: var(--space-4);
554 flex-wrap: wrap;
555 font-size: 13px;
556 }
557 .profile-meta-link {
558 color: var(--text-muted);
559 transition: color var(--t-fast, 0.15s) ease;
560 }
561 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
562 .profile-meta-link strong {
563 color: var(--text-strong);
564 font-weight: 600;
565 font-variant-numeric: tabular-nums;
566 }
567 .profile-follow-form { margin: 0; }
568 @media (max-width: 600px) {
569 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
570 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
571 }
572 .profile-readme {
573 margin-bottom: var(--space-6);
574 background: var(--bg-elevated);
575 border: 1px solid var(--border);
576 border-radius: 12px;
577 overflow: hidden;
578 }
579 .profile-readme-head {
580 display: flex;
581 align-items: center;
582 gap: 8px;
583 padding: 10px 16px;
584 background: var(--bg-secondary);
585 border-bottom: 1px solid var(--border);
586 font-size: 13px;
587 color: var(--text-muted);
588 }
589 .profile-readme-icon { color: var(--accent); font-size: 14px; }
590 .profile-readme-body { padding: var(--space-5) var(--space-6); }
591 .profile-section-head {
592 display: flex;
593 align-items: baseline;
594 gap: var(--space-2);
595 margin-bottom: var(--space-3);
596 }
597 .profile-section-title {
598 font-family: var(--font-display);
599 font-weight: 700;
600 font-size: 20px;
601 letter-spacing: -0.015em;
602 margin: 0;
603 color: var(--text-strong);
604 }
605 .profile-section-count {
606 font-family: var(--font-mono);
607 font-size: 12px;
608 color: var(--text-muted);
609 background: var(--bg-secondary);
610 border: 1px solid var(--border);
611 border-radius: 999px;
612 padding: 2px 10px;
613 font-variant-numeric: tabular-nums;
614 }
615 .profile-empty {
616 display: flex;
617 align-items: center;
618 justify-content: space-between;
619 gap: var(--space-3);
620 padding: var(--space-4) var(--space-5);
621 background: var(--bg-elevated);
622 border: 1px dashed var(--border);
623 border-radius: 12px;
624 }
625 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
626
627 /* ───────── tree (file browser) ───────── */
628 .tree-header {
629 margin-bottom: var(--space-3);
630 display: flex;
631 flex-direction: column;
632 gap: var(--space-2);
633 }
634 .tree-header-row {
635 display: flex;
636 align-items: center;
637 justify-content: space-between;
638 gap: var(--space-3);
639 flex-wrap: wrap;
640 }
641 .tree-header-stats {
642 display: flex;
643 align-items: center;
644 gap: var(--space-3);
645 font-size: 12px;
646 color: var(--text-muted);
647 }
648 .tree-stat strong {
649 color: var(--text-strong);
650 font-weight: 600;
651 font-variant-numeric: tabular-nums;
652 }
653 .tree-stat-link {
654 color: var(--text-muted);
655 padding: 4px 10px;
656 border-radius: 999px;
657 border: 1px solid var(--border);
658 background: var(--bg-elevated);
659 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
660 }
661 .tree-stat-link:hover {
662 color: var(--accent);
663 border-color: rgba(140,109,255,0.45);
664 text-decoration: none;
665 }
666 .tree-breadcrumb-row {
667 font-size: 13px;
668 }
669
670 /* ───────── blob (file viewer) ───────── */
671 .blob-toolbar {
672 margin-bottom: var(--space-3);
673 display: flex;
674 flex-direction: column;
675 gap: var(--space-2);
676 }
677 .blob-breadcrumb { font-size: 13px; }
678 .blob-card {
679 background: var(--bg-elevated);
680 border: 1px solid var(--border);
681 border-radius: 12px;
682 overflow: hidden;
683 }
684 .blob-header-polished {
685 display: flex;
686 align-items: center;
687 justify-content: space-between;
688 gap: var(--space-3);
689 padding: 10px 14px;
690 background: var(--bg-secondary);
691 border-bottom: 1px solid var(--border);
692 }
693 .blob-header-meta {
694 display: flex;
695 align-items: center;
696 gap: var(--space-2);
697 min-width: 0;
698 flex: 1;
699 }
700 .blob-header-icon { font-size: 14px; opacity: 0.85; }
701 .blob-header-name {
702 font-family: var(--font-mono);
703 font-size: 13px;
704 color: var(--text-strong);
705 font-weight: 600;
706 overflow: hidden;
707 text-overflow: ellipsis;
708 white-space: nowrap;
709 }
710 .blob-header-size {
711 font-family: var(--font-mono);
712 font-size: 11.5px;
713 color: var(--text-muted);
714 font-variant-numeric: tabular-nums;
715 border-left: 1px solid var(--border);
716 padding-left: var(--space-2);
717 margin-left: 2px;
718 }
719 .blob-header-actions {
720 display: flex;
721 gap: 6px;
722 flex-shrink: 0;
723 }
724 .blob-pill {
725 display: inline-flex;
726 align-items: center;
727 padding: 4px 12px;
728 font-size: 12px;
729 font-weight: 500;
730 color: var(--text);
731 background: var(--bg-elevated);
732 border: 1px solid var(--border);
733 border-radius: 999px;
734 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
735 }
736 .blob-pill:hover {
737 color: var(--accent);
738 border-color: rgba(140,109,255,0.45);
739 text-decoration: none;
740 background: rgba(140,109,255,0.06);
741 }
742 .blob-pill-accent {
743 color: var(--accent);
744 border-color: rgba(140,109,255,0.35);
745 background: rgba(140,109,255,0.08);
746 }
747 .blob-pill-accent:hover {
748 background: rgba(140,109,255,0.14);
749 }
750 .blob-binary {
751 padding: var(--space-5);
752 color: var(--text-muted);
753 text-align: center;
754 font-size: 13px;
755 background: var(--bg);
756 }
757
758 /* ───────── commits list ───────── */
759 .commits-hero {
760 position: relative;
761 margin-bottom: var(--space-4);
762 padding: var(--space-5) var(--space-6);
763 background: var(--bg-elevated);
764 border: 1px solid var(--border);
765 border-radius: 16px;
766 overflow: hidden;
767 }
768 .commits-hero-orb-wrap {
769 position: absolute;
770 inset: -25% -10% auto auto;
771 width: 320px;
772 height: 320px;
773 pointer-events: none;
774 z-index: 0;
775 }
776 .commits-hero-orb {
777 position: absolute;
778 inset: 0;
779 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
780 filter: blur(80px);
781 opacity: 0.7;
782 animation: cbHeroOrb 14s ease-in-out infinite;
783 }
784 .commits-hero-inner { position: relative; z-index: 1; }
785 .commits-eyebrow {
786 font-size: 12px;
787 font-family: var(--font-mono);
788 color: var(--text-muted);
789 letter-spacing: 0.1em;
790 text-transform: uppercase;
791 margin-bottom: var(--space-2);
792 }
793 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
794 .commits-title {
795 font-family: var(--font-display);
796 font-weight: 800;
797 letter-spacing: -0.025em;
798 font-size: clamp(22px, 3vw, 30px);
799 line-height: 1.15;
800 margin: 0 0 var(--space-2);
801 color: var(--text-strong);
802 }
803 .commits-branch {
804 font-family: var(--font-mono);
805 font-size: 0.7em;
806 color: var(--text);
807 background: var(--bg-secondary);
808 border: 1px solid var(--border);
809 border-radius: 6px;
810 padding: 2px 8px;
811 font-weight: 500;
812 vertical-align: middle;
813 }
814 .commits-sub {
815 font-size: 14px;
816 color: var(--text-muted);
817 margin: 0;
818 line-height: 1.55;
819 max-width: 640px;
820 }
398a10cClaude821 .commits-toolbar {
822 display: flex;
823 justify-content: space-between;
824 align-items: center;
825 flex-wrap: wrap;
826 gap: var(--space-2);
827 margin-bottom: var(--space-3);
828 }
829 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
830 .commits-toolbar-link {
831 display: inline-flex;
832 align-items: center;
833 gap: 6px;
834 padding: 6px 12px;
835 font-size: 12.5px;
836 font-weight: 500;
837 color: var(--text-muted);
838 background: rgba(255,255,255,0.025);
839 border: 1px solid var(--border);
840 border-radius: 8px;
841 text-decoration: none;
842 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
843 }
844 .commits-toolbar-link:hover {
845 border-color: var(--border-strong);
846 color: var(--text-strong);
847 background: rgba(255,255,255,0.04);
848 text-decoration: none;
849 }
efb11c5Claude850 .commits-list-wrap {
851 background: var(--bg-elevated);
852 border: 1px solid var(--border);
398a10cClaude853 border-radius: 14px;
efb11c5Claude854 overflow: hidden;
398a10cClaude855 position: relative;
856 }
857 .commits-list-wrap::before {
858 content: '';
859 position: absolute;
860 top: 0; left: 0; right: 0;
861 height: 2px;
862 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
863 opacity: 0.55;
864 pointer-events: none;
865 }
866 .commits-day-head {
867 display: flex;
868 align-items: center;
869 gap: 10px;
870 padding: 10px 18px;
871 font-size: 11.5px;
872 font-family: var(--font-mono);
873 text-transform: uppercase;
874 letter-spacing: 0.08em;
875 color: var(--text-muted);
876 background: var(--bg-secondary);
877 border-bottom: 1px solid var(--border);
878 }
879 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
880 .commits-day-head-dot {
881 width: 6px; height: 6px;
882 border-radius: 9999px;
883 background: linear-gradient(135deg, #8c6dff, #36c5d6);
884 }
885 .commits-row {
886 display: flex;
887 align-items: flex-start;
888 gap: 14px;
889 padding: 14px 18px;
890 border-bottom: 1px solid var(--border-subtle);
891 transition: background 120ms ease;
892 }
893 .commits-row:last-child { border-bottom: none; }
894 .commits-row:hover { background: rgba(255,255,255,0.022); }
895 .commits-avatar {
896 width: 34px; height: 34px;
897 border-radius: 9999px;
898 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
899 color: #fff;
900 display: inline-flex;
901 align-items: center;
902 justify-content: center;
903 font-family: var(--font-display);
904 font-weight: 700;
905 font-size: 13.5px;
906 flex-shrink: 0;
907 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
908 }
909 .commits-row-body { flex: 1; min-width: 0; }
910 .commits-row-msg {
911 display: flex;
912 align-items: center;
913 gap: 8px;
914 flex-wrap: wrap;
915 }
916 .commits-row-msg a {
917 font-family: var(--font-display);
918 font-weight: 600;
919 font-size: 14px;
920 color: var(--text-strong);
921 text-decoration: none;
922 letter-spacing: -0.005em;
923 }
924 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
925 .commits-row-verified {
926 font-size: 9.5px;
927 padding: 1px 7px;
928 border-radius: 9999px;
929 background: rgba(52,211,153,0.16);
930 color: #6ee7b7;
931 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
932 text-transform: uppercase;
933 letter-spacing: 0.06em;
934 font-weight: 700;
935 }
936 .commits-row-meta {
937 margin-top: 4px;
938 display: flex;
939 align-items: center;
940 gap: 8px;
941 flex-wrap: wrap;
942 font-size: 12.5px;
943 color: var(--text-muted);
944 }
945 .commits-row-meta strong { color: var(--text); font-weight: 600; }
946 .commits-row-meta .sep { opacity: 0.4; }
947 .commits-row-time {
948 font-variant-numeric: tabular-nums;
949 }
950 .commits-row-side {
951 display: flex;
952 align-items: center;
953 gap: 8px;
954 flex-shrink: 0;
955 }
956 .commits-row-sha {
957 display: inline-flex;
958 align-items: center;
959 padding: 4px 10px;
960 border-radius: 9999px;
961 font-family: var(--font-mono);
962 font-size: 11.5px;
963 font-weight: 600;
964 color: var(--text-strong);
965 background: rgba(255,255,255,0.04);
966 border: 1px solid var(--border);
967 text-decoration: none;
968 letter-spacing: 0.04em;
969 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
970 }
971 .commits-row-sha:hover {
972 border-color: rgba(140,109,255,0.55);
973 color: var(--accent);
974 background: rgba(140,109,255,0.08);
975 text-decoration: none;
976 }
977 .commits-row-copy {
978 display: inline-flex;
979 align-items: center;
980 justify-content: center;
981 width: 28px; height: 28px;
982 padding: 0;
983 border-radius: 8px;
984 background: transparent;
985 border: 1px solid var(--border);
986 color: var(--text-muted);
987 cursor: pointer;
988 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude989 }
398a10cClaude990 .commits-row-copy:hover {
991 border-color: var(--border-strong);
992 color: var(--text);
993 background: rgba(255,255,255,0.04);
994 }
995 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude996 /* Push Watch link — eye icon next to the SHA chip */
997 .commits-row-watch {
998 display: inline-flex;
999 align-items: center;
1000 justify-content: center;
1001 width: 28px;
1002 height: 28px;
1003 border-radius: 6px;
1004 border: 1px solid var(--border);
1005 color: var(--text-muted);
1006 background: transparent;
1007 cursor: pointer;
1008 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1009 text-decoration: none !important;
1010 }
1011 .commits-row-watch:hover {
1012 border-color: rgba(140,109,255,0.45);
1013 color: var(--accent);
1014 background: rgba(140,109,255,0.08);
1015 text-decoration: none !important;
1016 }
efb11c5Claude1017 .commits-empty {
398a10cClaude1018 position: relative;
1019 overflow: hidden;
1020 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1021 text-align: center;
398a10cClaude1022 background: var(--bg-elevated);
1023 border: 1px dashed var(--border-strong);
1024 border-radius: 16px;
1025 }
1026 .commits-empty-orb {
1027 position: absolute;
1028 inset: -40% 30% auto 30%;
1029 height: 280px;
1030 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
1031 filter: blur(70px);
1032 opacity: 0.7;
1033 pointer-events: none;
1034 z-index: 0;
1035 }
1036 .commits-empty-inner { position: relative; z-index: 1; }
1037 .commits-empty-icon {
1038 width: 56px; height: 56px;
1039 border-radius: 9999px;
1040 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
1041 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
1042 display: inline-flex;
1043 align-items: center;
1044 justify-content: center;
1045 color: #c4b5fd;
1046 margin: 0 auto 14px;
1047 }
1048 .commits-empty-title {
1049 font-family: var(--font-display);
1050 font-size: 18px;
1051 font-weight: 700;
1052 margin: 0 0 6px;
1053 color: var(--text-strong);
1054 }
1055 .commits-empty-sub {
1056 margin: 0 auto 0;
1057 font-size: 13.5px;
efb11c5Claude1058 color: var(--text-muted);
398a10cClaude1059 max-width: 420px;
1060 line-height: 1.5;
1061 }
1062
1063 /* ───────── branches list ───────── */
1064 .branches-list {
efb11c5Claude1065 background: var(--bg-elevated);
398a10cClaude1066 border: 1px solid var(--border);
1067 border-radius: 14px;
1068 overflow: hidden;
1069 position: relative;
1070 }
1071 .branches-list::before {
1072 content: '';
1073 position: absolute;
1074 top: 0; left: 0; right: 0;
1075 height: 2px;
1076 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1077 opacity: 0.55;
1078 pointer-events: none;
1079 }
1080 .branches-row {
1081 display: flex;
1082 align-items: center;
1083 gap: var(--space-3);
1084 padding: 14px 18px;
1085 border-bottom: 1px solid var(--border-subtle);
1086 transition: background 120ms ease;
1087 flex-wrap: wrap;
1088 }
1089 .branches-row:last-child { border-bottom: none; }
1090 .branches-row:hover { background: rgba(255,255,255,0.022); }
1091 .branches-row-icon {
1092 width: 32px; height: 32px;
1093 border-radius: 8px;
1094 background: rgba(140,109,255,0.10);
1095 color: #c4b5fd;
1096 display: inline-flex;
1097 align-items: center;
1098 justify-content: center;
1099 flex-shrink: 0;
1100 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1101 }
1102 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1103 .branches-row-name {
1104 display: inline-flex;
1105 align-items: center;
1106 gap: 8px;
1107 flex-wrap: wrap;
1108 }
1109 .branches-row-name a {
1110 font-family: var(--font-mono);
1111 font-size: 13.5px;
1112 color: var(--text-strong);
1113 font-weight: 600;
1114 text-decoration: none;
1115 }
1116 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1117 .branches-row-default {
1118 font-size: 10px;
1119 padding: 2px 8px;
1120 border-radius: 9999px;
1121 background: rgba(140,109,255,0.14);
1122 color: #c4b5fd;
1123 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1124 text-transform: uppercase;
1125 letter-spacing: 0.08em;
1126 font-weight: 700;
1127 font-family: var(--font-mono);
1128 }
1129 .branches-row-meta {
1130 display: flex;
1131 align-items: center;
1132 gap: 8px;
1133 flex-wrap: wrap;
1134 font-size: 12.5px;
1135 color: var(--text-muted);
1136 font-variant-numeric: tabular-nums;
1137 }
1138 .branches-row-meta .sep { opacity: 0.4; }
1139 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1140 .branches-row-side {
1141 display: flex;
1142 align-items: center;
1143 gap: 10px;
1144 flex-shrink: 0;
1145 flex-wrap: wrap;
1146 }
1147 .branches-row-divergence {
1148 display: inline-flex;
1149 align-items: center;
1150 gap: 8px;
1151 font-family: var(--font-mono);
1152 font-size: 11.5px;
1153 color: var(--text-muted);
1154 padding: 4px 10px;
1155 border-radius: 9999px;
1156 background: rgba(255,255,255,0.035);
1157 border: 1px solid var(--border);
1158 font-variant-numeric: tabular-nums;
1159 }
1160 .branches-row-divergence .ahead { color: #6ee7b7; }
1161 .branches-row-divergence .behind { color: #fca5a5; }
1162 .branches-row-actions {
1163 display: flex;
1164 align-items: center;
1165 gap: 6px;
1166 }
1167 .branches-btn {
1168 display: inline-flex;
1169 align-items: center;
1170 gap: 5px;
1171 padding: 6px 12px;
1172 border-radius: 9999px;
1173 font-size: 12px;
1174 font-weight: 600;
1175 text-decoration: none;
1176 border: 1px solid var(--border);
1177 background: transparent;
1178 color: var(--text-muted);
1179 cursor: pointer;
1180 font: inherit;
1181 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1182 }
1183 .branches-btn:hover {
1184 border-color: var(--border-strong);
1185 color: var(--text);
1186 background: rgba(255,255,255,0.04);
1187 text-decoration: none;
1188 }
1189 .branches-btn-danger {
1190 color: #fca5a5;
1191 border-color: rgba(248,113,113,0.30);
1192 }
1193 .branches-btn-danger:hover {
1194 border-style: dashed;
1195 border-color: rgba(248,113,113,0.65);
1196 background: rgba(248,113,113,0.06);
1197 color: #fecaca;
1198 }
1199
1200 /* ───────── tags list ───────── */
1201 .tags-list {
1202 background: var(--bg-elevated);
1203 border: 1px solid var(--border);
1204 border-radius: 14px;
1205 overflow: hidden;
1206 position: relative;
1207 }
1208 .tags-list::before {
1209 content: '';
1210 position: absolute;
1211 top: 0; left: 0; right: 0;
1212 height: 2px;
1213 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1214 opacity: 0.55;
1215 pointer-events: none;
1216 }
1217 .tags-row {
1218 display: flex;
1219 align-items: center;
1220 gap: var(--space-3);
1221 padding: 14px 18px;
1222 border-bottom: 1px solid var(--border-subtle);
1223 transition: background 120ms ease;
1224 flex-wrap: wrap;
1225 }
1226 .tags-row:last-child { border-bottom: none; }
1227 .tags-row:hover { background: rgba(255,255,255,0.022); }
1228 .tags-row-icon {
1229 width: 32px; height: 32px;
1230 border-radius: 8px;
1231 background: rgba(54,197,214,0.10);
1232 color: #67e8f9;
1233 display: inline-flex;
1234 align-items: center;
1235 justify-content: center;
1236 flex-shrink: 0;
1237 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1238 }
1239 .tags-row-main { flex: 1; min-width: 240px; }
1240 .tags-row-name {
1241 display: inline-flex;
1242 align-items: center;
1243 gap: 8px;
1244 flex-wrap: wrap;
1245 }
1246 .tags-row-version {
1247 display: inline-flex;
1248 align-items: center;
1249 padding: 3px 11px;
1250 border-radius: 9999px;
1251 font-family: var(--font-mono);
1252 font-size: 13px;
1253 font-weight: 700;
1254 color: #67e8f9;
1255 background: rgba(54,197,214,0.12);
1256 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1257 letter-spacing: 0.01em;
1258 }
1259 .tags-row-meta {
1260 margin-top: 4px;
1261 display: flex;
1262 align-items: center;
1263 gap: 8px;
1264 flex-wrap: wrap;
1265 font-size: 12.5px;
1266 color: var(--text-muted);
1267 font-variant-numeric: tabular-nums;
1268 }
1269 .tags-row-meta .sep { opacity: 0.4; }
1270 .tags-row-sha {
1271 font-family: var(--font-mono);
1272 font-size: 11.5px;
1273 color: var(--text-strong);
1274 padding: 2px 8px;
1275 border-radius: 6px;
1276 background: rgba(255,255,255,0.04);
1277 border: 1px solid var(--border);
1278 text-decoration: none;
1279 letter-spacing: 0.04em;
1280 }
1281 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1282 .tags-row-side {
1283 display: flex;
1284 align-items: center;
1285 gap: 6px;
1286 flex-shrink: 0;
1287 flex-wrap: wrap;
1288 }
1289 .tags-row-link {
1290 display: inline-flex;
1291 align-items: center;
1292 gap: 5px;
1293 padding: 6px 12px;
1294 border-radius: 9999px;
1295 font-size: 12px;
1296 font-weight: 600;
1297 text-decoration: none;
1298 color: var(--text-muted);
1299 background: rgba(255,255,255,0.025);
1300 border: 1px solid var(--border);
1301 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1302 }
1303 .tags-row-link:hover {
1304 border-color: rgba(140,109,255,0.45);
1305 color: var(--text-strong);
1306 background: rgba(140,109,255,0.06);
1307 text-decoration: none;
efb11c5Claude1308 }
1309
1310 /* ───────── commit detail ───────── */
1311 .commit-detail-card {
1312 position: relative;
1313 margin-bottom: var(--space-5);
1314 padding: var(--space-5) var(--space-5);
1315 background: var(--bg-elevated);
1316 border: 1px solid var(--border);
1317 border-radius: 14px;
1318 overflow: hidden;
1319 }
1320 .commit-detail-eyebrow {
1321 display: flex;
1322 align-items: center;
1323 gap: var(--space-2);
1324 font-size: 12px;
1325 font-family: var(--font-mono);
1326 text-transform: uppercase;
1327 letter-spacing: 0.1em;
1328 color: var(--text-muted);
1329 margin-bottom: var(--space-2);
1330 }
1331 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1332 .commit-detail-sha-pill {
1333 font-family: var(--font-mono);
1334 font-size: 11.5px;
1335 color: var(--text-strong);
1336 background: var(--bg-secondary);
1337 border: 1px solid var(--border);
1338 border-radius: 999px;
1339 padding: 2px 10px;
1340 letter-spacing: 0.04em;
1341 }
1342 .commit-detail-verify {
1343 font-size: 10px;
1344 padding: 2px 8px;
1345 border-radius: 999px;
1346 text-transform: uppercase;
1347 letter-spacing: 0.06em;
1348 font-weight: 700;
1349 color: #fff;
1350 }
1351 .commit-detail-verify-ok {
1352 background: linear-gradient(135deg, #2ea043, #34d399);
1353 }
1354 .commit-detail-verify-warn {
1355 background: linear-gradient(135deg, #d29922, #f59e0b);
1356 }
1357 .commit-detail-title {
1358 font-family: var(--font-display);
1359 font-weight: 700;
1360 letter-spacing: -0.018em;
1361 font-size: clamp(20px, 2.5vw, 26px);
1362 line-height: 1.25;
1363 margin: 0 0 var(--space-2);
1364 color: var(--text-strong);
1365 }
1366 .commit-detail-body {
1367 white-space: pre-wrap;
1368 color: var(--text-muted);
1369 font-size: 14px;
1370 line-height: 1.55;
1371 margin: 0 0 var(--space-3);
1372 font-family: var(--font-mono);
1373 background: var(--bg);
1374 border: 1px solid var(--border);
1375 border-radius: 8px;
1376 padding: var(--space-3) var(--space-4);
1377 max-height: 280px;
1378 overflow: auto;
1379 }
1380 .commit-detail-meta {
1381 display: flex;
1382 flex-wrap: wrap;
1383 gap: var(--space-3);
1384 font-size: 13px;
1385 color: var(--text-muted);
1386 margin-bottom: var(--space-3);
1387 }
1388 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1389 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1390 .commit-detail-sha-link {
1391 font-family: var(--font-mono);
1392 font-size: 12.5px;
1393 color: var(--accent);
1394 background: rgba(140,109,255,0.08);
1395 border-radius: 6px;
1396 padding: 1px 6px;
1397 margin-left: 2px;
1398 }
1399 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1400 .commit-detail-stats {
1401 display: flex;
1402 flex-wrap: wrap;
1403 align-items: center;
1404 gap: var(--space-3);
1405 padding-top: var(--space-3);
1406 border-top: 1px solid var(--border);
1407 font-size: 13px;
1408 color: var(--text-muted);
1409 }
1410 .commit-detail-stat {
1411 display: inline-flex;
1412 align-items: center;
1413 gap: 4px;
1414 font-variant-numeric: tabular-nums;
1415 }
1416 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1417 .commit-detail-stat-add strong { color: #34d399; }
1418 .commit-detail-stat-del strong { color: #f87171; }
1419 .commit-detail-stat-mark {
1420 font-family: var(--font-mono);
1421 font-weight: 700;
1422 font-size: 14px;
1423 }
1424 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1425 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1426 .commit-detail-sha-full {
1427 margin-left: auto;
1428 font-family: var(--font-mono);
1429 font-size: 11.5px;
1430 color: var(--text-faint);
1431 letter-spacing: 0.02em;
1432 overflow: hidden;
1433 text-overflow: ellipsis;
1434 white-space: nowrap;
1435 max-width: 100%;
1436 }
1437 .commit-detail-checks {
1438 margin-top: var(--space-3);
1439 padding-top: var(--space-3);
1440 border-top: 1px solid var(--border);
1441 }
1442 .commit-detail-checks-head {
1443 display: flex;
1444 align-items: center;
1445 gap: var(--space-2);
1446 font-size: 13px;
1447 color: var(--text-muted);
1448 margin-bottom: 8px;
1449 }
1450 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1451 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1452 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1453 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1454 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1455 .commit-detail-check {
1456 font-size: 11px;
1457 padding: 2px 8px;
1458 border-radius: 999px;
1459 color: #fff;
1460 font-weight: 500;
1461 }
398a10cClaude1462 .commit-detail-check a { color: inherit; text-decoration: none; }
1463 .commit-detail-check-success { background: #2ea043; }
1464 .commit-detail-check-pending { background: #d29922; }
1465 .commit-detail-check-failure { background: #da3633; }
1466
1467 /* ───────── blame ───────── */
1468 .blame-head { margin-bottom: var(--space-5); }
1469 .blame-eyebrow {
1470 display: inline-flex;
1471 align-items: center;
1472 gap: 8px;
1473 text-transform: uppercase;
1474 font-family: var(--font-mono);
1475 font-size: 11px;
1476 letter-spacing: 0.16em;
1477 color: var(--text-muted);
1478 font-weight: 600;
1479 margin-bottom: 10px;
1480 }
1481 .blame-eyebrow-dot {
1482 width: 8px; height: 8px;
1483 border-radius: 9999px;
1484 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1485 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1486 }
1487 .blame-title {
1488 font-family: var(--font-display);
1489 font-size: clamp(22px, 3vw, 30px);
1490 font-weight: 800;
1491 letter-spacing: -0.025em;
1492 line-height: 1.15;
1493 margin: 0 0 6px;
1494 color: var(--text-strong);
1495 }
1496 .blame-title code {
1497 font-family: var(--font-mono);
1498 font-size: 0.78em;
1499 color: var(--text-strong);
1500 font-weight: 700;
1501 }
1502 .blame-sub {
1503 margin: 0;
1504 font-size: 14px;
1505 color: var(--text-muted);
1506 line-height: 1.5;
1507 max-width: 700px;
1508 }
efb11c5Claude1509 .blame-toolbar {
398a10cClaude1510 display: flex;
1511 justify-content: space-between;
1512 align-items: center;
1513 gap: var(--space-3);
efb11c5Claude1514 margin-bottom: var(--space-3);
398a10cClaude1515 flex-wrap: wrap;
efb11c5Claude1516 font-size: 13px;
1517 }
398a10cClaude1518 .blame-toolbar-actions { display: flex; gap: 6px; }
1519 .blame-card {
1520 background: var(--bg-elevated);
1521 border: 1px solid var(--border);
1522 border-radius: 14px;
1523 overflow: hidden;
1524 position: relative;
1525 }
1526 .blame-card::before {
1527 content: '';
1528 position: absolute;
1529 top: 0; left: 0; right: 0;
1530 height: 2px;
1531 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1532 opacity: 0.55;
1533 pointer-events: none;
1534 }
efb11c5Claude1535 .blame-header {
1536 display: flex;
1537 align-items: center;
1538 justify-content: space-between;
1539 gap: var(--space-3);
1540 padding: 10px 14px;
1541 background: var(--bg-secondary);
1542 border-bottom: 1px solid var(--border);
1543 }
1544 .blame-header-meta {
1545 display: flex;
1546 align-items: center;
1547 gap: var(--space-2);
1548 min-width: 0;
1549 flex-wrap: wrap;
1550 }
1551 .blame-header-icon { color: var(--accent); font-size: 14px; }
1552 .blame-header-name {
1553 font-family: var(--font-mono);
1554 font-size: 13px;
1555 color: var(--text-strong);
1556 font-weight: 600;
1557 }
1558 .blame-header-tag {
1559 font-size: 10.5px;
1560 text-transform: uppercase;
1561 letter-spacing: 0.08em;
1562 font-family: var(--font-mono);
1563 background: rgba(140,109,255,0.12);
1564 color: var(--accent);
1565 border-radius: 999px;
1566 padding: 2px 8px;
1567 font-weight: 600;
1568 }
1569 .blame-header-stats {
1570 font-family: var(--font-mono);
1571 font-size: 11.5px;
1572 color: var(--text-muted);
1573 font-variant-numeric: tabular-nums;
1574 border-left: 1px solid var(--border);
1575 padding-left: var(--space-2);
1576 }
1577 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1578 .blame-table {
1579 width: 100%;
1580 border-collapse: collapse;
1581 font-family: var(--font-mono);
1582 font-size: 12.5px;
1583 line-height: 1.6;
1584 }
1585 .blame-table tr { border-bottom: 1px solid transparent; }
1586 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1587 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1588 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1589 .blame-gutter {
1590 width: 220px;
1591 min-width: 220px;
1592 padding: 0 12px;
1593 vertical-align: top;
1594 background: rgba(255,255,255,0.012);
1595 border-right: 1px solid var(--border-subtle);
1596 font-variant-numeric: tabular-nums;
1597 color: var(--text-muted);
1598 font-size: 11px;
1599 white-space: nowrap;
1600 overflow: hidden;
1601 text-overflow: ellipsis;
1602 padding-top: 2px;
1603 padding-bottom: 2px;
1604 }
1605 .blame-gutter-inner {
1606 display: inline-flex;
1607 align-items: center;
1608 gap: 7px;
1609 max-width: 100%;
1610 overflow: hidden;
1611 }
1612 .blame-gutter-sha {
1613 font-family: var(--font-mono);
1614 font-size: 10.5px;
1615 font-weight: 600;
1616 color: #c4b5fd;
1617 background: rgba(140,109,255,0.10);
1618 padding: 1px 7px;
1619 border-radius: 9999px;
1620 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1621 text-decoration: none;
1622 letter-spacing: 0.04em;
1623 flex-shrink: 0;
1624 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1625 }
1626 .blame-gutter-sha:hover {
1627 background: rgba(140,109,255,0.22);
1628 color: #ddd6fe;
1629 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1630 text-decoration: none;
1631 }
1632 .blame-gutter-author {
1633 color: var(--text);
1634 overflow: hidden;
1635 text-overflow: ellipsis;
1636 white-space: nowrap;
1637 font-size: 11px;
1638 font-family: var(--font-sans, inherit);
1639 }
1640 .blame-line-num {
1641 width: 1%;
1642 min-width: 50px;
1643 padding: 0 12px;
1644 text-align: right;
1645 color: var(--text-faint);
1646 user-select: none;
1647 border-right: 1px solid var(--border-subtle);
1648 font-variant-numeric: tabular-nums;
1649 }
1650 .blame-line-content {
1651 padding: 0 14px;
1652 white-space: pre;
1653 color: var(--text);
1654 transition: background 120ms ease;
1655 }
efb11c5Claude1656
1657 /* ───────── search ───────── */
1658 .search-hero {
1659 position: relative;
1660 margin-bottom: var(--space-4);
1661 padding: var(--space-5) var(--space-6);
1662 background: var(--bg-elevated);
1663 border: 1px solid var(--border);
1664 border-radius: 16px;
1665 overflow: hidden;
1666 }
1667 .search-eyebrow {
1668 font-size: 12px;
1669 font-family: var(--font-mono);
1670 color: var(--text-muted);
1671 letter-spacing: 0.1em;
1672 text-transform: uppercase;
1673 margin-bottom: var(--space-2);
1674 }
1675 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1676 .search-title {
1677 font-family: var(--font-display);
1678 font-weight: 800;
1679 letter-spacing: -0.025em;
1680 font-size: clamp(22px, 3vw, 30px);
1681 line-height: 1.15;
1682 margin: 0 0 var(--space-3);
1683 color: var(--text-strong);
1684 }
1685 .search-form {
1686 display: flex;
1687 gap: var(--space-2);
1688 align-items: stretch;
1689 }
1690 .search-input-wrap {
1691 position: relative;
1692 flex: 1;
1693 display: flex;
1694 align-items: center;
1695 }
1696 .search-input-icon {
1697 position: absolute;
1698 left: 12px;
1699 color: var(--text-muted);
1700 font-size: 15px;
1701 pointer-events: none;
1702 }
1703 .search-input {
1704 appearance: none;
1705 width: 100%;
1706 padding: 10px 12px 10px 34px;
1707 background: var(--bg);
1708 border: 1px solid var(--border);
1709 border-radius: 10px;
1710 color: var(--text-strong);
1711 font-size: 14px;
1712 font-family: inherit;
1713 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1714 }
1715 .search-input:focus {
1716 outline: none;
1717 border-color: var(--accent);
1718 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1719 }
1720 .search-submit { min-width: 96px; }
1721 .search-results-head {
1722 display: flex;
1723 align-items: baseline;
1724 gap: var(--space-2);
1725 margin-bottom: var(--space-3);
1726 font-size: 13px;
1727 color: var(--text-muted);
1728 }
1729 .search-results-count strong {
1730 color: var(--text-strong);
1731 font-weight: 600;
1732 font-variant-numeric: tabular-nums;
1733 }
1734 .search-results-q { color: var(--text-strong); font-weight: 600; }
1735 .search-results-head code {
1736 font-family: var(--font-mono);
1737 font-size: 12px;
1738 background: var(--bg-secondary);
1739 border: 1px solid var(--border);
1740 border-radius: 5px;
1741 padding: 1px 6px;
1742 color: var(--text);
1743 }
1744 .search-empty {
1745 padding: var(--space-5) var(--space-6);
1746 background: var(--bg-elevated);
1747 border: 1px dashed var(--border);
1748 border-radius: 12px;
1749 color: var(--text-muted);
1750 font-size: 14px;
1751 }
1752 .search-empty strong { color: var(--text-strong); }
1753 .search-results {
1754 display: flex;
1755 flex-direction: column;
1756 gap: var(--space-3);
1757 }
1758 .search-file-head {
1759 display: flex;
1760 align-items: center;
1761 justify-content: space-between;
1762 gap: var(--space-2);
1763 }
1764 .search-file-link {
1765 font-family: var(--font-mono);
1766 font-size: 13px;
1767 color: var(--text-strong);
1768 font-weight: 600;
1769 }
1770 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1771 .search-file-count {
1772 font-family: var(--font-mono);
1773 font-size: 11.5px;
1774 color: var(--text-muted);
1775 font-variant-numeric: tabular-nums;
1776 }
1777`;
1778
fc1817aClaude1779// Home page
06d5ffeClaude1780web.get("/", async (c) => {
1781 const user = c.get("user");
1782
1783 if (user) {
0316dbbClaude1784 return c.redirect("/dashboard");
06d5ffeClaude1785 }
1786
8e9f1d9Claude1787 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1788 let publicStats: PublicStats | null = null;
8e9f1d9Claude1789 try {
1790 const [repoRow] = await db
1791 .select({ n: sql<number>`count(*)::int` })
1792 .from(repositories)
1793 .where(eq(repositories.isPrivate, false));
1794 const [userRow] = await db
1795 .select({ n: sql<number>`count(*)::int` })
1796 .from(users);
1797 stats = {
1798 publicRepos: Number(repoRow?.n ?? 0),
1799 users: Number(userRow?.n ?? 0),
1800 };
1801 } catch {
1802 stats = undefined;
1803 }
1804
52ad8b1Claude1805 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1806 try {
1807 publicStats = await computePublicStats();
1808 } catch {
1809 publicStats = null;
1810 }
1811
534f04aClaude1812 // Block M1 — initial SSR snapshot for the live-now demo feed.
1813 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1814 // in try/catch so a freak module-level explosion can't take down /.
1815 let liveFeed: LandingLiveFeed | null = null;
1816 try {
1817 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1818 listQueuedAiBuildIssues(3),
1819 listRecentAutoMerges(3, 24),
1820 listRecentAiReviews(3, 24),
1821 countAiReviewsSince(24),
1822 listDemoActivityFeed(10),
1823 ]);
1824 liveFeed = {
1825 queued: queued.map((i) => ({
1826 repo: i.repo,
1827 number: i.number,
1828 title: i.title,
1829 createdAt: i.createdAt,
1830 })),
1831 merges: merges.map((m) => ({
1832 repo: m.repo,
1833 number: m.number,
1834 title: m.title,
1835 mergedAt: m.mergedAt,
1836 })),
1837 reviews: reviewList.map((r) => ({
1838 repo: r.repo,
1839 prNumber: r.prNumber,
1840 commentSnippet: r.commentSnippet,
1841 createdAt: r.createdAt,
1842 })),
1843 reviewCount,
1844 feed: feed.map((e) => ({
1845 kind: e.kind,
1846 repo: e.repo,
1847 ref: e.ref,
1848 at: e.at,
1849 })),
1850 };
1851 } catch {
1852 liveFeed = null;
1853 }
1854
29924bcClaude1855 // 2030 reboot — the public landing is a self-contained light marketing
1856 // document (its own shell + design system), rendered directly so it never
1857 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1858 // above for the legacy LandingPage and other surfaces; the new page uses the
1859 // headline counters from `stats`.
1860 void publicStats;
1861 void liveFeed;
1862 void LandingPage;
1863 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1864});
1865
06d5ffeClaude1866// New repository form
1867web.get("/new", requireAuth, (c) => {
1868 const user = c.get("user")!;
1869 const error = c.req.query("error");
1870
1871 return c.html(
1872 <Layout title="New repository" user={user}>
efb11c5Claude1873 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1874 <div class="new-repo-hero">
1875 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1876 <div class="new-repo-hero-orb" />
1877 </div>
1878 <div class="new-repo-hero-inner">
1879 <div class="new-repo-eyebrow">
1880 <strong>Create</strong> · {user.username}
1881 </div>
1882 <h1 class="new-repo-title">
1883 Spin up a <span class="gradient-text">repository</span>.
1884 </h1>
1885 <p class="new-repo-sub">
1886 Push your first commit, and Gluecron wires up gate checks, AI review,
1887 and auto-merge from the moment your branch lands.
1888 </p>
1889 </div>
1890 </div>
06d5ffeClaude1891 <div class="new-repo-form">
efb11c5Claude1892 {error && (
1893 <div class="new-repo-error" role="alert">
1894 {decodeURIComponent(error)}
1895 </div>
1896 )}
1897 <form method="post" action="/new" class="new-repo-form-grid">
1898 <div class="new-repo-row">
1899 <label class="new-repo-label">Owner</label>
1900 <input
1901 type="text"
1902 value={user.username}
1903 disabled
1904 aria-label="Owner"
1905 class="new-repo-input new-repo-input-disabled"
1906 />
06d5ffeClaude1907 </div>
efb11c5Claude1908 <div class="new-repo-row">
1909 <label class="new-repo-label" for="name">
1910 Repository name
1911 </label>
06d5ffeClaude1912 <input
1913 type="text"
1914 id="name"
1915 name="name"
1916 required
1917 pattern="^[a-zA-Z0-9._-]+$"
1918 placeholder="my-project"
1919 autocomplete="off"
efb11c5Claude1920 class="new-repo-input"
06d5ffeClaude1921 />
efb11c5Claude1922 <p class="new-repo-hint">
1923 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1924 <code>{user.username}/&lt;name&gt;</code>.
1925 </p>
06d5ffeClaude1926 </div>
efb11c5Claude1927 <div class="new-repo-row">
1928 <label class="new-repo-label" for="description">
1929 Description{" "}
1930 <span class="new-repo-label-optional">(optional)</span>
1931 </label>
06d5ffeClaude1932 <input
1933 type="text"
1934 id="description"
1935 name="description"
1936 placeholder="A short description of your repository"
efb11c5Claude1937 class="new-repo-input"
06d5ffeClaude1938 />
1939 </div>
efb11c5Claude1940 <div class="new-repo-row">
1941 <span class="new-repo-label">Visibility</span>
1942 <div class="new-repo-visibility">
1943 <label class="new-repo-vis-card">
1944 <input
1945 type="radio"
1946 name="visibility"
1947 value="public"
1948 checked
1949 class="new-repo-vis-radio"
1950 />
1951 <span class="new-repo-vis-body">
1952 <span class="new-repo-vis-label">Public</span>
1953 <span class="new-repo-vis-desc">
1954 Anyone can see this repository. You choose who can commit.
1955 </span>
1956 </span>
1957 </label>
1958 <label class="new-repo-vis-card">
1959 <input
1960 type="radio"
1961 name="visibility"
1962 value="private"
1963 class="new-repo-vis-radio"
1964 />
1965 <span class="new-repo-vis-body">
1966 <span class="new-repo-vis-label">Private</span>
1967 <span class="new-repo-vis-desc">
1968 Only you (and collaborators you invite) can see this
1969 repository.
1970 </span>
1971 </span>
1972 </label>
1973 </div>
1974 </div>
398a10cClaude1975 <div class="new-repo-row">
1976 <span class="new-repo-label">
1977 Starter content{" "}
1978 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1979 </span>
1980 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1981 <label class="new-repo-template-chip">
1982 <input type="radio" name="starter" value="empty" checked />
1983 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1984 Empty
1985 </label>
1986 <label class="new-repo-template-chip">
1987 <input type="radio" name="starter" value="readme" />
1988 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1989 README
1990 </label>
1991 <label class="new-repo-template-chip">
1992 <input type="radio" name="starter" value="readme-mit" />
1993 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1994 README + MIT
1995 </label>
1996 <label class="new-repo-template-chip">
1997 <input type="radio" name="starter" value="node" />
1998 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1999 Node + .gitignore
2000 </label>
2001 </div>
2002 <p class="new-repo-hint">
2003 Just a UI hint — push your own commits to fill the repo.
2004 </p>
2005 </div>
44f1a02Claude2006 <div class="new-repo-row">
2007 <label class="new-repo-label" for="data_region">
2008 Data region
2009 </label>
2010 <select
2011 id="data_region"
2012 name="data_region"
2013 class="new-repo-input"
2014 style="cursor: pointer;"
2015 >
2016 <option value="us" selected>US (default)</option>
2017 <option value="eu">EU (Frankfurt)</option>
2018 </select>
2019 <p class="new-repo-hint">
2020 EU data residency requires a{" "}
2021 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2022 Pro plan or higher
2023 </a>
2024 . Repositories cannot be moved between regions after creation.
2025 </p>
2026 </div>
efb11c5Claude2027 <div class="new-repo-callout">
2028 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2029 <p class="new-repo-callout-body">
2030 Every push is gate-checked and reviewed by Claude automatically.
2031 Label an issue <code>ai-build</code> and Gluecron will open the PR
2032 for you.
2033 </p>
2034 </div>
2035 <div class="new-repo-actions">
398a10cClaude2036 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2037 Create repository
2038 </button>
2039 <a href="/dashboard" class="btn new-repo-cancel">
2040 Cancel
2041 </a>
06d5ffeClaude2042 </div>
2043 </form>
2044 </div>
2045 </Layout>
2046 );
2047});
2048
2049web.post("/new", requireAuth, async (c) => {
2050 const user = c.get("user")!;
2051 const body = await c.req.parseBody();
2052 const name = String(body.name || "").trim();
2053 const description = String(body.description || "").trim();
2054 const isPrivate = body.visibility === "private";
44f1a02Claude2055 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2056
2057 if (!name) {
2058 return c.redirect("/new?error=Repository+name+is+required");
2059 }
2060
c63b860Claude2061 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2062 // outage never blocks repo creation.
2063 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2064 const gate = await checkRepoCreateAllowed(user.id);
2065 if (!gate.ok) {
2066 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2067 }
2068
06d5ffeClaude2069 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2070 return c.redirect("/new?error=Invalid+repository+name");
2071 }
2072
2073 if (await repoExists(user.username, name)) {
2074 return c.redirect("/new?error=Repository+already+exists");
2075 }
2076
2077 const diskPath = await initBareRepo(user.username, name);
2078
3ef4c9dClaude2079 const [newRepo] = await db
2080 .insert(repositories)
2081 .values({
2082 name,
2083 ownerId: user.id,
2084 description: description || null,
2085 isPrivate,
2086 diskPath,
44f1a02Claude2087 dataRegion,
3ef4c9dClaude2088 })
2089 .returning();
2090
2091 if (newRepo) {
2092 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2093 await bootstrapRepository({
2094 repositoryId: newRepo.id,
2095 ownerUserId: user.id,
2096 defaultBranch: "main",
2097 });
2098 }
06d5ffeClaude2099
2100 return c.redirect(`/${user.username}/${name}`);
2101});
2102
2103// User profile
fc1817aClaude2104web.get("/:owner", async (c) => {
06d5ffeClaude2105 const { owner: ownerName } = c.req.param();
2106 const user = c.get("user");
2107
2108 // Avoid clashing with fixed routes
2109 if (
2110 ["login", "register", "logout", "new", "settings", "api"].includes(
2111 ownerName
2112 )
2113 ) {
2114 return c.notFound();
2115 }
2116
2117 let ownerUser;
2118 try {
2119 const [found] = await db
2120 .select()
2121 .from(users)
2122 .where(eq(users.username, ownerName))
2123 .limit(1);
2124 ownerUser = found;
2125 } catch {
2126 // DB not available — check if repos exist on disk
2127 ownerUser = null;
2128 }
2129
2130 // Even without DB, show repos if they exist on disk
2131 let repos: any[] = [];
2132 if (ownerUser) {
2133 const allRepos = await db
2134 .select()
2135 .from(repositories)
2136 .where(eq(repositories.ownerId, ownerUser.id))
2137 .orderBy(desc(repositories.updatedAt));
2138
2139 // Show public repos to everyone, private only to owner
2140 repos =
2141 user?.id === ownerUser.id
2142 ? allRepos
2143 : allRepos.filter((r) => !r.isPrivate);
2144 }
2145
7aa8b99Claude2146 // Block J4 — follow counts + viewer's follow state
2147 let followState = {
2148 followers: 0,
2149 following: 0,
2150 viewerFollows: false,
2151 };
2152 if (ownerUser) {
2153 try {
2154 const { followCounts, isFollowing } = await import("../lib/follows");
2155 const counts = await followCounts(ownerUser.id);
2156 followState.followers = counts.followers;
2157 followState.following = counts.following;
2158 if (user && user.id !== ownerUser.id) {
2159 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2160 }
2161 } catch {
2162 // DB hiccup — fall back to zeros.
2163 }
2164 }
2165 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2166
d412586Claude2167 // Block J5 — profile README. Render owner/owner repo's README on the
2168 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2169 // back to "<user>/.github" for org-style profile repos.
2170 let profileReadmeHtml: string | null = null;
2171 try {
2172 const candidates = [ownerName, ".github"];
2173 for (const rname of candidates) {
2174 if (await repoExists(ownerName, rname)) {
2175 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2176 const md = await getReadme(ownerName, rname, ref);
2177 if (md) {
2178 profileReadmeHtml = renderMarkdown(md);
2179 break;
2180 }
2181 }
2182 }
2183 } catch {
2184 profileReadmeHtml = null;
2185 }
2186
efb11c5Claude2187 const displayName = ownerUser?.displayName || ownerName;
2188 const memberSince = ownerUser?.createdAt
2189 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2190 : null;
2191
fc1817aClaude2192 return c.html(
06d5ffeClaude2193 <Layout title={ownerName} user={user}>
efb11c5Claude2194 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2195 <div class="profile-hero">
2196 <div class="profile-hero-orb-wrap" aria-hidden="true">
2197 <div class="profile-hero-orb" />
06d5ffeClaude2198 </div>
efb11c5Claude2199 <div class="profile-hero-inner">
2200 <div class="profile-hero-avatar" aria-hidden="true">
2201 {displayName[0].toUpperCase()}
2202 </div>
2203 <div class="profile-hero-text">
2204 <div class="profile-eyebrow">
2205 <strong>Developer</strong>
2206 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2207 <>
2208 {" "}· Joined{" "}
2209 {memberSince.toLocaleDateString("en-US", {
2210 month: "short",
2211 year: "numeric",
2212 })}
2213 </>
2214 )}
2215 </div>
2216 <h1 class="profile-name">
2217 <span class="gradient-text">{displayName}</span>
2218 </h1>
2219 <div class="profile-handle">@{ownerName}</div>
2220 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2221 <div class="profile-meta">
2222 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2223 <strong>{followState.followers}</strong> follower
2224 {followState.followers === 1 ? "" : "s"}
2225 </a>
2226 <a href={`/${ownerName}/following`} class="profile-meta-link">
2227 <strong>{followState.following}</strong> following
2228 </a>
2229 <a href={`/${ownerName}`} class="profile-meta-link">
2230 <strong>{repos.length}</strong> repo
2231 {repos.length === 1 ? "" : "s"}
2232 </a>
2233 {canFollow && (
2234 <form
2235 method="post"
2236 action={`/${ownerName}/${
2237 followState.viewerFollows ? "unfollow" : "follow"
2238 }`}
2239 class="profile-follow-form"
7aa8b99Claude2240 >
efb11c5Claude2241 <button
2242 type="submit"
2243 class={`btn ${
2244 followState.viewerFollows ? "" : "btn-primary"
2245 } btn-sm`}
2246 >
2247 {followState.viewerFollows ? "Unfollow" : "Follow"}
2248 </button>
2249 </form>
2250 )}
2251 </div>
7aa8b99Claude2252 </div>
06d5ffeClaude2253 </div>
2254 </div>
d412586Claude2255 {profileReadmeHtml && (
efb11c5Claude2256 <div class="profile-readme">
2257 <div class="profile-readme-head">
2258 <span class="profile-readme-icon">{"☰"}</span>
2259 <span>{ownerName}/{ownerName} README.md</span>
2260 </div>
2261 <div
2262 class="markdown-body profile-readme-body"
2263 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2264 />
2265 </div>
d412586Claude2266 )}
efb11c5Claude2267 <div class="profile-section-head">
2268 <h2 class="profile-section-title">Repositories</h2>
2269 <span class="profile-section-count">{repos.length}</span>
2270 </div>
06d5ffeClaude2271 {repos.length === 0 ? (
efb11c5Claude2272 <div class="profile-empty">
2273 <p class="profile-empty-text">
2274 No repositories yet
2275 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2276 </p>
2277 {user?.id === ownerUser?.id && (
2278 <a href="/new" class="btn btn-primary btn-sm">
2279 + Create your first
2280 </a>
2281 )}
2282 </div>
06d5ffeClaude2283 ) : (
2284 <div class="card-grid">
2285 {repos.map((repo) => (
2286 <RepoCard repo={repo} ownerName={ownerName} />
2287 ))}
2288 </div>
2289 )}
fc1817aClaude2290 </Layout>
2291 );
2292});
2293
06d5ffeClaude2294// Star/unstar a repo
2295web.post("/:owner/:repo/star", requireAuth, async (c) => {
2296 const { owner: ownerName, repo: repoName } = c.req.param();
2297 const user = c.get("user")!;
2298
2299 try {
2300 const [ownerUser] = await db
2301 .select()
2302 .from(users)
2303 .where(eq(users.username, ownerName))
2304 .limit(1);
2305 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2306
2307 const [repo] = await db
2308 .select()
2309 .from(repositories)
2310 .where(
2311 and(
2312 eq(repositories.ownerId, ownerUser.id),
2313 eq(repositories.name, repoName)
2314 )
2315 )
2316 .limit(1);
2317 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2318
2319 // Toggle star
2320 const [existing] = await db
2321 .select()
2322 .from(stars)
2323 .where(
2324 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2325 )
2326 .limit(1);
2327
2328 if (existing) {
2329 await db.delete(stars).where(eq(stars.id, existing.id));
2330 await db
2331 .update(repositories)
2332 .set({ starCount: Math.max(0, repo.starCount - 1) })
2333 .where(eq(repositories.id, repo.id));
2334 } else {
2335 await db.insert(stars).values({
2336 userId: user.id,
2337 repositoryId: repo.id,
2338 });
2339 await db
2340 .update(repositories)
2341 .set({ starCount: repo.starCount + 1 })
2342 .where(eq(repositories.id, repo.id));
2343 }
2344 } catch {
2345 // DB error — ignore
2346 }
2347
2348 return c.redirect(`/${ownerName}/${repoName}`);
2349});
2350
9c5223fClaude2351// ---------------------------------------------------------------------------
2352// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2353// ---------------------------------------------------------------------------
2354const obCss = `
2355 .ob-card {
2356 position: relative;
2357 margin: 14px 0 18px;
2358 background: linear-gradient(135deg, rgba(140,109,255,0.08), rgba(54,197,214,0.05));
2359 border: 1px solid rgba(140,109,255,0.30);
2360 border-radius: 14px;
2361 overflow: hidden;
2362 }
2363 .ob-card::before {
2364 content: '';
2365 position: absolute;
2366 top: 0; left: 0; right: 0;
2367 height: 2px;
2368 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2369 pointer-events: none;
2370 }
2371 .ob-header {
2372 padding: 16px 20px 10px;
2373 border-bottom: 1px solid var(--border);
2374 }
2375 .ob-header h3 {
2376 font-size: 16px;
2377 font-weight: 700;
2378 margin: 0 0 4px;
2379 color: var(--text-strong);
2380 }
2381 .ob-header p {
2382 font-size: 13px;
2383 color: var(--text-muted);
2384 margin: 0;
2385 }
2386 .ob-sections {
2387 display: grid;
2388 grid-template-columns: repeat(3, 1fr);
2389 gap: 0;
2390 }
2391 @media (max-width: 760px) {
2392 .ob-sections { grid-template-columns: 1fr; }
2393 }
2394 .ob-section {
2395 padding: 14px 20px;
2396 border-right: 1px solid var(--border);
2397 }
2398 .ob-section:last-child { border-right: none; }
2399 .ob-section h4 {
2400 font-size: 12px;
2401 font-weight: 700;
2402 text-transform: uppercase;
2403 letter-spacing: 0.08em;
2404 color: var(--accent);
2405 margin: 0 0 8px;
2406 }
2407 .ob-preview {
2408 font-family: var(--font-mono);
2409 font-size: 11.5px;
2410 background: var(--bg);
2411 border: 1px solid var(--border);
2412 border-radius: 6px;
2413 padding: 8px 10px;
2414 color: var(--text-muted);
2415 line-height: 1.55;
2416 margin-bottom: 8px;
2417 white-space: pre-wrap;
2418 overflow: hidden;
2419 max-height: 80px;
2420 position: relative;
2421 }
2422 .ob-preview::after {
2423 content: '';
2424 position: absolute;
2425 bottom: 0; left: 0; right: 0;
2426 height: 24px;
2427 background: linear-gradient(transparent, var(--bg));
2428 pointer-events: none;
2429 }
2430 .ob-labels {
2431 display: flex;
2432 flex-wrap: wrap;
2433 gap: 5px;
2434 margin-bottom: 8px;
2435 }
2436 .ob-label-chip {
2437 display: inline-flex;
2438 align-items: center;
2439 padding: 2px 8px;
2440 border-radius: 9999px;
2441 font-size: 11.5px;
2442 font-weight: 600;
2443 line-height: 1.5;
2444 border: 1px solid transparent;
2445 }
2446 .ob-section ul {
2447 margin: 0;
2448 padding: 0 0 0 16px;
2449 font-size: 13px;
2450 color: var(--text-muted);
2451 line-height: 1.7;
2452 }
2453 .ob-section ul li { margin-bottom: 2px; }
2454 .ob-footer {
2455 display: flex;
2456 align-items: center;
2457 justify-content: flex-end;
2458 padding: 10px 20px;
2459 border-top: 1px solid var(--border);
2460 gap: 10px;
2461 }
2462 .ob-dismiss {
2463 appearance: none;
2464 background: transparent;
2465 border: 1px solid var(--border);
2466 color: var(--text-muted);
2467 border-radius: 6px;
2468 padding: 5px 12px;
2469 font-size: 12.5px;
2470 font-family: inherit;
2471 cursor: pointer;
2472 transition: background var(--t-fast), color var(--t-fast);
2473 }
2474 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2475 .btn-sm {
2476 appearance: none;
2477 background: var(--bg-elevated);
2478 border: 1px solid var(--border);
2479 color: var(--text-strong);
2480 border-radius: 6px;
2481 padding: 5px 12px;
2482 font-size: 12.5px;
2483 font-weight: 600;
2484 font-family: inherit;
2485 cursor: pointer;
2486 text-decoration: none;
2487 display: inline-flex;
2488 align-items: center;
2489 transition: background var(--t-fast);
2490 }
2491 .btn-sm:hover { background: var(--bg-hover); }
2492 .btn-sm.btn-primary {
2493 background: var(--accent);
2494 color: #fff;
2495 border-color: var(--accent);
2496 }
2497 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2498`;
2499
2500// Onboarding card component — shown to repo owner until dismissed
2501function RepoOnboardingCard({
2502 owner,
2503 repo,
2504 data,
2505}: {
2506 owner: string;
2507 repo: string;
2508 data: typeof repoOnboardingData.$inferSelect;
2509}) {
2510 const labels = (data.suggestedLabels ?? []) as Array<{
2511 name: string;
2512 color: string;
2513 description: string;
2514 }>;
2515 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2516 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2517
2518 return (
2519 <>
2520 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2521 <div class="ob-card" id="repo-onboarding">
2522 <div class="ob-header">
2523 <h3>Get started with {owner}/{repo}</h3>
2524 <p>
2525 Detected: {data.detectedLanguage ?? "Unknown"}
2526 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2527 Here&rsquo;s what we suggest to hit the ground running.
2528 </p>
2529 </div>
2530 <div class="ob-sections">
2531 <div class="ob-section">
2532 <h4>Suggested README</h4>
2533 <div class="ob-preview">{readmePreview}</div>
2534 <button
2535 class="btn-sm"
2536 type="button"
2537 onclick={`(function(){var t=${JSON.stringify(data.suggestedReadme ?? "")};try{navigator.clipboard.writeText(t).then(function(){var b=document.querySelector('.ob-copy-btn');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy to clipboard';},2000);}});}catch(_){};})()`}
2538 aria-label="Copy suggested README to clipboard"
2539 >
2540 Copy to clipboard
2541 </button>
2542 </div>
2543 <div class="ob-section">
2544 <h4>Suggested labels ({labels.length})</h4>
2545 <div class="ob-labels">
2546 {labels.slice(0, 6).map((l) => (
2547 <span
2548 class="ob-label-chip"
2549 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2550 title={l.description}
2551 >
2552 {l.name}
2553 </span>
2554 ))}
2555 </div>
2556 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2557 <button class="btn-sm btn-primary" type="submit">
2558 Create all labels
2559 </button>
2560 </form>
2561 </div>
2562 <div class="ob-section">
2563 <h4>First steps</h4>
2564 <ul>
2565 {suggestions.map((s) => (
2566 <li>{s}</li>
2567 ))}
2568 </ul>
2569 </div>
2570 </div>
2571 <div class="ob-footer">
2572 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2573 <button class="ob-dismiss" type="submit">
2574 Dismiss
2575 </button>
2576 </form>
2577 </div>
2578 <script
2579 dangerouslySetInnerHTML={{
2580 __html: `
2581 (function(){
2582 var card = document.getElementById('repo-onboarding');
2583 if (!card) return;
2584 document.addEventListener('keydown', function(e){
2585 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2586 card.style.display = 'none';
2587 }
2588 });
2589 })();
2590 `,
2591 }}
2592 />
2593 </div>
2594 </>
2595 );
2596}
2597
2598// ---------------------------------------------------------------------------
2599// Setup routes — create suggested labels + dismiss onboarding
2600// ---------------------------------------------------------------------------
2601
2602// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2603// requireAuth + write access. Idempotent (label UPSERT by name).
2604web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2605 const { owner, repo } = c.req.param();
2606 const user = c.get("user")!;
2607
2608 // Resolve repo + verify write access
2609 let repoRow: { id: string; ownerId: string } | null = null;
2610 try {
2611 const [ownerUser] = await db
2612 .select({ id: users.id })
2613 .from(users)
2614 .where(eq(users.username, owner))
2615 .limit(1);
2616 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2617 const [r] = await db
2618 .select({ id: repositories.id, ownerId: repositories.ownerId })
2619 .from(repositories)
2620 .where(
2621 and(
2622 eq(repositories.ownerId, ownerUser.id),
2623 eq(repositories.name, repo)
2624 )
2625 )
2626 .limit(1);
2627 repoRow = r ?? null;
2628 } catch {
2629 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2630 }
2631 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2632 if (repoRow.ownerId !== user.id) {
2633 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2634 }
2635
2636 // Fetch the onboarding data
2637 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2638 try {
2639 const [r] = await db
2640 .select()
2641 .from(repoOnboardingData)
2642 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2643 .limit(1);
2644 obRow = r ?? null;
2645 } catch {
2646 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2647 }
2648 if (!obRow) {
2649 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2650 }
2651
2652 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2653 name: string;
2654 color: string;
2655 description: string;
2656 }>;
2657
2658 // Create labels — import the labels table which was already imported
2659 let created = 0;
2660 for (const l of suggestedLabels) {
2661 try {
2662 await db
2663 .insert(labels)
2664 .values({
2665 repositoryId: repoRow.id,
2666 name: l.name,
2667 color: l.color,
2668 description: l.description ?? "",
2669 })
2670 .onConflictDoNothing();
2671 created++;
2672 } catch {
2673 /* skip duplicates */
2674 }
2675 }
2676
2677 // Mark onboarding dismissed
2678 try {
2679 await db
2680 .update(repositories)
2681 .set({ onboardingShown: true })
2682 .where(eq(repositories.id, repoRow.id));
2683 } catch {
2684 /* ignore */
2685 }
2686
2687 return c.redirect(
2688 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2689 );
2690});
2691
2692// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2693web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2694 const { owner, repo } = c.req.param();
2695 const user = c.get("user")!;
2696
2697 try {
2698 const [ownerUser] = await db
2699 .select({ id: users.id })
2700 .from(users)
2701 .where(eq(users.username, owner))
2702 .limit(1);
2703 if (ownerUser) {
2704 const [r] = await db
2705 .select({ id: repositories.id, ownerId: repositories.ownerId })
2706 .from(repositories)
2707 .where(
2708 and(
2709 eq(repositories.ownerId, ownerUser.id),
2710 eq(repositories.name, repo)
2711 )
2712 )
2713 .limit(1);
2714 if (r && r.ownerId === user.id) {
2715 await db
2716 .update(repositories)
2717 .set({ onboardingShown: true })
2718 .where(eq(repositories.id, r.id));
2719 }
2720 }
2721 } catch {
2722 /* swallow — dismiss should never fail visibly */
2723 }
2724
2725 return c.redirect(`/${owner}/${repo}`);
2726});
2727
fc1817aClaude2728// Repository overview — file tree at HEAD
2729web.get("/:owner/:repo", async (c) => {
2730 const { owner, repo } = c.req.param();
06d5ffeClaude2731 const user = c.get("user");
fc1817aClaude2732
f1dc7c7Claude2733 // ── Loading skeleton (flag-gated) ──
2734 // Renders an SSR'd shell with file-tree + README placeholders when
2735 // `?skeleton=1` is set. Lets the user see the page structure before
2736 // git ops finish. Behind a flag for now so we never flash before the
2737 // real content lands.
2738 if (c.req.query("skeleton") === "1") {
2739 return c.html(
2740 <Layout title={`${owner}/${repo}`} user={user}>
2741 <style
2742 dangerouslySetInnerHTML={{
2743 __html: `
2744 .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; }
2745 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2746 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2747 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2748 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2749 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2750 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2751 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2752 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2753 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2754 .repo-skel-readme { height: 320px; border-radius: 12px; }
2755 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2756 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2757 `,
2758 }}
2759 />
2760 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2761 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2762 <div class="repo-skel-grid" aria-hidden="true">
2763 <div>
2764 <div class="repo-skel repo-skel-branch" />
2765 <div class="repo-skel-tree">
2766 {Array.from({ length: 8 }).map(() => (
2767 <div class="repo-skel repo-skel-tree-row" />
2768 ))}
2769 </div>
2770 <div class="repo-skel repo-skel-readme" />
2771 </div>
2772 <aside class="repo-skel-side">
2773 <div class="repo-skel repo-skel-side-card" />
2774 <div class="repo-skel repo-skel-side-card" />
2775 </aside>
2776 </div>
2777 <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">
2778 Loading {owner}/{repo}…
2779 </span>
2780 </Layout>
2781 );
2782 }
2783
8f50ed0Claude2784 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2785 trackByName(owner, repo, "view", {
2786 userId: user?.id || null,
2787 path: `/${owner}/${repo}`,
2788 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2789 userAgent: c.req.header("user-agent") || null,
2790 referer: c.req.header("referer") || null,
a28cedeClaude2791 }).catch((err) => {
2792 console.warn(
2793 `[web] view tracking failed for ${owner}/${repo}:`,
2794 err instanceof Error ? err.message : err
2795 );
2796 });
8f50ed0Claude2797
fc1817aClaude2798 if (!(await repoExists(owner, repo))) {
2799 return c.html(
06d5ffeClaude2800 <Layout title="Not Found" user={user}>
fc1817aClaude2801 <div class="empty-state">
2802 <h2>Repository not found</h2>
2803 <p>
2804 {owner}/{repo} does not exist.
2805 </p>
2806 </div>
2807 </Layout>,
2808 404
2809 );
2810 }
2811
05b973eClaude2812 // Parallelize all independent operations
2813 const [defaultBranch, branches] = await Promise.all([
2814 getDefaultBranch(owner, repo).then((b) => b || "main"),
2815 listBranches(owner, repo),
2816 ]);
2817 const [tree, starInfo] = await Promise.all([
2818 getTree(owner, repo, defaultBranch),
2819 // Star info fetched in parallel with tree
2820 (async () => {
2821 try {
2822 const [ownerUser] = await db
2823 .select()
2824 .from(users)
2825 .where(eq(users.username, owner))
2826 .limit(1);
71cd5ecClaude2827 if (!ownerUser)
2828 return {
2829 starCount: 0,
2830 starred: false,
2831 archived: false,
2832 isTemplate: false,
544d842Claude2833 forkCount: 0,
2834 description: null as string | null,
2835 pushedAt: null as Date | null,
2836 createdAt: null as Date | null,
cb5a796Claude2837 repoId: null as string | null,
2838 repoOwnerId: null as string | null,
71cd5ecClaude2839 };
05b973eClaude2840 const [repoRow] = await db
2841 .select()
2842 .from(repositories)
2843 .where(
2844 and(
2845 eq(repositories.ownerId, ownerUser.id),
2846 eq(repositories.name, repo)
2847 )
06d5ffeClaude2848 )
05b973eClaude2849 .limit(1);
71cd5ecClaude2850 if (!repoRow)
2851 return {
2852 starCount: 0,
2853 starred: false,
2854 archived: false,
2855 isTemplate: false,
544d842Claude2856 forkCount: 0,
2857 description: null as string | null,
2858 pushedAt: null as Date | null,
2859 createdAt: null as Date | null,
cb5a796Claude2860 repoId: null as string | null,
2861 repoOwnerId: null as string | null,
71cd5ecClaude2862 };
05b973eClaude2863 let starred = false;
06d5ffeClaude2864 if (user) {
2865 const [star] = await db
2866 .select()
2867 .from(stars)
2868 .where(
2869 and(
2870 eq(stars.userId, user.id),
2871 eq(stars.repositoryId, repoRow.id)
2872 )
2873 )
2874 .limit(1);
2875 starred = !!star;
2876 }
71cd5ecClaude2877 return {
2878 starCount: repoRow.starCount,
2879 starred,
2880 archived: repoRow.isArchived,
2881 isTemplate: repoRow.isTemplate,
544d842Claude2882 forkCount: repoRow.forkCount,
2883 description: repoRow.description as string | null,
2884 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2885 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2886 repoId: repoRow.id as string,
2887 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2888 };
05b973eClaude2889 } catch {
71cd5ecClaude2890 return {
2891 starCount: 0,
2892 starred: false,
2893 archived: false,
2894 isTemplate: false,
544d842Claude2895 forkCount: 0,
2896 description: null as string | null,
2897 pushedAt: null as Date | null,
2898 createdAt: null as Date | null,
cb5a796Claude2899 repoId: null as string | null,
2900 repoOwnerId: null as string | null,
71cd5ecClaude2901 };
06d5ffeClaude2902 }
05b973eClaude2903 })(),
2904 ]);
544d842Claude2905 const {
2906 starCount,
2907 starred,
2908 archived,
2909 isTemplate,
2910 forkCount,
2911 description,
2912 pushedAt,
2913 createdAt,
cb5a796Claude2914 repoId,
2915 repoOwnerId,
544d842Claude2916 } = starInfo;
2917
91a0204Claude2918 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2919 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2920 // and the badge simply doesn't render.
2921 let healthScore: HealthScore | null = null;
2922 if (repoId) {
2923 try {
2924 healthScore = await computeHealthScore(repoId);
2925 } catch {
2926 // swallow — badge is optional
2927 }
2928 }
2929
cb5a796Claude2930 // Pending-comments banner data (lazy + best-effort). Only the repo
2931 // owner sees the banner, so non-owner views skip the DB hit entirely.
2932 let repoHomePendingCount = 0;
2933 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2934 try {
2935 const { countPendingForRepo } = await import(
2936 "../lib/comment-moderation"
2937 );
2938 repoHomePendingCount = await countPendingForRepo(repoId);
2939 } catch {
2940 /* swallow */
2941 }
2942 }
2943
8c790e0Claude2944 // Push Watch discoverability — fetch the most recent push within 24 h.
2945 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2946 const recentPush: RecentPush | null = repoId
2947 ? await getRecentPush(repoId)
2948 : null;
2949
ebaae0fClaude2950 // AI stats strip — best-effort, fail-open to zero values.
2951 let aiStats: RepoAiStats = {
2952 mergedCount: 0,
2953 reviewCount: 0,
2954 securityAlertCount: 0,
2955 hoursSaved: "0.0",
2956 };
2957 if (repoId) {
2958 try {
2959 aiStats = await getRepoAiStats(repoId);
2960 } catch {
2961 /* swallow — show zero values */
2962 }
2963 }
2964
9c5223fClaude2965 // Onboarding card — shown to repo owner until dismissed. Best-effort;
2966 // if the DB is down the card simply won't appear. Only the owner sees it.
2967 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2968 let showOnboarding = false;
2969 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
2970 try {
2971 // Check if onboarding_shown is still false (not yet dismissed)
2972 const [repoRow2] = await db
2973 .select({ onboardingShown: repositories.onboardingShown })
2974 .from(repositories)
2975 .where(eq(repositories.id, repoId))
2976 .limit(1);
2977 if (repoRow2 && !repoRow2.onboardingShown) {
2978 const [obRow] = await db
2979 .select()
2980 .from(repoOnboardingData)
2981 .where(eq(repoOnboardingData.repositoryId, repoId))
2982 .limit(1);
2983 onboardingRow = obRow ?? null;
2984 showOnboarding = !!onboardingRow;
2985 }
2986 } catch {
2987 /* swallow — onboarding is optional */
2988 }
2989 }
2990
544d842Claude2991 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2992 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2993 const repoHomeCss = `
2994 .repo-home-hero {
2995 position: relative;
2996 margin-bottom: var(--space-5);
2997 padding: var(--space-5) var(--space-6);
2998 background: var(--bg-elevated);
2999 border: 1px solid var(--border);
3000 border-radius: 16px;
3001 overflow: hidden;
3002 }
3003 .repo-home-hero::before {
3004 content: '';
3005 position: absolute;
3006 top: 0; left: 0; right: 0;
3007 height: 2px;
3008 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3009 opacity: 0.7;
3010 pointer-events: none;
3011 }
3012 .repo-home-hero-orb-wrap {
3013 position: absolute;
3014 inset: -25% -10% auto auto;
3015 width: 360px;
3016 height: 360px;
3017 pointer-events: none;
3018 z-index: 0;
3019 }
3020 .repo-home-hero-orb {
3021 position: absolute;
3022 inset: 0;
3023 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
3024 filter: blur(80px);
3025 opacity: 0.7;
3026 animation: repoHomeOrb 14s ease-in-out infinite;
3027 }
3028 @keyframes repoHomeOrb {
3029 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3030 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3031 }
3032 @media (prefers-reduced-motion: reduce) {
3033 .repo-home-hero-orb { animation: none; }
3034 }
3035 .repo-home-hero-inner {
3036 position: relative;
3037 z-index: 1;
3038 }
3039 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3040 .repo-home-eyebrow {
3041 font-size: 12px;
3042 font-family: var(--font-mono);
3043 color: var(--text-muted);
3044 letter-spacing: 0.1em;
3045 text-transform: uppercase;
3046 margin-bottom: var(--space-2);
3047 }
3048 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3049 .repo-home-description {
3050 font-size: 15px;
3051 line-height: 1.55;
3052 color: var(--text);
3053 margin: 0;
3054 max-width: 720px;
3055 }
3056 .repo-home-description-empty {
3057 font-size: 14px;
3058 color: var(--text-muted);
3059 font-style: italic;
3060 margin: 0;
3061 }
3062 .repo-home-stat-row {
3063 display: flex;
3064 flex-wrap: wrap;
3065 gap: var(--space-4);
3066 margin-top: var(--space-3);
3067 font-size: 13px;
3068 color: var(--text-muted);
3069 }
3070 .repo-home-stat {
3071 display: inline-flex;
3072 align-items: center;
3073 gap: 6px;
3074 }
3075 .repo-home-stat strong {
3076 color: var(--text-strong);
3077 font-weight: 600;
3078 font-variant-numeric: tabular-nums;
3079 }
3080 .repo-home-stat .repo-home-stat-icon {
3081 color: var(--text-faint);
3082 font-size: 14px;
3083 line-height: 1;
3084 }
3085 .repo-home-stat a {
3086 color: var(--text-muted);
3087 transition: color var(--t-fast) var(--ease);
3088 }
3089 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3090
3091 /* Two-column layout: file tree + sidebar */
3092 .repo-home-grid {
3093 display: grid;
3094 grid-template-columns: minmax(0, 1fr) 280px;
3095 gap: var(--space-5);
3096 align-items: start;
3097 }
3098 @media (max-width: 960px) {
3099 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3100 }
3101 .repo-home-main { min-width: 0; }
3102
3103 /* Sidebar card */
3104 .repo-home-side {
3105 display: flex;
3106 flex-direction: column;
3107 gap: var(--space-4);
3108 }
3109 .repo-home-side-card {
3110 background: var(--bg-elevated);
3111 border: 1px solid var(--border);
3112 border-radius: 12px;
3113 padding: var(--space-4);
3114 }
3115 .repo-home-side-title {
3116 font-size: 11px;
3117 font-family: var(--font-mono);
3118 letter-spacing: 0.12em;
3119 text-transform: uppercase;
3120 color: var(--text-muted);
3121 margin: 0 0 var(--space-3);
3122 font-weight: 600;
3123 }
3124 .repo-home-side-row {
3125 display: flex;
3126 justify-content: space-between;
3127 align-items: center;
3128 gap: var(--space-2);
3129 font-size: 13px;
3130 padding: 6px 0;
3131 border-top: 1px solid var(--border);
3132 }
3133 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3134 .repo-home-side-key {
3135 color: var(--text-muted);
3136 display: inline-flex;
3137 align-items: center;
3138 gap: 6px;
3139 }
3140 .repo-home-side-val {
3141 color: var(--text-strong);
3142 font-weight: 500;
3143 font-variant-numeric: tabular-nums;
3144 max-width: 60%;
3145 text-align: right;
3146 overflow: hidden;
3147 text-overflow: ellipsis;
3148 white-space: nowrap;
3149 }
3150 .repo-home-side-val a { color: var(--text-strong); }
3151 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3152
3153 /* Clone / Code tabs */
3154 .repo-home-clone {
3155 background: var(--bg-elevated);
3156 border: 1px solid var(--border);
3157 border-radius: 12px;
3158 overflow: hidden;
3159 }
3160 .repo-home-clone-tabs {
3161 display: flex;
3162 gap: 0;
3163 background: var(--bg-secondary);
3164 border-bottom: 1px solid var(--border);
3165 padding: 0 var(--space-2);
3166 }
3167 .repo-home-clone-tab {
3168 appearance: none;
3169 background: transparent;
3170 border: 0;
3171 border-bottom: 2px solid transparent;
3172 padding: 9px 12px;
3173 font-size: 12px;
3174 font-weight: 500;
3175 color: var(--text-muted);
3176 cursor: pointer;
3177 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3178 font-family: inherit;
3179 margin-bottom: -1px;
3180 }
3181 .repo-home-clone-tab:hover { color: var(--text-strong); }
3182 .repo-home-clone-tab[aria-selected="true"] {
3183 color: var(--text-strong);
3184 border-bottom-color: var(--accent);
3185 }
3186 .repo-home-clone-body {
3187 padding: var(--space-3);
3188 display: flex;
3189 align-items: center;
3190 gap: var(--space-2);
3191 }
3192 .repo-home-clone-input {
3193 flex: 1;
3194 min-width: 0;
3195 font-family: var(--font-mono);
3196 font-size: 12px;
3197 background: var(--bg);
3198 border: 1px solid var(--border);
3199 border-radius: 8px;
3200 padding: 8px 10px;
3201 color: var(--text-strong);
3202 overflow: hidden;
3203 text-overflow: ellipsis;
3204 white-space: nowrap;
3205 }
3206 .repo-home-clone-copy {
3207 appearance: none;
3208 background: var(--bg-secondary);
3209 border: 1px solid var(--border);
3210 color: var(--text-strong);
3211 border-radius: 8px;
3212 padding: 8px 12px;
3213 font-size: 12px;
3214 font-weight: 600;
3215 cursor: pointer;
3216 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3217 font-family: inherit;
3218 }
3219 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3220 .repo-home-clone-pane { display: none; }
3221 .repo-home-clone-pane[data-active="true"] { display: flex; }
3222
3223 /* README card */
3224 .repo-home-readme {
3225 margin-top: var(--space-5);
3226 background: var(--bg-elevated);
3227 border: 1px solid var(--border);
3228 border-radius: 12px;
3229 overflow: hidden;
3230 }
3231 .repo-home-readme-head {
3232 display: flex;
3233 align-items: center;
3234 gap: 8px;
3235 padding: 10px 16px;
3236 background: var(--bg-secondary);
3237 border-bottom: 1px solid var(--border);
3238 font-size: 13px;
3239 color: var(--text-muted);
3240 }
3241 .repo-home-readme-head .repo-home-readme-icon {
3242 color: var(--accent);
3243 font-size: 14px;
3244 }
3245 .repo-home-readme-body {
3246 padding: var(--space-5) var(--space-6);
3247 }
3248
3249 /* Empty-state CTA */
3250 .repo-home-empty {
3251 position: relative;
3252 margin-top: var(--space-4);
3253 background: var(--bg-elevated);
3254 border: 1px solid var(--border);
3255 border-radius: 16px;
3256 padding: var(--space-6);
3257 overflow: hidden;
3258 }
3259 .repo-home-empty::before {
3260 content: '';
3261 position: absolute;
3262 top: 0; left: 0; right: 0;
3263 height: 2px;
3264 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3265 opacity: 0.7;
3266 pointer-events: none;
3267 }
3268 .repo-home-empty-eyebrow {
3269 font-size: 12px;
3270 font-family: var(--font-mono);
3271 color: var(--accent);
3272 letter-spacing: 0.12em;
3273 text-transform: uppercase;
3274 margin-bottom: var(--space-2);
3275 font-weight: 600;
3276 }
3277 .repo-home-empty-title {
3278 font-family: var(--font-display);
3279 font-weight: 800;
3280 letter-spacing: -0.025em;
3281 font-size: clamp(22px, 3vw, 30px);
3282 line-height: 1.1;
3283 margin: 0 0 var(--space-2);
3284 color: var(--text-strong);
3285 }
3286 .repo-home-empty-sub {
3287 color: var(--text-muted);
3288 font-size: 14px;
3289 line-height: 1.55;
3290 max-width: 640px;
3291 margin: 0 0 var(--space-4);
3292 }
3293 .repo-home-empty-snippet {
3294 background: var(--bg);
3295 border: 1px solid var(--border);
3296 border-radius: 10px;
3297 padding: var(--space-3) var(--space-4);
3298 font-family: var(--font-mono);
3299 font-size: 12.5px;
3300 line-height: 1.7;
3301 color: var(--text-strong);
3302 overflow-x: auto;
3303 white-space: pre;
3304 margin: 0;
3305 }
3306 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3307 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3308
91a0204Claude3309 /* Health score badge */
3310 .repo-health-badge {
3311 display: inline-flex;
3312 align-items: center;
3313 gap: 5px;
3314 padding: 3px 10px;
3315 border-radius: 999px;
3316 font-size: 11.5px;
3317 font-weight: 700;
3318 font-family: var(--font-mono);
3319 text-decoration: none;
3320 border: 1px solid transparent;
3321 transition: filter 120ms ease, opacity 120ms ease;
3322 vertical-align: middle;
3323 }
3324 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3325 .repo-health-badge-elite {
3326 background: rgba(52,211,153,0.15);
3327 color: #6ee7b7;
3328 border-color: rgba(52,211,153,0.30);
3329 }
3330 .repo-health-badge-strong {
3331 background: rgba(96,165,250,0.15);
3332 color: #93c5fd;
3333 border-color: rgba(96,165,250,0.30);
3334 }
3335 .repo-health-badge-improving {
3336 background: rgba(251,191,36,0.15);
3337 color: #fde68a;
3338 border-color: rgba(251,191,36,0.30);
3339 }
3340 .repo-health-badge-needs-attention {
3341 background: rgba(248,113,113,0.15);
3342 color: #fca5a5;
3343 border-color: rgba(248,113,113,0.30);
3344 }
3345
3346 /* Three-option empty-state panel */
3347 .repo-empty-options {
3348 display: grid;
3349 grid-template-columns: repeat(3, 1fr);
3350 gap: var(--space-3);
3351 margin-top: var(--space-5);
3352 }
3353 @media (max-width: 760px) {
3354 .repo-empty-options { grid-template-columns: 1fr; }
3355 }
3356 .repo-empty-option {
3357 position: relative;
3358 background: var(--bg-elevated);
3359 border: 1px solid var(--border);
3360 border-radius: 14px;
3361 padding: var(--space-4) var(--space-4);
3362 display: flex;
3363 flex-direction: column;
3364 gap: var(--space-2);
3365 overflow: hidden;
3366 transition: border-color 140ms ease, background 140ms ease;
3367 }
3368 .repo-empty-option:hover {
3369 border-color: rgba(140,109,255,0.45);
3370 background: rgba(140,109,255,0.04);
3371 }
3372 .repo-empty-option::before {
3373 content: '';
3374 position: absolute;
3375 top: 0; left: 0; right: 0;
3376 height: 2px;
3377 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3378 opacity: 0.55;
3379 pointer-events: none;
3380 }
3381 .repo-empty-option-label {
3382 font-size: 10px;
3383 font-family: var(--font-mono);
3384 font-weight: 700;
3385 letter-spacing: 0.12em;
3386 text-transform: uppercase;
3387 color: var(--accent);
3388 margin-bottom: 2px;
3389 }
3390 .repo-empty-option-title {
3391 font-family: var(--font-display);
3392 font-weight: 700;
3393 font-size: 16px;
3394 letter-spacing: -0.01em;
3395 color: var(--text-strong);
3396 margin: 0;
3397 }
3398 .repo-empty-option-sub {
3399 font-size: 13px;
3400 color: var(--text-muted);
3401 line-height: 1.5;
3402 margin: 0;
3403 flex: 1;
3404 }
3405 .repo-empty-option-snippet {
3406 background: var(--bg);
3407 border: 1px solid var(--border);
3408 border-radius: 8px;
3409 padding: var(--space-2) var(--space-3);
3410 font-family: var(--font-mono);
3411 font-size: 11.5px;
3412 line-height: 1.75;
3413 color: var(--text-strong);
3414 overflow-x: auto;
3415 white-space: pre;
3416 margin: var(--space-1) 0 0;
3417 }
3418 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3419 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3420 .repo-empty-option-cta {
3421 display: inline-flex;
3422 align-items: center;
3423 gap: 6px;
3424 margin-top: auto;
3425 padding-top: var(--space-2);
3426 font-size: 13px;
3427 font-weight: 600;
3428 color: var(--accent);
3429 text-decoration: none;
3430 transition: color 120ms ease;
3431 }
3432 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3433
544d842Claude3434 @media (max-width: 720px) {
3435 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3436 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3437 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3438 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3439 .repo-home-side-val { max-width: 55%; }
3440 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3441 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3442 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3443 }
ebaae0fClaude3444
3445 /* AI stats strip */
3446 .repo-ai-stats-strip {
3447 display: flex;
3448 align-items: center;
3449 gap: 6px;
3450 margin-top: 12px;
3451 margin-bottom: 4px;
3452 font-size: 12px;
3453 color: var(--text-muted);
3454 flex-wrap: wrap;
3455 }
3456 .repo-ai-stats-strip a {
3457 color: var(--text-muted);
3458 text-decoration: underline;
3459 text-decoration-color: transparent;
3460 transition: color 120ms ease, text-decoration-color 120ms ease;
3461 }
3462 .repo-ai-stats-strip a:hover {
3463 color: var(--accent);
3464 text-decoration-color: currentColor;
3465 }
3466 .repo-ai-stats-sep {
3467 opacity: 0.4;
3468 user-select: none;
3469 }
544d842Claude3470 `;
3471 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3472 // SSH URL — port-aware:
3473 // Standard port 22 → git@host:owner/repo.git
3474 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3475 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3476 try {
3477 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3478 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3479 ? host
3480 : "localhost";
3481 const sshPort = config.sshPort;
3482 if (sshPort === 22) {
3483 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3484 } else {
3485 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3486 }
3487 } catch {
3488 // Fall through to default.
3489 }
3490 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3491 const formatRelative = (date: Date | null): string => {
3492 if (!date) return "never";
3493 const ms = Date.now() - date.getTime();
3494 const s = Math.max(0, Math.round(ms / 1000));
3495 if (s < 60) return "just now";
3496 const m = Math.round(s / 60);
3497 if (m < 60) return `${m} min ago`;
3498 const h = Math.round(m / 60);
3499 if (h < 24) return `${h}h ago`;
3500 const d = Math.round(h / 24);
3501 if (d < 30) return `${d}d ago`;
3502 const mo = Math.round(d / 30);
3503 if (mo < 12) return `${mo}mo ago`;
3504 const y = Math.round(d / 365);
3505 return `${y}y ago`;
3506 };
06d5ffeClaude3507
fc1817aClaude3508 if (tree.length === 0) {
5acce80Claude3509 const repoOgDesc = description
3510 ? `${owner}/${repo} on Gluecron — ${description}`
3511 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3512 return c.html(
5acce80Claude3513 <Layout
3514 title={`${owner}/${repo}`}
3515 user={user}
3516 description={repoOgDesc}
3517 ogTitle={`${owner}/${repo} — Gluecron`}
3518 ogDescription={repoOgDesc}
3519 twitterCard="summary"
3520 >
544d842Claude3521 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3522 <style dangerouslySetInnerHTML={{ __html: `
3523 .empty-options-grid {
3524 display: grid;
3525 grid-template-columns: repeat(3, 1fr);
3526 gap: var(--space-4);
3527 margin-top: var(--space-4);
3528 }
3529 @media (max-width: 800px) {
3530 .empty-options-grid { grid-template-columns: 1fr; }
3531 }
3532 .empty-option-card {
3533 position: relative;
3534 background: var(--bg-elevated);
3535 border: 1px solid var(--border);
3536 border-radius: 14px;
3537 padding: var(--space-5);
3538 display: flex;
3539 flex-direction: column;
3540 gap: var(--space-3);
3541 overflow: hidden;
3542 transition: border-color 140ms ease, box-shadow 140ms ease;
3543 }
3544 .empty-option-card:hover {
3545 border-color: rgba(140,109,255,0.45);
3546 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3547 }
3548 .empty-option-card::before {
3549 content: '';
3550 position: absolute;
3551 top: 0; left: 0; right: 0;
3552 height: 2px;
3553 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3554 opacity: 0.55;
3555 pointer-events: none;
3556 }
3557 .empty-option-label {
3558 font-size: 10.5px;
3559 font-family: var(--font-mono);
3560 text-transform: uppercase;
3561 letter-spacing: 0.14em;
3562 color: var(--accent);
3563 font-weight: 700;
3564 }
3565 .empty-option-title {
3566 font-family: var(--font-display);
3567 font-weight: 700;
3568 font-size: 17px;
3569 letter-spacing: -0.01em;
3570 color: var(--text-strong);
3571 margin: 0;
3572 line-height: 1.25;
3573 }
3574 .empty-option-body {
3575 font-size: 13px;
3576 color: var(--text-muted);
3577 line-height: 1.55;
3578 margin: 0;
3579 flex: 1;
3580 }
3581 .empty-option-snippet {
3582 background: var(--bg);
3583 border: 1px solid var(--border);
3584 border-radius: 8px;
3585 padding: var(--space-3) var(--space-4);
3586 font-family: var(--font-mono);
3587 font-size: 11.5px;
3588 line-height: 1.75;
3589 color: var(--text-strong);
3590 overflow-x: auto;
3591 white-space: pre;
3592 margin: 0;
3593 }
3594 .empty-option-snippet .ec { color: var(--text-faint); }
3595 .empty-option-snippet .em { color: var(--accent); }
3596 .empty-option-cta {
3597 display: inline-flex;
3598 align-items: center;
3599 gap: 6px;
3600 padding: 8px 16px;
3601 font-size: 13px;
3602 font-weight: 600;
3603 color: var(--text-strong);
3604 background: var(--bg-secondary);
3605 border: 1px solid var(--border);
3606 border-radius: 9999px;
3607 text-decoration: none;
3608 align-self: flex-start;
3609 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3610 }
3611 .empty-option-cta:hover {
3612 border-color: rgba(140,109,255,0.55);
3613 background: rgba(140,109,255,0.08);
3614 color: var(--accent);
3615 text-decoration: none;
3616 }
3617 .empty-option-cta-accent {
3618 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3619 border-color: transparent;
3620 color: #fff;
3621 }
3622 .empty-option-cta-accent:hover {
3623 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3624 border-color: transparent;
3625 color: #fff;
3626 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3627 }
3628 ` }} />
544d842Claude3629 <div class="repo-home-hero">
3630 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3631 <div class="repo-home-hero-orb" />
3632 </div>
3633 <div class="repo-home-hero-inner">
3634 <div class="repo-home-eyebrow">
3635 <strong>Repository</strong> · {owner}
3636 </div>
91a0204Claude3637 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3638 <RepoHeader
3639 owner={owner}
3640 repo={repo}
3641 starCount={starCount}
3642 starred={starred}
3643 forkCount={forkCount}
3644 currentUser={user?.username}
3645 archived={archived}
3646 isTemplate={isTemplate}
8c790e0Claude3647 recentPush={recentPush}
91a0204Claude3648 />
3649 {healthScore && (() => {
3650 const gradeLabel: Record<string, string> = {
3651 elite: "Elite", strong: "Strong",
3652 improving: "Improving", "needs-attention": "Needs Attention",
3653 };
3654 return (
3655 <a
3656 href={`/${owner}/${repo}/insights/health`}
3657 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3658 title={`Health score: ${healthScore!.total}/100`}
3659 >
3660 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3661 </a>
3662 );
3663 })()}
3664 </div>
544d842Claude3665 {description ? (
3666 <p class="repo-home-description">{description}</p>
3667 ) : (
3668 <p class="repo-home-description-empty">
3669 No description yet — push a README to tell the world what this
3670 ships.
3671 </p>
3672 )}
3673 </div>
3674 </div>
fc1817aClaude3675 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3676 <div style="margin-top:var(--space-4)">
3677 <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)">
3678 Getting started
3679 </div>
544d842Claude3680 <h2 class="repo-home-empty-title">
91a0204Claude3681 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3682 </h2>
3683 <p class="repo-home-empty-sub">
91a0204Claude3684 Choose how you want to kick things off. Every push wires up gate
3685 checks and AI review automatically.
544d842Claude3686 </p>
91a0204Claude3687 <div class="empty-options-grid">
3688 <div class="empty-option-card">
3689 <div class="empty-option-label">Option A</div>
3690 <h3 class="empty-option-title">Push your first commit</h3>
3691 <p class="empty-option-body">
3692 Wire up an existing project in seconds. Run these commands from
3693 your project directory.
3694 </p>
3695 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3696<span class="em">git remote add</span> origin {cloneHttpsUrl}
3697<span class="em">git branch</span> -M main
3698<span class="em">git push</span> -u origin main</pre>
3699 </div>
3700 <div class="empty-option-card">
3701 <div class="empty-option-label">Option B</div>
3702 <h3 class="empty-option-title">Import from GitHub</h3>
3703 <p class="empty-option-body">
3704 Mirror an existing GitHub repository here in one click. Gluecron
3705 syncs the full history and branches.
3706 </p>
3707 <a href="/import" class="empty-option-cta">
3708 Import repository
3709 </a>
3710 </div>
3711 <div class="empty-option-card">
3712 <div class="empty-option-label">Option C</div>
3713 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3714 <p class="empty-option-body">
3715 Let AI write your first feature. Describe what you want to build
3716 and Gluecron opens a pull request with the code.
3717 </p>
3718 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3719 Let AI write your first feature
3720 </a>
3721 </div>
3722 </div>
fc1817aClaude3723 </div>
3724 </Layout>
3725 );
3726 }
3727
3728 const readme = await getReadme(owner, repo, defaultBranch);
3729
544d842Claude3730 // Sidebar facts — derived from data we already have.
3731 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3732 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3733
5acce80Claude3734 const repoOgDesc = description
3735 ? `${owner}/${repo} on Gluecron — ${description}`
3736 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3737
fc1817aClaude3738 return c.html(
5acce80Claude3739 <Layout
3740 title={`${owner}/${repo}`}
3741 user={user}
3742 description={repoOgDesc}
3743 ogTitle={`${owner}/${repo} — Gluecron`}
3744 ogDescription={repoOgDesc}
3745 twitterCard="summary"
3746 >
544d842Claude3747 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
9c5223fClaude3748 {/* Repo-context commands for the command palette (Feature 2) */}
3749 <script
3750 id="cmdk-repo-context"
3751 dangerouslySetInnerHTML={{
3752 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3753 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3754 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3755 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3756 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3757 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3758 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3759 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3760 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3761 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3762 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3763 ])};`,
3764 }}
3765 />
544d842Claude3766 <div class="repo-home-hero">
3767 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3768 <div class="repo-home-hero-orb" />
3769 </div>
3770 <div class="repo-home-hero-inner">
3771 <div class="repo-home-eyebrow">
3772 <strong>Repository</strong> · {owner}
3773 </div>
91a0204Claude3774 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3775 <RepoHeader
3776 owner={owner}
3777 repo={repo}
3778 starCount={starCount}
3779 starred={starred}
3780 forkCount={forkCount}
3781 currentUser={user?.username}
3782 archived={archived}
3783 isTemplate={isTemplate}
8c790e0Claude3784 recentPush={recentPush}
91a0204Claude3785 />
3786 {healthScore && (() => {
3787 const gradeLabel: Record<string, string> = {
3788 elite: "Elite", strong: "Strong",
3789 improving: "Improving", "needs-attention": "Needs Attention",
3790 };
3791 return (
3792 <a
3793 href={`/${owner}/${repo}/insights/health`}
3794 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3795 title={`Health score: ${healthScore!.total}/100`}
3796 >
3797 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3798 </a>
3799 );
3800 })()}
3801 </div>
544d842Claude3802 {description ? (
3803 <p class="repo-home-description">{description}</p>
3804 ) : (
3805 <p class="repo-home-description-empty">
3806 No description yet.
3807 </p>
3808 )}
3809 <div class="repo-home-stat-row" aria-label="Repository stats">
3810 <span class="repo-home-stat" title="Default branch">
3811 <span class="repo-home-stat-icon">{"⎇"}</span>
3812 <strong>{defaultBranch}</strong>
3813 </span>
3814 <a
3815 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3816 class="repo-home-stat"
3817 title="Browse all branches"
3818 >
3819 <span class="repo-home-stat-icon">{"⊢"}</span>
3820 <strong>{branches.length}</strong>{" "}
3821 branch{branches.length === 1 ? "" : "es"}
3822 </a>
3823 <span class="repo-home-stat" title="Top-level entries">
3824 <span class="repo-home-stat-icon">{"■"}</span>
3825 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3826 {dirCount > 0 && (
3827 <>
3828 {" · "}
3829 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3830 </>
3831 )}
3832 </span>
3833 {pushedAt && (
3834 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3835 <span class="repo-home-stat-icon">{"↻"}</span>
3836 Updated <strong>{formatRelative(pushedAt)}</strong>
3837 </span>
3838 )}
3839 </div>
3840 </div>
3841 </div>
71cd5ecClaude3842 {isTemplate && user && user.username !== owner && (
3843 <div
3844 class="panel"
dc26881CC LABS App3845 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3846 >
3847 <div style="font-size:13px">
3848 <strong>Template repository.</strong> Create a new repository from
3849 this template's files.
3850 </div>
3851 <form
001af43Claude3852 method="post"
71cd5ecClaude3853 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3854 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3855 >
3856 <input
3857 type="text"
3858 name="name"
3859 placeholder="new-repo-name"
3860 required
2c3ba6ecopilot-swe-agent[bot]3861 aria-label="New repository name"
71cd5ecClaude3862 style="width:200px"
3863 />
3864 <button type="submit" class="btn btn-primary">
3865 Use this template
3866 </button>
3867 </form>
3868 </div>
3869 )}
fc1817aClaude3870 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3871 <RepoHomePendingBanner
3872 owner={owner}
3873 repo={repo}
3874 count={repoHomePendingCount}
3875 />
9c5223fClaude3876 {showOnboarding && onboardingRow && (
3877 <RepoOnboardingCard
3878 owner={owner}
3879 repo={repo}
3880 data={onboardingRow}
3881 />
3882 )}
c6018a5Claude3883 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3884 row sits just below the nav as a slim CTA strip. Scoped under
3885 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3886 <style
3887 dangerouslySetInnerHTML={{
3888 __html: `
3889 .repo-ai-cta-row {
3890 display: flex;
3891 flex-wrap: wrap;
3892 gap: 8px;
3893 margin: 12px 0 18px;
3894 padding: 10px 14px;
3895 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3896 border: 1px solid var(--border);
3897 border-radius: 10px;
3898 position: relative;
3899 overflow: hidden;
3900 }
3901 .repo-ai-cta-row::before {
3902 content: '';
3903 position: absolute;
3904 top: 0; left: 0; right: 0;
3905 height: 1px;
3906 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3907 opacity: 0.7;
3908 pointer-events: none;
3909 }
3910 .repo-ai-cta-label {
3911 font-size: 11px;
3912 font-weight: 600;
3913 letter-spacing: 0.06em;
3914 text-transform: uppercase;
3915 color: var(--accent);
3916 align-self: center;
3917 padding-right: 8px;
3918 border-right: 1px solid var(--border);
3919 margin-right: 4px;
3920 }
3921 .repo-ai-cta {
3922 display: inline-flex;
3923 align-items: center;
3924 gap: 6px;
3925 padding: 5px 10px;
3926 font-size: 12.5px;
3927 font-weight: 500;
3928 color: var(--text);
3929 background: var(--bg);
3930 border: 1px solid var(--border);
3931 border-radius: 7px;
3932 text-decoration: none;
3933 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3934 }
3935 .repo-ai-cta:hover {
3936 border-color: rgba(140,109,255,0.45);
3937 color: var(--text-strong);
3938 background: rgba(140,109,255,0.06);
3939 text-decoration: none;
3940 }
3941 .repo-ai-cta-icon {
3942 opacity: 0.75;
3943 font-size: 12px;
3944 }
3945 @media (max-width: 640px) {
3946 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3947 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3948 }
3949 `,
3950 }}
3951 />
3952 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3953 <span class="repo-ai-cta-label">AI surfaces</span>
3954 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3955 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3956 Chat
3957 </a>
3958 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3959 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3960 Previews
3961 </a>
3962 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3963 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3964 Migrations
3965 </a>
3966 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3967 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3968 Semantic search
3969 </a>
3970 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3971 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3972 AI release notes
3973 </a>
3646bfeClaude3974 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3975 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3976 Dev environment
3977 </a>
c6018a5Claude3978 </nav>
544d842Claude3979 <div class="repo-home-grid">
3980 <div class="repo-home-main">
3981 <BranchSwitcher
3982 owner={owner}
3983 repo={repo}
3984 currentRef={defaultBranch}
3985 branches={branches}
3986 pathType="tree"
3987 />
3988 <FileTable
3989 entries={tree}
3990 owner={owner}
3991 repo={repo}
3992 ref={defaultBranch}
3993 path=""
3994 />
ebaae0fClaude3995 {/* AI stats strip — one-line summary of AI value for this repo */}
3996 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3997 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3998 <span>{"⚡"}</span>
3999 {aiStats.mergedCount > 0 ? (
4000 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4001 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4002 </a>
4003 ) : (
4004 <span>0 AI merges this week</span>
4005 )}
4006 <span class="repo-ai-stats-sep">{"·"}</span>
4007 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4008 <span class="repo-ai-stats-sep">{"·"}</span>
4009 {aiStats.securityAlertCount > 0 ? (
4010 <a href={`/${owner}/${repo}/issues?label=security`}>
4011 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4012 </a>
4013 ) : (
4014 <span>0 open security alerts</span>
4015 )}
4016 </div>
4017 ) : (
4018 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4019 <span>{"⚡"}</span>
4020 <span>AI is watching — no activity this week</span>
4021 </div>
4022 )}
544d842Claude4023 {readme && (() => {
4024 const readmeHtml = renderMarkdown(readme);
4025 return (
4026 <div class="repo-home-readme">
4027 <div class="repo-home-readme-head">
4028 <span class="repo-home-readme-icon">{"☰"}</span>
4029 <span>README.md</span>
4030 </div>
4031 <style>{markdownCss}</style>
4032 <div class="markdown-body repo-home-readme-body">
4033 {html([readmeHtml] as unknown as TemplateStringsArray)}
4034 </div>
4035 </div>
4036 );
4037 })()}
4038 </div>
4039 <aside class="repo-home-side" aria-label="Repository details">
4040 <div class="repo-home-clone">
4041 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4042 <button
4043 type="button"
4044 class="repo-home-clone-tab"
4045 role="tab"
4046 aria-selected="true"
4047 data-pane="https"
4048 data-repo-home-clone-tab
4049 >
4050 HTTPS
4051 </button>
4052 <button
4053 type="button"
4054 class="repo-home-clone-tab"
4055 role="tab"
4056 aria-selected="false"
4057 data-pane="ssh"
4058 data-repo-home-clone-tab
4059 >
4060 SSH
4061 </button>
4062 <button
4063 type="button"
4064 class="repo-home-clone-tab"
4065 role="tab"
4066 aria-selected="false"
4067 data-pane="cli"
4068 data-repo-home-clone-tab
4069 >
4070 CLI
4071 </button>
4072 </div>
4073 <div
4074 class="repo-home-clone-pane"
4075 data-pane="https"
4076 data-active="true"
4077 role="tabpanel"
4078 >
4079 <div class="repo-home-clone-body">
4080 <input
4081 class="repo-home-clone-input"
4082 type="text"
4083 value={cloneHttpsUrl}
4084 readonly
4085 aria-label="HTTPS clone URL"
4086 data-repo-home-clone-input
4087 />
4088 <button
4089 type="button"
4090 class="repo-home-clone-copy"
4091 data-repo-home-copy={cloneHttpsUrl}
4092 >
4093 Copy
4094 </button>
4095 </div>
4096 </div>
4097 <div
4098 class="repo-home-clone-pane"
4099 data-pane="ssh"
4100 data-active="false"
4101 role="tabpanel"
4102 >
4103 <div class="repo-home-clone-body">
4104 <input
4105 class="repo-home-clone-input"
4106 type="text"
4107 value={cloneSshUrl}
4108 readonly
4109 aria-label="SSH clone URL"
4110 data-repo-home-clone-input
4111 />
4112 <button
4113 type="button"
4114 class="repo-home-clone-copy"
4115 data-repo-home-copy={cloneSshUrl}
4116 >
4117 Copy
4118 </button>
4119 </div>
4120 </div>
4121 <div
4122 class="repo-home-clone-pane"
4123 data-pane="cli"
4124 data-active="false"
4125 role="tabpanel"
4126 >
4127 <div class="repo-home-clone-body">
4128 <input
4129 class="repo-home-clone-input"
4130 type="text"
4131 value={cloneCliCmd}
4132 readonly
4133 aria-label="Gluecron CLI clone command"
4134 data-repo-home-clone-input
4135 />
4136 <button
4137 type="button"
4138 class="repo-home-clone-copy"
4139 data-repo-home-copy={cloneCliCmd}
4140 >
4141 Copy
4142 </button>
4143 </div>
79136bbClaude4144 </div>
fc1817aClaude4145 </div>
544d842Claude4146 <div class="repo-home-side-card">
4147 <h3 class="repo-home-side-title">About</h3>
4148 <div class="repo-home-side-row">
4149 <span class="repo-home-side-key">Default branch</span>
4150 <span class="repo-home-side-val">{defaultBranch}</span>
4151 </div>
4152 <div class="repo-home-side-row">
4153 <span class="repo-home-side-key">Branches</span>
4154 <span class="repo-home-side-val">
4155 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4156 {branches.length}
4157 </a>
4158 </span>
4159 </div>
4160 <div class="repo-home-side-row">
4161 <span class="repo-home-side-key">Stars</span>
4162 <span class="repo-home-side-val">{starCount}</span>
4163 </div>
4164 <div class="repo-home-side-row">
4165 <span class="repo-home-side-key">Forks</span>
4166 <span class="repo-home-side-val">{forkCount}</span>
4167 </div>
4168 {pushedAt && (
4169 <div class="repo-home-side-row">
4170 <span class="repo-home-side-key">Last push</span>
4171 <span class="repo-home-side-val">
4172 {formatRelative(pushedAt)}
4173 </span>
4174 </div>
4175 )}
4176 {createdAt && (
4177 <div class="repo-home-side-row">
4178 <span class="repo-home-side-key">Created</span>
4179 <span class="repo-home-side-val">
4180 {formatRelative(createdAt)}
4181 </span>
4182 </div>
4183 )}
4184 {(archived || isTemplate) && (
4185 <div class="repo-home-side-row">
4186 <span class="repo-home-side-key">State</span>
4187 <span class="repo-home-side-val">
4188 {archived ? "Archived" : "Template"}
4189 </span>
4190 </div>
4191 )}
4192 </div>
f1dc38bClaude4193 {/* Claude AI — quick-access card for authenticated users */}
4194 {user && (
4195 <div class="repo-home-side-card" style="margin-top:12px">
4196 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4197 ✨ Claude AI
4198 </h3>
4199 <a
4200 href={`/${owner}/${repo}/claude`}
4201 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"
4202 >
4203 Claude Code sessions
4204 </a>
4205 <a
4206 href={`/${owner}/${repo}/ask`}
4207 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4208 >
4209 Ask AI about this repo
4210 </a>
4211 </div>
4212 )}
544d842Claude4213 </aside>
4214 </div>
4215 <script
4216 dangerouslySetInnerHTML={{
4217 __html: `
4218 (function(){
4219 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4220 tabs.forEach(function(tab){
4221 tab.addEventListener('click', function(){
4222 var target = tab.getAttribute('data-pane');
4223 tabs.forEach(function(t){
4224 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4225 });
4226 var panes = document.querySelectorAll('.repo-home-clone-pane');
4227 panes.forEach(function(p){
4228 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4229 });
4230 });
4231 });
4232 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4233 copyBtns.forEach(function(btn){
4234 btn.addEventListener('click', function(){
4235 var text = btn.getAttribute('data-repo-home-copy') || '';
4236 var done = function(){
4237 var prev = btn.textContent;
4238 btn.textContent = 'Copied';
4239 setTimeout(function(){ btn.textContent = prev; }, 1200);
4240 };
4241 if (navigator.clipboard && navigator.clipboard.writeText) {
4242 navigator.clipboard.writeText(text).then(done, done);
4243 } else {
4244 var ta = document.createElement('textarea');
4245 ta.value = text;
4246 document.body.appendChild(ta);
4247 ta.select();
4248 try { document.execCommand('copy'); } catch (e) {}
4249 document.body.removeChild(ta);
4250 done();
4251 }
4252 });
4253 });
4254 })();
4255 `,
4256 }}
4257 />
fc1817aClaude4258 </Layout>
4259 );
4260});
4261
4262// Browse tree at ref/path
4263web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4264 const { owner, repo } = c.req.param();
06d5ffeClaude4265 const user = c.get("user");
fc1817aClaude4266 const refAndPath = c.req.param("ref");
4267
4268 const branches = await listBranches(owner, repo);
4269 let ref = "";
4270 let treePath = "";
4271
4272 for (const branch of branches) {
4273 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4274 ref = branch;
4275 treePath = refAndPath.slice(branch.length + 1);
4276 break;
4277 }
4278 }
4279
4280 if (!ref) {
4281 const slashIdx = refAndPath.indexOf("/");
4282 if (slashIdx === -1) {
4283 ref = refAndPath;
4284 } else {
4285 ref = refAndPath.slice(0, slashIdx);
4286 treePath = refAndPath.slice(slashIdx + 1);
4287 }
4288 }
4289
4290 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4291 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4292 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4293
4294 return c.html(
06d5ffeClaude4295 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4296 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4297 <RepoHeader owner={owner} repo={repo} />
4298 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4299 <div class="tree-header">
4300 <div class="tree-header-row">
4301 <BranchSwitcher
4302 owner={owner}
4303 repo={repo}
4304 currentRef={ref}
4305 branches={branches}
4306 pathType="tree"
4307 subPath={treePath}
4308 />
4309 <div class="tree-header-stats">
4310 <span class="tree-stat" title="Entries in this directory">
4311 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4312 {dirCount > 0 && (
4313 <>
4314 {" · "}
4315 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4316 </>
4317 )}
4318 </span>
4319 <a
4320 href={`/${owner}/${repo}/search`}
4321 class="tree-stat tree-stat-link"
4322 title="Search code in this repository"
4323 >
4324 {"⌕"} Search
4325 </a>
4326 </div>
4327 </div>
4328 <div class="tree-breadcrumb-row">
4329 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4330 </div>
4331 </div>
fc1817aClaude4332 <FileTable
4333 entries={tree}
4334 owner={owner}
4335 repo={repo}
4336 ref={ref}
4337 path={treePath}
4338 />
4339 </Layout>
4340 );
4341});
4342
06d5ffeClaude4343// View file blob with syntax highlighting
fc1817aClaude4344web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4345 const { owner, repo } = c.req.param();
06d5ffeClaude4346 const user = c.get("user");
fc1817aClaude4347 const refAndPath = c.req.param("ref");
4348
4349 const branches = await listBranches(owner, repo);
4350 let ref = "";
4351 let filePath = "";
4352
4353 for (const branch of branches) {
4354 if (refAndPath.startsWith(branch + "/")) {
4355 ref = branch;
4356 filePath = refAndPath.slice(branch.length + 1);
4357 break;
4358 }
4359 }
4360
4361 if (!ref) {
4362 const slashIdx = refAndPath.indexOf("/");
4363 if (slashIdx === -1) return c.text("Not found", 404);
4364 ref = refAndPath.slice(0, slashIdx);
4365 filePath = refAndPath.slice(slashIdx + 1);
4366 }
4367
4368 const blob = await getBlob(owner, repo, ref, filePath);
4369 if (!blob) {
4370 return c.html(
06d5ffeClaude4371 <Layout title="Not Found" user={user}>
fc1817aClaude4372 <div class="empty-state">
4373 <h2>File not found</h2>
4374 </div>
4375 </Layout>,
4376 404
4377 );
4378 }
4379
06d5ffeClaude4380 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4381 const lineCount = blob.isBinary
4382 ? 0
4383 : (blob.content.endsWith("\n")
4384 ? blob.content.split("\n").length - 1
4385 : blob.content.split("\n").length);
4386 const formatBytes = (n: number): string => {
4387 if (n < 1024) return `${n} B`;
4388 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4389 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4390 };
fc1817aClaude4391
4392 return c.html(
06d5ffeClaude4393 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4394 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4395 <RepoHeader owner={owner} repo={repo} />
4396 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4397 <div class="blob-toolbar">
4398 <BranchSwitcher
4399 owner={owner}
4400 repo={repo}
4401 currentRef={ref}
4402 branches={branches}
4403 pathType="blob"
4404 subPath={filePath}
4405 />
4406 <div class="blob-breadcrumb">
4407 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4408 </div>
4409 </div>
4410 <div class="blob-view blob-card">
4411 <div class="blob-header blob-header-polished">
4412 <div class="blob-header-meta">
4413 <span class="blob-header-icon" aria-hidden="true">
4414 {"📄"}
4415 </span>
4416 <span class="blob-header-name">{fileName}</span>
4417 <span class="blob-header-size">
4418 {formatBytes(blob.size)}
4419 {!blob.isBinary && (
4420 <>
4421 {" · "}
4422 {lineCount} line{lineCount === 1 ? "" : "s"}
4423 </>
4424 )}
4425 </span>
4426 </div>
4427 <div class="blob-header-actions">
4428 <a
4429 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4430 class="blob-pill"
4431 >
79136bbClaude4432 Raw
4433 </a>
efb11c5Claude4434 <a
4435 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4436 class="blob-pill"
4437 >
79136bbClaude4438 Blame
4439 </a>
efb11c5Claude4440 <a
4441 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4442 class="blob-pill"
4443 >
16b325cClaude4444 History
4445 </a>
0074234Claude4446 {user && (
efb11c5Claude4447 <a
4448 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4449 class="blob-pill blob-pill-accent"
4450 >
0074234Claude4451 Edit
4452 </a>
4453 )}
efb11c5Claude4454 </div>
fc1817aClaude4455 </div>
4456 {blob.isBinary ? (
efb11c5Claude4457 <div class="blob-binary">
fc1817aClaude4458 Binary file not shown.
4459 </div>
06d5ffeClaude4460 ) : (() => {
4461 const { html: highlighted, language } = highlightCode(
4462 blob.content,
4463 fileName
4464 );
4465 if (language) {
4466 return (
4467 <HighlightedCode
4468 highlightedHtml={highlighted}
efb11c5Claude4469 lineCount={lineCount}
06d5ffeClaude4470 />
4471 );
4472 }
4473 const lines = blob.content.split("\n");
4474 if (lines[lines.length - 1] === "") lines.pop();
4475 return <PlainCode lines={lines} />;
4476 })()}
fc1817aClaude4477 </div>
4478 </Layout>
4479 );
4480});
4481
398a10cClaude4482// ─── Branches list ────────────────────────────────────────────────────────
4483// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4484// author + relative time + ahead/behind vs the default branch. No DB. All
4485// data comes from git plumbing; failures degrade gracefully (counts omitted).
4486web.get("/:owner/:repo/branches", async (c) => {
4487 const { owner, repo } = c.req.param();
4488 const user = c.get("user");
4489
4490 if (!(await repoExists(owner, repo))) return c.notFound();
4491
4492 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4493 const branches = await listBranches(owner, repo);
4494 const repoDir = getRepoPath(owner, repo);
4495
4496 type BranchRow = {
4497 name: string;
4498 isDefault: boolean;
4499 sha: string;
4500 subject: string;
4501 author: string;
4502 date: string;
4503 ahead: number;
4504 behind: number;
4505 };
4506
4507 const runGit = async (args: string[]): Promise<string> => {
4508 try {
4509 const proc = Bun.spawn(["git", ...args], {
4510 cwd: repoDir,
4511 stdout: "pipe",
4512 stderr: "pipe",
4513 });
4514 const out = await new Response(proc.stdout).text();
4515 await proc.exited;
4516 return out;
4517 } catch {
4518 return "";
4519 }
4520 };
4521
4522 const meta = await runGit([
4523 "for-each-ref",
4524 "--sort=-committerdate",
4525 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4526 "refs/heads/",
4527 ]);
4528 const metaByName: Record<
4529 string,
4530 { sha: string; subject: string; author: string; date: string }
4531 > = {};
4532 for (const line of meta.split("\n").filter(Boolean)) {
4533 const [name, sha, subject, author, date] = line.split("\0");
4534 metaByName[name] = { sha, subject, author, date };
4535 }
4536
4537 const branchOrder = [...branches].sort((a, b) => {
4538 if (a === defaultBranch) return -1;
4539 if (b === defaultBranch) return 1;
4540 const aDate = metaByName[a]?.date || "";
4541 const bDate = metaByName[b]?.date || "";
4542 return bDate.localeCompare(aDate);
4543 });
4544
4545 const rows: BranchRow[] = [];
4546 for (const name of branchOrder) {
4547 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4548 let ahead = 0;
4549 let behind = 0;
4550 if (name !== defaultBranch && metaByName[defaultBranch]) {
4551 const out = await runGit([
4552 "rev-list",
4553 "--left-right",
4554 "--count",
4555 `${defaultBranch}...${name}`,
4556 ]);
4557 const parts = out.trim().split(/\s+/);
4558 if (parts.length === 2) {
4559 behind = parseInt(parts[0], 10) || 0;
4560 ahead = parseInt(parts[1], 10) || 0;
4561 }
4562 }
4563 rows.push({
4564 name,
4565 isDefault: name === defaultBranch,
4566 sha: m.sha,
4567 subject: m.subject,
4568 author: m.author,
4569 date: m.date,
4570 ahead,
4571 behind,
4572 });
4573 }
4574
4575 const relative = (iso: string): string => {
4576 if (!iso) return "—";
4577 const d = new Date(iso);
4578 if (Number.isNaN(d.getTime())) return "—";
4579 const diff = Date.now() - d.getTime();
4580 const m = Math.floor(diff / 60000);
4581 if (m < 1) return "just now";
4582 if (m < 60) return `${m} min ago`;
4583 const h = Math.floor(m / 60);
4584 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4585 const dd = Math.floor(h / 24);
4586 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4587 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4588 };
4589
4590 const success = c.req.query("success");
4591 const error = c.req.query("error");
4592
4593 return c.html(
4594 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4595 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4596 <RepoHeader owner={owner} repo={repo} />
4597 <RepoNav owner={owner} repo={repo} active="code" />
4598 <div
4599 class="branches-wrap"
eed4684Claude4600 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4601 >
4602 <header class="branches-head" style="margin-bottom:var(--space-5)">
4603 <div
4604 class="branches-eyebrow"
4605 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"
4606 >
4607 <span
4608 class="branches-eyebrow-dot"
4609 aria-hidden="true"
4610 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)"
4611 />
4612 Repository · Branches
4613 </div>
4614 <h1
4615 class="branches-title"
4616 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)"
4617 >
4618 <span class="gradient-text">{rows.length}</span>{" "}
4619 branch{rows.length === 1 ? "" : "es"}
4620 </h1>
4621 <p
4622 class="branches-sub"
4623 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4624 >
4625 All work-in-progress lines for{" "}
4626 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4627 counts are relative to{" "}
4628 <code style="font-size:12.5px">{defaultBranch}</code>.
4629 </p>
4630 </header>
4631
4632 {success && (
4633 <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">
4634 {decodeURIComponent(success)}
4635 </div>
4636 )}
4637 {error && (
4638 <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">
4639 {decodeURIComponent(error)}
4640 </div>
4641 )}
4642
4643 {rows.length === 0 ? (
4644 <div class="commits-empty">
4645 <div class="commits-empty-orb" aria-hidden="true" />
4646 <div class="commits-empty-inner">
4647 <div class="commits-empty-icon" aria-hidden="true">
4648 <svg
4649 width="22"
4650 height="22"
4651 viewBox="0 0 24 24"
4652 fill="none"
4653 stroke="currentColor"
4654 stroke-width="2"
4655 stroke-linecap="round"
4656 stroke-linejoin="round"
4657 >
4658 <line x1="6" y1="3" x2="6" y2="15" />
4659 <circle cx="18" cy="6" r="3" />
4660 <circle cx="6" cy="18" r="3" />
4661 <path d="M18 9a9 9 0 0 1-9 9" />
4662 </svg>
4663 </div>
4664 <h3 class="commits-empty-title">No branches yet</h3>
4665 <p class="commits-empty-sub">
4666 Push your first commit to create the default branch.
4667 </p>
4668 </div>
4669 </div>
4670 ) : (
4671 <div class="branches-list">
4672 {rows.map((r) => (
4673 <div class="branches-row">
4674 <div class="branches-row-icon" aria-hidden="true">
4675 <svg
4676 width="14"
4677 height="14"
4678 viewBox="0 0 24 24"
4679 fill="none"
4680 stroke="currentColor"
4681 stroke-width="2"
4682 stroke-linecap="round"
4683 stroke-linejoin="round"
4684 >
4685 <line x1="6" y1="3" x2="6" y2="15" />
4686 <circle cx="18" cy="6" r="3" />
4687 <circle cx="6" cy="18" r="3" />
4688 <path d="M18 9a9 9 0 0 1-9 9" />
4689 </svg>
4690 </div>
4691 <div class="branches-row-main">
4692 <div class="branches-row-name">
4693 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4694 {r.isDefault && (
4695 <span
4696 class="branches-row-default"
4697 title="Default branch"
4698 >
4699 Default
4700 </span>
4701 )}
4702 </div>
4703 <div class="branches-row-meta">
4704 {r.subject ? (
4705 <>
4706 <strong>{r.author || "—"}</strong>
4707 <span class="sep">·</span>
4708 <span
4709 title={r.date ? new Date(r.date).toISOString() : ""}
4710 >
4711 updated {relative(r.date)}
4712 </span>
4713 {r.sha && (
4714 <>
4715 <span class="sep">·</span>
4716 <a
4717 href={`/${owner}/${repo}/commit/${r.sha}`}
4718 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4719 >
4720 {r.sha.slice(0, 7)}
4721 </a>
4722 </>
4723 )}
4724 </>
4725 ) : (
4726 <span>No commit metadata</span>
4727 )}
4728 </div>
4729 </div>
4730 <div class="branches-row-side">
4731 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4732 <span
4733 class="branches-row-divergence"
4734 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4735 >
4736 <span class="ahead">{r.ahead} ahead</span>
4737 <span style="opacity:0.4">|</span>
4738 <span class="behind">{r.behind} behind</span>
4739 </span>
4740 )}
4741 <div class="branches-row-actions">
4742 <a
4743 href={`/${owner}/${repo}/commits/${r.name}`}
4744 class="branches-btn"
4745 title="View commits on this branch"
4746 >
4747 Commits
4748 </a>
4749 {!r.isDefault &&
4750 user &&
4751 user.username === owner && (
4752 <form
4753 method="post"
4754 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4755 style="margin:0"
4756 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4757 >
4758 <button
4759 type="submit"
4760 class="branches-btn branches-btn-danger"
4761 >
4762 Delete
4763 </button>
4764 </form>
4765 )}
4766 </div>
4767 </div>
4768 </div>
4769 ))}
4770 </div>
4771 )}
4772 </div>
4773 </Layout>
4774 );
4775});
4776
4777// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4778// that are not merged into the default branch — matches the explicit
4779// confirmation on the row's delete button.
4780web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4781 const { owner, repo } = c.req.param();
4782 const branchName = decodeURIComponent(c.req.param("name"));
4783 const user = c.get("user")!;
4784
4785 // Owner-only check (mirrors collaborators.tsx pattern).
4786 const [ownerRow] = await db
4787 .select()
4788 .from(users)
4789 .where(eq(users.username, owner))
4790 .limit(1);
4791 if (!ownerRow || ownerRow.id !== user.id) {
4792 return c.redirect(
4793 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4794 );
4795 }
4796
4797 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4798 if (branchName === defaultBranch) {
4799 return c.redirect(
4800 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4801 );
4802 }
4803
4804 const branches = await listBranches(owner, repo);
4805 if (!branches.includes(branchName)) {
4806 return c.redirect(
4807 `/${owner}/${repo}/branches?error=Branch+not+found`
4808 );
4809 }
4810
4811 try {
4812 const repoDir = getRepoPath(owner, repo);
4813 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4814 cwd: repoDir,
4815 stdout: "pipe",
4816 stderr: "pipe",
4817 });
4818 await proc.exited;
4819 if (proc.exitCode !== 0) {
4820 return c.redirect(
4821 `/${owner}/${repo}/branches?error=Delete+failed`
4822 );
4823 }
4824 } catch {
4825 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4826 }
4827
4828 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4829});
4830
4831// ─── Tags list ────────────────────────────────────────────────────────────
4832// Pulls from `listTags` (sorted newest first). For each tag we look up an
4833// associated release row so the "View release" CTA can deep-link directly
4834// without making the user hunt for it.
4835web.get("/:owner/:repo/tags", async (c) => {
4836 const { owner, repo } = c.req.param();
4837 const user = c.get("user");
4838
4839 if (!(await repoExists(owner, repo))) return c.notFound();
4840
4841 const tags = await listTags(owner, repo);
4842
4843 // Map tags -> releases. Best-effort; releases table may not exist in
4844 // every test setup, so any error falls through with an empty set.
4845 const tagsWithReleases = new Set<string>();
4846 try {
4847 const [ownerRow] = await db
4848 .select()
4849 .from(users)
4850 .where(eq(users.username, owner))
4851 .limit(1);
4852 if (ownerRow) {
4853 const [repoRow] = await db
4854 .select()
4855 .from(repositories)
4856 .where(
4857 and(
4858 eq(repositories.ownerId, ownerRow.id),
4859 eq(repositories.name, repo)
4860 )
4861 )
4862 .limit(1);
4863 if (repoRow) {
4864 // Raw SQL so we don't need to import the releases schema here.
4865 const result = await db.execute(
4866 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4867 );
4868 const rows: any[] = (result as any).rows || (result as any) || [];
4869 for (const row of rows) {
4870 const tag = row?.tag;
4871 if (typeof tag === "string") tagsWithReleases.add(tag);
4872 }
4873 }
4874 }
4875 } catch {
4876 // No releases table or DB error — leave set empty.
4877 }
4878
4879 const relative = (iso: string): string => {
4880 if (!iso) return "—";
4881 const d = new Date(iso);
4882 if (Number.isNaN(d.getTime())) return "—";
4883 const diff = Date.now() - d.getTime();
4884 const m = Math.floor(diff / 60000);
4885 if (m < 1) return "just now";
4886 if (m < 60) return `${m} min ago`;
4887 const h = Math.floor(m / 60);
4888 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4889 const dd = Math.floor(h / 24);
4890 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4891 return d.toLocaleDateString("en-US", {
4892 month: "short",
4893 day: "numeric",
4894 year: "numeric",
4895 });
4896 };
4897
4898 return c.html(
4899 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4900 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4901 <RepoHeader owner={owner} repo={repo} />
4902 <RepoNav owner={owner} repo={repo} active="code" />
4903 <div
4904 class="tags-wrap"
eed4684Claude4905 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4906 >
4907 <header class="tags-head" style="margin-bottom:var(--space-5)">
4908 <div
4909 class="tags-eyebrow"
4910 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"
4911 >
4912 <span
4913 class="tags-eyebrow-dot"
4914 aria-hidden="true"
4915 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)"
4916 />
4917 Repository · Tags
4918 </div>
4919 <h1
4920 class="tags-title"
4921 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)"
4922 >
4923 <span class="gradient-text">{tags.length}</span>{" "}
4924 tag{tags.length === 1 ? "" : "s"}
4925 </h1>
4926 <p
4927 class="tags-sub"
4928 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4929 >
4930 Named points in the history — typically releases, milestones,
4931 or shipped versions. Click a tag to browse the tree at that
4932 revision.
4933 </p>
4934 </header>
4935
4936 {tags.length === 0 ? (
4937 <div class="commits-empty">
4938 <div class="commits-empty-orb" aria-hidden="true" />
4939 <div class="commits-empty-inner">
4940 <div class="commits-empty-icon" aria-hidden="true">
4941 <svg
4942 width="22"
4943 height="22"
4944 viewBox="0 0 24 24"
4945 fill="none"
4946 stroke="currentColor"
4947 stroke-width="2"
4948 stroke-linecap="round"
4949 stroke-linejoin="round"
4950 >
4951 <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" />
4952 <line x1="7" y1="7" x2="7.01" y2="7" />
4953 </svg>
4954 </div>
4955 <h3 class="commits-empty-title">No tags yet</h3>
4956 <p class="commits-empty-sub">
4957 Tag a commit to mark a release or milestone. From the CLI:{" "}
4958 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4959 </p>
4960 </div>
4961 </div>
4962 ) : (
4963 <div class="tags-list">
4964 {tags.map((t) => {
4965 const hasRelease = tagsWithReleases.has(t.name);
4966 return (
4967 <div class="tags-row">
4968 <div class="tags-row-icon" aria-hidden="true">
4969 <svg
4970 width="14"
4971 height="14"
4972 viewBox="0 0 24 24"
4973 fill="none"
4974 stroke="currentColor"
4975 stroke-width="2"
4976 stroke-linecap="round"
4977 stroke-linejoin="round"
4978 >
4979 <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" />
4980 <line x1="7" y1="7" x2="7.01" y2="7" />
4981 </svg>
4982 </div>
4983 <div class="tags-row-main">
4984 <div class="tags-row-name">
4985 <a
4986 href={`/${owner}/${repo}/tree/${t.name}`}
4987 style="text-decoration:none"
4988 >
4989 <span class="tags-row-version">{t.name}</span>
4990 </a>
4991 </div>
4992 <div class="tags-row-meta">
4993 <span
4994 title={t.date ? new Date(t.date).toISOString() : ""}
4995 >
4996 Tagged {relative(t.date)}
4997 </span>
4998 <span class="sep">·</span>
4999 <a
5000 href={`/${owner}/${repo}/commit/${t.sha}`}
5001 class="tags-row-sha"
5002 title={t.sha}
5003 >
5004 {t.sha.slice(0, 7)}
5005 </a>
5006 </div>
5007 </div>
5008 <div class="tags-row-side">
5009 <a
5010 href={`/${owner}/${repo}/tree/${t.name}`}
5011 class="tags-row-link"
5012 >
5013 Browse files
5014 </a>
5015 {hasRelease && (
5016 <a
5017 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5018 class="tags-row-link"
5019 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
5020 >
5021 View release
5022 </a>
5023 )}
5024 </div>
5025 </div>
5026 );
5027 })}
5028 </div>
5029 )}
5030 </div>
5031 </Layout>
5032 );
5033});
5034
fc1817aClaude5035// Commit log
5036web.get("/:owner/:repo/commits/:ref?", async (c) => {
5037 const { owner, repo } = c.req.param();
06d5ffeClaude5038 const user = c.get("user");
fc1817aClaude5039 const ref =
5040 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5041 const branches = await listBranches(owner, repo);
fc1817aClaude5042
5043 const commits = await listCommits(owner, repo, ref, 50);
5044
3951454Claude5045 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5046 // Also resolve repoId here so we can query the recent push for the
5047 // Push Watch live/watch indicator in the header.
3951454Claude5048 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5049 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5050 try {
5051 const [ownerRow] = await db
5052 .select()
5053 .from(users)
5054 .where(eq(users.username, owner))
5055 .limit(1);
5056 if (ownerRow) {
5057 const [repoRow] = await db
5058 .select()
5059 .from(repositories)
5060 .where(
5061 and(
5062 eq(repositories.ownerId, ownerRow.id),
5063 eq(repositories.name, repo)
5064 )
5065 )
5066 .limit(1);
8c790e0Claude5067 if (repoRow) {
5068 // Fetch verifications and recent push in parallel.
5069 const [verRows, rp] = await Promise.all([
5070 commits.length > 0
5071 ? db
5072 .select()
5073 .from(commitVerifications)
5074 .where(
5075 and(
5076 eq(commitVerifications.repositoryId, repoRow.id),
5077 inArray(
5078 commitVerifications.commitSha,
5079 commits.map((c) => c.sha)
5080 )
5081 )
5082 )
5083 : Promise.resolve([]),
5084 getRecentPush(repoRow.id),
5085 ]);
5086 for (const r of verRows) {
3951454Claude5087 verifications[r.commitSha] = {
5088 verified: r.verified,
5089 reason: r.reason,
5090 };
5091 }
8c790e0Claude5092 commitsPageRecentPush = rp;
3951454Claude5093 }
5094 }
5095 } catch {
5096 // DB unavailable — skip the badges gracefully.
5097 }
5098
fc1817aClaude5099 return c.html(
06d5ffeClaude5100 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5101 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5102 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5103 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5104 <div class="commits-hero">
5105 <div class="commits-hero-orb-wrap" aria-hidden="true">
5106 <div class="commits-hero-orb" />
fc1817aClaude5107 </div>
efb11c5Claude5108 <div class="commits-hero-inner">
5109 <div class="commits-eyebrow">
5110 <strong>History</strong> · {owner}/{repo}
5111 </div>
5112 <h1 class="commits-title">
5113 <span class="gradient-text">{commits.length}</span> recent commit
5114 {commits.length === 1 ? "" : "s"} on{" "}
5115 <span class="commits-branch">{ref}</span>
5116 </h1>
5117 <p class="commits-sub">
5118 Browse the project's history. Click any commit to see the full
5119 diff, AI review notes, and signature status.
5120 </p>
5121 </div>
5122 </div>
5123 <div class="commits-toolbar">
5124 <BranchSwitcher
3951454Claude5125 owner={owner}
5126 repo={repo}
efb11c5Claude5127 currentRef={ref}
5128 branches={branches}
5129 pathType="commits"
3951454Claude5130 />
398a10cClaude5131 <div class="commits-toolbar-actions">
5132 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5133 {"⊢"} Branches
5134 </a>
5135 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5136 {"#"} Tags
5137 </a>
5138 </div>
efb11c5Claude5139 </div>
5140 {commits.length === 0 ? (
5141 <div class="commits-empty">
398a10cClaude5142 <div class="commits-empty-orb" aria-hidden="true" />
5143 <div class="commits-empty-inner">
5144 <div class="commits-empty-icon" aria-hidden="true">
5145 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5146 <circle cx="12" cy="12" r="4" />
5147 <line x1="1.05" y1="12" x2="7" y2="12" />
5148 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5149 </svg>
5150 </div>
5151 <h3 class="commits-empty-title">No commits yet</h3>
5152 <p class="commits-empty-sub">
5153 This branch is empty. Push your first commit, or use the
5154 web editor to create a file.
5155 </p>
5156 </div>
efb11c5Claude5157 </div>
5158 ) : (
5159 <div class="commits-list-wrap">
398a10cClaude5160 {(() => {
5161 // Group commits by day for the section headers.
5162 const dayLabel = (iso: string): string => {
5163 const d = new Date(iso);
5164 if (Number.isNaN(d.getTime())) return "Unknown";
5165 const today = new Date();
5166 const yesterday = new Date(today);
5167 yesterday.setDate(today.getDate() - 1);
5168 const sameDay = (a: Date, b: Date) =>
5169 a.getFullYear() === b.getFullYear() &&
5170 a.getMonth() === b.getMonth() &&
5171 a.getDate() === b.getDate();
5172 if (sameDay(d, today)) return "Today";
5173 if (sameDay(d, yesterday)) return "Yesterday";
5174 return d.toLocaleDateString("en-US", {
5175 month: "long",
5176 day: "numeric",
5177 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5178 });
5179 };
5180 const relative = (iso: string): string => {
5181 const d = new Date(iso);
5182 if (Number.isNaN(d.getTime())) return "";
5183 const diff = Date.now() - d.getTime();
5184 const m = Math.floor(diff / 60000);
5185 if (m < 1) return "just now";
5186 if (m < 60) return `${m} min ago`;
5187 const h = Math.floor(m / 60);
5188 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5189 const dd = Math.floor(h / 24);
5190 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5191 return d.toLocaleDateString("en-US", {
5192 month: "short",
5193 day: "numeric",
5194 });
5195 };
5196 const initial = (name: string): string =>
5197 (name || "?").trim().charAt(0).toUpperCase() || "?";
5198 // Build groups preserving order.
5199 const groups: Array<{ label: string; items: typeof commits }> = [];
5200 let lastLabel = "";
5201 for (const cm of commits) {
5202 const label = dayLabel(cm.date);
5203 if (label !== lastLabel) {
5204 groups.push({ label, items: [] });
5205 lastLabel = label;
5206 }
5207 groups[groups.length - 1].items.push(cm);
5208 }
5209 return groups.map((g) => (
5210 <>
5211 <div class="commits-day-head">
5212 <span class="commits-day-head-dot" aria-hidden="true" />
5213 Commits on {g.label}
5214 </div>
5215 {g.items.map((cm) => {
5216 const v = verifications[cm.sha];
5217 return (
5218 <div class="commits-row">
5219 <div class="commits-avatar" aria-hidden="true">
5220 {initial(cm.author)}
5221 </div>
5222 <div class="commits-row-body">
5223 <div class="commits-row-msg">
5224 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5225 {cm.message}
5226 </a>
5227 {v?.verified && (
5228 <span
5229 class="commits-row-verified"
5230 title="Signed with a registered key"
5231 >
5232 Verified
5233 </span>
5234 )}
5235 </div>
5236 <div class="commits-row-meta">
5237 <strong>{cm.author}</strong>
5238 <span class="sep">·</span>
5239 <span
5240 class="commits-row-time"
5241 title={new Date(cm.date).toISOString()}
5242 >
5243 committed {relative(cm.date)}
5244 </span>
5245 {cm.parentShas.length > 1 && (
5246 <>
5247 <span class="sep">·</span>
5248 <span>merge of {cm.parentShas.length} parents</span>
5249 </>
5250 )}
5251 </div>
5252 </div>
5253 <div class="commits-row-side">
5254 <a
5255 href={`/${owner}/${repo}/commit/${cm.sha}`}
5256 class="commits-row-sha"
5257 title={cm.sha}
5258 >
5259 {cm.sha.slice(0, 7)}
5260 </a>
8c790e0Claude5261 <a
5262 href={`/${owner}/${repo}/push/${cm.sha}`}
5263 class="commits-row-watch"
5264 title="Watch gate + deploy results for this push"
5265 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5266 >
5267 <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">
5268 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5269 <circle cx="12" cy="12" r="3" />
5270 </svg>
5271 </a>
398a10cClaude5272 <button
5273 type="button"
5274 class="commits-row-copy"
5275 data-copy-sha={cm.sha}
5276 title="Copy full SHA"
5277 aria-label={`Copy SHA ${cm.sha}`}
5278 >
5279 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5280 <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" />
5281 <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" />
5282 </svg>
5283 </button>
5284 </div>
5285 </div>
5286 );
5287 })}
5288 </>
5289 ));
5290 })()}
efb11c5Claude5291 </div>
fc1817aClaude5292 )}
398a10cClaude5293 <script
5294 dangerouslySetInnerHTML={{
5295 __html: `
5296 (function(){
5297 document.addEventListener('click', function(e){
5298 var t = e.target; if (!t) return;
5299 var btn = t.closest && t.closest('[data-copy-sha]');
5300 if (!btn) return;
5301 e.preventDefault();
5302 var sha = btn.getAttribute('data-copy-sha') || '';
5303 if (!navigator.clipboard) return;
5304 navigator.clipboard.writeText(sha).then(function(){
5305 btn.classList.add('is-copied');
5306 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5307 }).catch(function(){});
5308 });
5309 })();
5310 `,
5311 }}
5312 />
fc1817aClaude5313 </Layout>
5314 );
5315});
5316
5317// Single commit with diff
5318web.get("/:owner/:repo/commit/:sha", async (c) => {
5319 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5320 const user = c.get("user");
fc1817aClaude5321
05b973eClaude5322 // Fetch commit, full message, and diff in parallel
5323 const [commit, fullMessage, diffResult] = await Promise.all([
5324 getCommit(owner, repo, sha),
5325 getCommitFullMessage(owner, repo, sha),
5326 getDiff(owner, repo, sha),
5327 ]);
fc1817aClaude5328 if (!commit) {
5329 return c.html(
06d5ffeClaude5330 <Layout title="Not Found" user={user}>
fc1817aClaude5331 <div class="empty-state">
5332 <h2>Commit not found</h2>
5333 </div>
5334 </Layout>,
5335 404
5336 );
5337 }
5338
3951454Claude5339 // Block J3 — try to verify this commit's signature.
5340 let verification:
5341 | { verified: boolean; reason: string; signatureType: string | null }
5342 | null = null;
0cdfd89Claude5343 // Block J8 — external CI commit statuses rollup.
5344 let statusCombined:
5345 | {
5346 state: "pending" | "success" | "failure";
5347 total: number;
5348 contexts: Array<{
5349 context: string;
5350 state: string;
5351 description: string | null;
5352 targetUrl: string | null;
5353 }>;
5354 }
5355 | null = null;
3951454Claude5356 try {
5357 const [ownerRow] = await db
5358 .select()
5359 .from(users)
5360 .where(eq(users.username, owner))
5361 .limit(1);
5362 if (ownerRow) {
5363 const [repoRow] = await db
5364 .select()
5365 .from(repositories)
5366 .where(
5367 and(
5368 eq(repositories.ownerId, ownerRow.id),
5369 eq(repositories.name, repo)
5370 )
5371 )
5372 .limit(1);
5373 if (repoRow) {
5374 const { verifyCommit } = await import("../lib/signatures");
5375 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5376 verification = {
5377 verified: v.verified,
5378 reason: v.reason,
5379 signatureType: v.signatureType,
5380 };
0cdfd89Claude5381 try {
5382 const { combinedStatus } = await import("../lib/commit-statuses");
5383 const combined = await combinedStatus(repoRow.id, commit.sha);
5384 if (combined.total > 0) {
5385 statusCombined = {
5386 state: combined.state as any,
5387 total: combined.total,
5388 contexts: combined.contexts.map((c) => ({
5389 context: c.context,
5390 state: c.state,
5391 description: c.description,
5392 targetUrl: c.targetUrl,
5393 })),
5394 };
5395 }
5396 } catch {
5397 statusCombined = null;
5398 }
3951454Claude5399 }
5400 }
5401 } catch {
5402 verification = null;
5403 }
5404
05b973eClaude5405 const { files, raw } = diffResult;
fc1817aClaude5406
efb11c5Claude5407 // Diff stats: count additions / deletions across all files for the
5408 // header summary bar. Computed here from the parsed diff so we don't
5409 // touch the DiffView component.
5410 let additions = 0;
5411 let deletions = 0;
5412 for (const f of files) {
5413 const hunks = (f as any).hunks as Array<any> | undefined;
5414 if (Array.isArray(hunks)) {
5415 for (const h of hunks) {
5416 const lines = (h?.lines || []) as Array<any>;
5417 for (const ln of lines) {
5418 const t = ln?.type || ln?.kind;
5419 if (t === "add" || t === "added" || t === "+") additions += 1;
5420 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5421 deletions += 1;
5422 }
5423 }
5424 }
5425 }
5426 // Fall back: scan raw if file-level counting yielded zero (it's just a
5427 // header polish — never let a parsing miss break the page).
5428 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5429 for (const line of raw.split("\n")) {
5430 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5431 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5432 }
5433 }
5434 const fileCount = files.length;
5435
fc1817aClaude5436 return c.html(
06d5ffeClaude5437 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5438 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5439 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5440 <div class="commit-detail-card">
5441 <div class="commit-detail-eyebrow">
5442 <strong>Commit</strong>
5443 <span class="commit-detail-sha-pill" title={commit.sha}>
5444 {commit.sha.slice(0, 7)}
5445 </span>
3951454Claude5446 {verification && verification.reason !== "unsigned" && (
5447 <span
efb11c5Claude5448 class={`commit-detail-verify ${
3951454Claude5449 verification.verified
efb11c5Claude5450 ? "commit-detail-verify-ok"
5451 : "commit-detail-verify-warn"
3951454Claude5452 }`}
5453 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5454 >
5455 {verification.verified ? "Verified" : verification.reason}
5456 </span>
5457 )}
fc1817aClaude5458 </div>
efb11c5Claude5459 <h1 class="commit-detail-title">{commit.message}</h1>
5460 {fullMessage !== commit.message && (
5461 <pre class="commit-detail-body">{fullMessage}</pre>
5462 )}
5463 <div class="commit-detail-meta">
5464 <span class="commit-detail-author">
5465 <strong>{commit.author}</strong> committed on{" "}
5466 {new Date(commit.date).toLocaleDateString("en-US", {
5467 month: "long",
5468 day: "numeric",
5469 year: "numeric",
5470 })}
5471 </span>
fc1817aClaude5472 {commit.parentShas.length > 0 && (
efb11c5Claude5473 <span class="commit-detail-parents">
5474 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5475 {commit.parentShas.map((p, idx) => (
5476 <>
5477 {idx > 0 && " "}
5478 <a
5479 href={`/${owner}/${repo}/commit/${p}`}
5480 class="commit-detail-sha-link"
5481 >
5482 {p.slice(0, 7)}
5483 </a>
5484 </>
fc1817aClaude5485 ))}
5486 </span>
5487 )}
5488 </div>
efb11c5Claude5489 <div class="commit-detail-stats">
5490 <span class="commit-detail-stat">
5491 <strong>{fileCount}</strong>{" "}
5492 file{fileCount === 1 ? "" : "s"} changed
5493 </span>
5494 <span class="commit-detail-stat commit-detail-stat-add">
5495 <span class="commit-detail-stat-mark">+</span>
5496 <strong>{additions}</strong>
5497 </span>
5498 <span class="commit-detail-stat commit-detail-stat-del">
5499 <span class="commit-detail-stat-mark">−</span>
5500 <strong>{deletions}</strong>
5501 </span>
5502 <span class="commit-detail-sha-full" title="Full SHA">
5503 {commit.sha}
5504 </span>
5505 </div>
0cdfd89Claude5506 {statusCombined && (
efb11c5Claude5507 <div class="commit-detail-checks">
5508 <div class="commit-detail-checks-head">
5509 <strong>Checks</strong>
5510 <span class="commit-detail-checks-summary">
5511 {statusCombined.total} total ·{" "}
5512 <span
5513 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5514 >
5515 {statusCombined.state}
5516 </span>
0cdfd89Claude5517 </span>
efb11c5Claude5518 </div>
5519 <div class="commit-detail-check-row">
0cdfd89Claude5520 {statusCombined.contexts.map((cx) => (
5521 <span
efb11c5Claude5522 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5523 title={cx.description || cx.context}
5524 >
5525 {cx.targetUrl ? (
efb11c5Claude5526 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5527 {cx.context}: {cx.state}
5528 </a>
5529 ) : (
5530 <>
5531 {cx.context}: {cx.state}
5532 </>
5533 )}
5534 </span>
5535 ))}
5536 </div>
5537 </div>
5538 )}
fc1817aClaude5539 </div>
ea9ed4cClaude5540 <DiffView
5541 raw={raw}
5542 files={files}
5543 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5544 />
fc1817aClaude5545 </Layout>
5546 );
5547});
5548
79136bbClaude5549// Raw file download
5550web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5551 const { owner, repo } = c.req.param();
5552 const refAndPath = c.req.param("ref");
5553
5554 const branches = await listBranches(owner, repo);
5555 let ref = "";
5556 let filePath = "";
5557
5558 for (const branch of branches) {
5559 if (refAndPath.startsWith(branch + "/")) {
5560 ref = branch;
5561 filePath = refAndPath.slice(branch.length + 1);
5562 break;
5563 }
5564 }
5565
5566 if (!ref) {
5567 const slashIdx = refAndPath.indexOf("/");
5568 if (slashIdx === -1) return c.text("Not found", 404);
5569 ref = refAndPath.slice(0, slashIdx);
5570 filePath = refAndPath.slice(slashIdx + 1);
5571 }
5572
5573 const data = await getRawBlob(owner, repo, ref, filePath);
5574 if (!data) return c.text("Not found", 404);
5575
5576 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5577 return new Response(data as BodyInit, {
79136bbClaude5578 headers: {
5579 "Content-Type": "application/octet-stream",
5580 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5581 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5582 },
5583 });
5584});
5585
5586// Blame view
5587web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5588 const { owner, repo } = c.req.param();
5589 const user = c.get("user");
5590 const refAndPath = c.req.param("ref");
5591
5592 const branches = await listBranches(owner, repo);
5593 let ref = "";
5594 let filePath = "";
5595
5596 for (const branch of branches) {
5597 if (refAndPath.startsWith(branch + "/")) {
5598 ref = branch;
5599 filePath = refAndPath.slice(branch.length + 1);
5600 break;
5601 }
5602 }
5603
5604 if (!ref) {
5605 const slashIdx = refAndPath.indexOf("/");
5606 if (slashIdx === -1) return c.text("Not found", 404);
5607 ref = refAndPath.slice(0, slashIdx);
5608 filePath = refAndPath.slice(slashIdx + 1);
5609 }
5610
5611 const blameLines = await getBlame(owner, repo, ref, filePath);
5612 if (blameLines.length === 0) {
5613 return c.html(
5614 <Layout title="Not Found" user={user}>
5615 <div class="empty-state">
5616 <h2>File not found</h2>
5617 </div>
5618 </Layout>,
5619 404
5620 );
5621 }
5622
5623 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5624 // Unique contributors (by author) tracked once for the header chip.
5625 const blameAuthors = new Set<string>();
5626 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5627
5628 return c.html(
5629 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5630 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5631 <RepoHeader owner={owner} repo={repo} />
5632 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5633 <header class="blame-head">
5634 <div class="blame-eyebrow">
5635 <span class="blame-eyebrow-dot" aria-hidden="true" />
5636 Blame · Line-by-line history
5637 </div>
5638 <h1 class="blame-title">
5639 <code>{fileName}</code>
5640 </h1>
5641 <p class="blame-sub">
5642 Each line is annotated with the commit that last touched it.
5643 Click any SHA to jump to that commit and see the surrounding
5644 change.
5645 </p>
5646 </header>
efb11c5Claude5647 <div class="blame-toolbar">
5648 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5649 </div>
398a10cClaude5650 <div class="blame-card">
5651 <div class="blame-header">
efb11c5Claude5652 <div class="blame-header-meta">
5653 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5654 <span class="blame-header-name">{fileName}</span>
5655 <span class="blame-header-tag">Blame</span>
5656 <span class="blame-header-stats">
5657 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5658 {blameAuthors.size} contributor
5659 {blameAuthors.size === 1 ? "" : "s"}
5660 </span>
5661 </div>
5662 <div class="blame-header-actions">
5663 <a
5664 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5665 class="blob-pill"
5666 >
5667 Normal view
5668 </a>
5669 <a
5670 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5671 class="blob-pill"
5672 >
5673 Raw
5674 </a>
5675 </div>
79136bbClaude5676 </div>
398a10cClaude5677 <div style="overflow-x:auto">
5678 <table class="blame-table">
79136bbClaude5679 <tbody>
5680 {blameLines.map((line, i) => {
5681 const showInfo =
5682 i === 0 || blameLines[i - 1].sha !== line.sha;
5683 return (
398a10cClaude5684 <tr class={showInfo ? "blame-row-first" : ""}>
5685 <td class="blame-gutter">
79136bbClaude5686 {showInfo && (
398a10cClaude5687 <span class="blame-gutter-inner">
79136bbClaude5688 <a
5689 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5690 class="blame-gutter-sha"
5691 title={`Commit ${line.sha}`}
79136bbClaude5692 >
5693 {line.sha.slice(0, 7)}
398a10cClaude5694 </a>
5695 <span class="blame-gutter-author" title={line.author}>
5696 {line.author}
5697 </span>
5698 </span>
79136bbClaude5699 )}
5700 </td>
398a10cClaude5701 <td class="blame-line-num">{line.lineNum}</td>
5702 <td class="blame-line-content">{line.content}</td>
79136bbClaude5703 </tr>
5704 );
5705 })}
5706 </tbody>
5707 </table>
5708 </div>
5709 </div>
5710 </Layout>
5711 );
5712});
5713
5714// Search
5715web.get("/:owner/:repo/search", async (c) => {
5716 const { owner, repo } = c.req.param();
5717 const user = c.get("user");
5718 const q = c.req.query("q") || "";
5719
5720 if (!(await repoExists(owner, repo))) return c.notFound();
5721
5722 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5723 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5724
5725 if (q.trim()) {
5726 results = await searchCode(owner, repo, defaultBranch, q.trim());
5727 }
5728
5729 return c.html(
5730 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude5731 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5732 <RepoHeader owner={owner} repo={repo} />
5733 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5734 <div class="search-hero">
5735 <div class="search-eyebrow">
5736 <strong>Search</strong> · {owner}/{repo}
5737 </div>
5738 <h1 class="search-title">
5739 Find any line in <span class="gradient-text">{repo}</span>
5740 </h1>
5741 <form
5742 method="get"
5743 action={`/${owner}/${repo}/search`}
5744 class="search-form"
5745 role="search"
5746 >
5747 <div class="search-input-wrap">
5748 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5749 <input
5750 type="text"
5751 name="q"
5752 value={q}
5753 placeholder="Search code on the default branch…"
5754 aria-label="Search code"
5755 class="search-input"
5756 autocomplete="off"
5757 autofocus
5758 />
5759 </div>
5760 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5761 Search
5762 </button>
efb11c5Claude5763 </form>
5764 </div>
79136bbClaude5765 {q && (
efb11c5Claude5766 <div class="search-results-head">
5767 <span class="search-results-count">
5768 <strong>{results.length}</strong> result
5769 {results.length !== 1 ? "s" : ""}
5770 </span>
5771 <span class="search-results-query">
5772 for <span class="search-results-q">"{q}"</span> on{" "}
5773 <code>{defaultBranch}</code>
5774 </span>
5775 </div>
79136bbClaude5776 )}
efb11c5Claude5777 {q && results.length === 0 ? (
5778 <div class="search-empty">
5779 <p>
5780 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5781 you're on the right branch.
5782 </p>
5783 </div>
5784 ) : results.length > 0 ? (
79136bbClaude5785 <div class="search-results">
5786 {(() => {
5787 // Group by file
5788 const grouped: Record<
5789 string,
5790 Array<{ lineNum: number; line: string }>
5791 > = {};
5792 for (const r of results) {
5793 if (!grouped[r.file]) grouped[r.file] = [];
5794 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5795 }
5796 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude5797 <div class="search-file diff-file">
5798 <div class="search-file-head diff-file-header">
79136bbClaude5799 <a
5800 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude5801 class="search-file-link"
79136bbClaude5802 >
5803 {file}
5804 </a>
efb11c5Claude5805 <span class="search-file-count">
5806 {matches.length} match{matches.length === 1 ? "" : "es"}
5807 </span>
79136bbClaude5808 </div>
5809 <div class="blob-code">
5810 <table>
5811 <tbody>
5812 {matches.map((m) => (
5813 <tr>
5814 <td class="line-num">{m.lineNum}</td>
5815 <td class="line-content">{m.line}</td>
5816 </tr>
5817 ))}
5818 </tbody>
5819 </table>
5820 </div>
5821 </div>
5822 ));
5823 })()}
5824 </div>
efb11c5Claude5825 ) : null}
79136bbClaude5826 </Layout>
5827 );
5828});
5829
fc1817aClaude5830export default web;