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