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.tsxBlame5237 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";
8e9f1d9Claude8import { eq, and, desc, inArray, sql } 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,
3951454Claude17} from "../db/schema";
fc1817aClaude18import { Layout } from "../views/layout";
cb5a796Claude19import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude20import {
21 RepoHeader,
22 RepoNav,
23 Breadcrumb,
24 FileTable,
06d5ffeClaude25 RepoCard,
26 BranchSwitcher,
27 HighlightedCode,
28 PlainCode,
8c790e0Claude29 type RecentPush,
fc1817aClaude30} from "../views/components";
ea9ed4cClaude31import { DiffView } from "../views/diff-view";
fc1817aClaude32import {
33 getTree,
34 getBlob,
35 listCommits,
36 getCommit,
37 getCommitFullMessage,
38 getDiff,
39 getReadme,
40 getDefaultBranch,
41 listBranches,
398a10cClaude42 listTags,
fc1817aClaude43 repoExists,
06d5ffeClaude44 initBareRepo,
79136bbClaude45 getBlame,
46 getRawBlob,
47 searchCode,
398a10cClaude48 getRepoPath,
fc1817aClaude49} from "../git/repository";
79136bbClaude50import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude51import { highlightCode } from "../lib/highlight";
91a0204Claude52import { computeHealthScore } from "../lib/health-score";
53import type { HealthScore } from "../lib/health-score";
06d5ffeClaude54import { softAuth, requireAuth } from "../middleware/auth";
55import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude56import { trackByName } from "../lib/traffic";
534f04aClaude57import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude58import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude59import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude60import {
61 listQueuedAiBuildIssues,
62 listRecentAutoMerges,
63 listRecentAiReviews,
64 countAiReviewsSince,
65 listDemoActivityFeed,
66} from "../lib/demo-activity";
91a0204Claude67import { computeHealthScore, type HealthScore } from "../lib/health-score";
fc1817aClaude68
06d5ffeClaude69const web = new Hono<AuthEnv>();
70
71// Soft auth on all web routes — c.get("user") available but may be null
72web.use("*", softAuth);
fc1817aClaude73
8c790e0Claude74/**
75 * Query the most recent push to a repo from the activity_feed table.
76 * Returns a RecentPush if there's been a push in the last 24 hours,
77 * or null otherwise. Used by the RepoHeader live/watch indicator.
78 *
79 * Queries activity_feed where action='push' and targetId holds the commit SHA.
80 */
81async function getRecentPush(repoId: string): Promise<RecentPush | null> {
82 try {
83 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
84 const [row] = await db
85 .select({
86 targetId: activityFeed.targetId,
87 createdAt: activityFeed.createdAt,
88 })
89 .from(activityFeed)
90 .where(
91 and(
92 eq(activityFeed.repositoryId, repoId),
93 eq(activityFeed.action, "push"),
94 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
95 )
96 )
97 .orderBy(desc(activityFeed.createdAt))
98 .limit(1);
99 if (!row || !row.targetId) return null;
100 return {
101 sha: row.targetId,
102 ageMs: Date.now() - new Date(row.createdAt).getTime(),
103 };
104 } catch {
105 // DB unavailable or schema mismatch — degrade gracefully.
106 return null;
107 }
108}
109
efb11c5Claude110/**
111 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
112 *
113 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
114 * scope is route-local and `layout.tsx` is locked. Each polished handler
115 * injects this via a `<style>` tag; the rules are namespaced by surface
116 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
117 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
118 * `.repo-home-*` styling Agent A already shipped.
119 */
120const codeBrowseCss = `
121 /* ───────── shared primitives ───────── */
122 .cb-hairline::before,
123 .new-repo-hero::before,
124 .profile-hero::before,
125 .commits-hero::before,
126 .commit-detail-card::before,
127 .search-hero::before {
128 content: '';
129 position: absolute;
130 top: 0; left: 0; right: 0;
131 height: 2px;
132 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
133 opacity: 0.7;
134 pointer-events: none;
135 }
136 @keyframes cbHeroOrb {
137 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
138 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
139 }
140 @media (prefers-reduced-motion: reduce) {
141 .new-repo-hero-orb,
142 .profile-hero-orb,
143 .commits-hero-orb { animation: none; }
144 }
145
146 /* ───────── new-repo ───────── */
147 .new-repo-hero {
148 position: relative;
149 margin-bottom: var(--space-5);
150 padding: var(--space-5) var(--space-6);
151 background: var(--bg-elevated);
152 border: 1px solid var(--border);
153 border-radius: 16px;
154 overflow: hidden;
155 }
156 .new-repo-hero-orb-wrap {
157 position: absolute;
158 inset: -25% -10% auto auto;
159 width: 360px;
160 height: 360px;
161 pointer-events: none;
162 z-index: 0;
163 }
164 .new-repo-hero-orb {
165 position: absolute;
166 inset: 0;
167 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
168 filter: blur(80px);
169 opacity: 0.7;
170 animation: cbHeroOrb 14s ease-in-out infinite;
171 }
172 .new-repo-hero-inner { position: relative; z-index: 1; }
173 .new-repo-eyebrow {
174 font-size: 12px;
175 font-family: var(--font-mono);
176 color: var(--text-muted);
177 letter-spacing: 0.1em;
178 text-transform: uppercase;
179 margin-bottom: var(--space-2);
180 }
181 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
182 .new-repo-title {
183 font-family: var(--font-display);
184 font-weight: 800;
185 letter-spacing: -0.028em;
186 font-size: clamp(28px, 4vw, 40px);
187 line-height: 1.05;
188 margin: 0 0 var(--space-2);
189 color: var(--text-strong);
190 }
191 .new-repo-sub {
192 font-size: 15px;
193 color: var(--text-muted);
194 margin: 0;
195 line-height: 1.55;
196 max-width: 620px;
197 }
198 .new-repo-form {
199 max-width: 680px;
200 }
201 .new-repo-error {
202 background: rgba(218, 54, 51, 0.12);
203 border: 1px solid rgba(218, 54, 51, 0.35);
204 color: #ffb3b3;
205 padding: 10px 14px;
206 border-radius: 10px;
207 margin-bottom: var(--space-4);
208 font-size: 14px;
209 }
210 .new-repo-form-grid {
211 display: flex;
212 flex-direction: column;
213 gap: var(--space-4);
214 }
215 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
216 .new-repo-label {
217 font-size: 13px;
218 color: var(--text-strong);
219 font-weight: 600;
220 }
221 .new-repo-label-optional {
222 color: var(--text-muted);
223 font-weight: 400;
224 font-size: 12px;
225 }
226 .new-repo-input {
227 appearance: none;
228 width: 100%;
229 background: var(--bg);
230 border: 1px solid var(--border);
231 color: var(--text-strong);
232 border-radius: 10px;
233 padding: 10px 12px;
234 font-size: 14px;
235 font-family: inherit;
236 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
237 }
238 .new-repo-input:focus {
239 outline: none;
240 border-color: var(--accent);
241 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
242 }
243 .new-repo-input-disabled {
244 color: var(--text-muted);
245 background: var(--bg-secondary);
246 cursor: not-allowed;
247 }
248 .new-repo-hint {
249 font-size: 12px;
250 color: var(--text-muted);
251 margin: 4px 0 0;
252 }
253 .new-repo-hint code {
254 font-family: var(--font-mono);
255 font-size: 11.5px;
256 background: var(--bg-secondary);
257 border: 1px solid var(--border);
258 border-radius: 5px;
259 padding: 1px 5px;
260 color: var(--text-strong);
261 }
262 .new-repo-visibility {
263 display: grid;
264 grid-template-columns: 1fr 1fr;
265 gap: var(--space-2);
266 }
267 @media (max-width: 600px) {
268 .new-repo-visibility { grid-template-columns: 1fr; }
269 }
270 .new-repo-vis-card {
271 display: flex;
272 gap: 10px;
273 padding: 14px;
274 background: var(--bg);
275 border: 1px solid var(--border);
276 border-radius: 12px;
277 cursor: pointer;
278 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
279 }
280 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
281 .new-repo-vis-card:has(input:checked) {
282 border-color: rgba(140,109,255,0.55);
283 background: rgba(140,109,255,0.06);
284 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
285 }
286 .new-repo-vis-radio {
287 margin-top: 3px;
288 accent-color: var(--accent);
289 }
290 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
291 .new-repo-vis-label {
292 font-size: 14px;
293 font-weight: 600;
294 color: var(--text-strong);
295 }
296 .new-repo-vis-desc {
297 font-size: 12.5px;
298 color: var(--text-muted);
299 line-height: 1.45;
300 }
301 .new-repo-callout {
302 margin-top: var(--space-2);
303 padding: var(--space-3) var(--space-4);
304 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
305 border: 1px solid rgba(140,109,255,0.2);
306 border-radius: 12px;
307 }
308 .new-repo-callout-eyebrow {
309 font-size: 11px;
310 font-family: var(--font-mono);
311 text-transform: uppercase;
312 letter-spacing: 0.12em;
313 color: var(--accent);
314 font-weight: 700;
315 margin-bottom: 4px;
316 }
317 .new-repo-callout-body {
318 font-size: 13px;
319 color: var(--text);
320 line-height: 1.5;
321 margin: 0;
322 }
323 .new-repo-callout-body code {
324 font-family: var(--font-mono);
325 font-size: 12px;
326 color: var(--accent);
327 background: rgba(140,109,255,0.1);
328 border-radius: 4px;
329 padding: 1px 5px;
330 }
398a10cClaude331 .new-repo-templates {
332 display: flex;
333 flex-wrap: wrap;
334 gap: 8px;
335 margin-top: 4px;
336 }
337 .new-repo-template-chip {
338 position: relative;
339 display: inline-flex;
340 align-items: center;
341 gap: 6px;
342 padding: 8px 14px;
343 background: var(--bg);
344 border: 1px solid var(--border);
345 border-radius: 999px;
346 font-size: 13px;
347 color: var(--text);
348 cursor: pointer;
349 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
350 }
351 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
352 .new-repo-template-chip:hover {
353 border-color: var(--border-strong, var(--border));
354 color: var(--text-strong);
355 }
356 .new-repo-template-chip:has(input:checked) {
357 border-color: rgba(140,109,255,0.55);
358 background: rgba(140,109,255,0.10);
359 color: var(--text-strong);
360 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
361 }
362 .new-repo-template-chip-dot {
363 width: 8px; height: 8px;
364 border-radius: 999px;
365 background: var(--text-faint, var(--text-muted));
366 transition: background 140ms ease, box-shadow 140ms ease;
367 }
368 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
369 background: linear-gradient(135deg, #8c6dff, #36c5d6);
370 box-shadow: 0 0 8px rgba(140,109,255,0.6);
371 }
efb11c5Claude372 .new-repo-actions {
373 display: flex;
374 gap: var(--space-2);
375 margin-top: var(--space-2);
398a10cClaude376 align-items: center;
377 }
378 .new-repo-submit {
379 min-width: 180px;
380 border: 1px solid rgba(140,109,255,0.45);
381 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
382 color: #fff;
383 font-weight: 700;
384 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
385 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
386 }
387 .new-repo-submit:hover {
388 transform: translateY(-1px);
389 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
390 filter: brightness(1.06);
391 }
392 .new-repo-submit:focus-visible {
393 outline: 3px solid rgba(140,109,255,0.45);
394 outline-offset: 2px;
efb11c5Claude395 }
396
397 /* ───────── profile ───────── */
398 .profile-hero {
399 position: relative;
400 margin-bottom: var(--space-5);
401 padding: var(--space-5) var(--space-6);
402 background: var(--bg-elevated);
403 border: 1px solid var(--border);
404 border-radius: 16px;
405 overflow: hidden;
406 }
407 .profile-hero-orb-wrap {
408 position: absolute;
409 inset: -25% -10% auto auto;
410 width: 360px;
411 height: 360px;
412 pointer-events: none;
413 z-index: 0;
414 }
415 .profile-hero-orb {
416 position: absolute;
417 inset: 0;
418 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
419 filter: blur(80px);
420 opacity: 0.7;
421 animation: cbHeroOrb 14s ease-in-out infinite;
422 }
423 .profile-hero-inner {
424 position: relative;
425 z-index: 1;
426 display: flex;
427 align-items: flex-start;
428 gap: var(--space-5);
429 }
430 .profile-hero-avatar {
431 flex: 0 0 auto;
432 width: 88px;
433 height: 88px;
434 border-radius: 50%;
435 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
436 color: #fff;
437 display: flex;
438 align-items: center;
439 justify-content: center;
440 font-size: 38px;
441 font-weight: 700;
442 font-family: var(--font-display);
443 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
444 }
445 .profile-hero-text { flex: 1; min-width: 0; }
446 .profile-eyebrow {
447 font-size: 12px;
448 font-family: var(--font-mono);
449 color: var(--text-muted);
450 letter-spacing: 0.1em;
451 text-transform: uppercase;
452 margin-bottom: var(--space-2);
453 }
454 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
455 .profile-name {
456 font-family: var(--font-display);
457 font-weight: 800;
458 letter-spacing: -0.028em;
459 font-size: clamp(28px, 3.6vw, 36px);
460 line-height: 1.05;
461 margin: 0 0 4px;
462 color: var(--text-strong);
463 }
464 .profile-handle {
465 font-family: var(--font-mono);
466 font-size: 13px;
467 color: var(--text-muted);
468 margin-bottom: var(--space-2);
469 }
470 .profile-bio {
471 font-size: 14.5px;
472 color: var(--text);
473 line-height: 1.55;
474 margin: 0 0 var(--space-3);
475 max-width: 640px;
476 }
477 .profile-meta {
478 display: flex;
479 align-items: center;
480 gap: var(--space-4);
481 flex-wrap: wrap;
482 font-size: 13px;
483 }
484 .profile-meta-link {
485 color: var(--text-muted);
486 transition: color var(--t-fast, 0.15s) ease;
487 }
488 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
489 .profile-meta-link strong {
490 color: var(--text-strong);
491 font-weight: 600;
492 font-variant-numeric: tabular-nums;
493 }
494 .profile-follow-form { margin: 0; }
495 @media (max-width: 600px) {
496 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
497 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
498 }
499 .profile-readme {
500 margin-bottom: var(--space-6);
501 background: var(--bg-elevated);
502 border: 1px solid var(--border);
503 border-radius: 12px;
504 overflow: hidden;
505 }
506 .profile-readme-head {
507 display: flex;
508 align-items: center;
509 gap: 8px;
510 padding: 10px 16px;
511 background: var(--bg-secondary);
512 border-bottom: 1px solid var(--border);
513 font-size: 13px;
514 color: var(--text-muted);
515 }
516 .profile-readme-icon { color: var(--accent); font-size: 14px; }
517 .profile-readme-body { padding: var(--space-5) var(--space-6); }
518 .profile-section-head {
519 display: flex;
520 align-items: baseline;
521 gap: var(--space-2);
522 margin-bottom: var(--space-3);
523 }
524 .profile-section-title {
525 font-family: var(--font-display);
526 font-weight: 700;
527 font-size: 20px;
528 letter-spacing: -0.015em;
529 margin: 0;
530 color: var(--text-strong);
531 }
532 .profile-section-count {
533 font-family: var(--font-mono);
534 font-size: 12px;
535 color: var(--text-muted);
536 background: var(--bg-secondary);
537 border: 1px solid var(--border);
538 border-radius: 999px;
539 padding: 2px 10px;
540 font-variant-numeric: tabular-nums;
541 }
542 .profile-empty {
543 display: flex;
544 align-items: center;
545 justify-content: space-between;
546 gap: var(--space-3);
547 padding: var(--space-4) var(--space-5);
548 background: var(--bg-elevated);
549 border: 1px dashed var(--border);
550 border-radius: 12px;
551 }
552 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
553
554 /* ───────── tree (file browser) ───────── */
555 .tree-header {
556 margin-bottom: var(--space-3);
557 display: flex;
558 flex-direction: column;
559 gap: var(--space-2);
560 }
561 .tree-header-row {
562 display: flex;
563 align-items: center;
564 justify-content: space-between;
565 gap: var(--space-3);
566 flex-wrap: wrap;
567 }
568 .tree-header-stats {
569 display: flex;
570 align-items: center;
571 gap: var(--space-3);
572 font-size: 12px;
573 color: var(--text-muted);
574 }
575 .tree-stat strong {
576 color: var(--text-strong);
577 font-weight: 600;
578 font-variant-numeric: tabular-nums;
579 }
580 .tree-stat-link {
581 color: var(--text-muted);
582 padding: 4px 10px;
583 border-radius: 999px;
584 border: 1px solid var(--border);
585 background: var(--bg-elevated);
586 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
587 }
588 .tree-stat-link:hover {
589 color: var(--accent);
590 border-color: rgba(140,109,255,0.45);
591 text-decoration: none;
592 }
593 .tree-breadcrumb-row {
594 font-size: 13px;
595 }
596
597 /* ───────── blob (file viewer) ───────── */
598 .blob-toolbar {
599 margin-bottom: var(--space-3);
600 display: flex;
601 flex-direction: column;
602 gap: var(--space-2);
603 }
604 .blob-breadcrumb { font-size: 13px; }
605 .blob-card {
606 background: var(--bg-elevated);
607 border: 1px solid var(--border);
608 border-radius: 12px;
609 overflow: hidden;
610 }
611 .blob-header-polished {
612 display: flex;
613 align-items: center;
614 justify-content: space-between;
615 gap: var(--space-3);
616 padding: 10px 14px;
617 background: var(--bg-secondary);
618 border-bottom: 1px solid var(--border);
619 }
620 .blob-header-meta {
621 display: flex;
622 align-items: center;
623 gap: var(--space-2);
624 min-width: 0;
625 flex: 1;
626 }
627 .blob-header-icon { font-size: 14px; opacity: 0.85; }
628 .blob-header-name {
629 font-family: var(--font-mono);
630 font-size: 13px;
631 color: var(--text-strong);
632 font-weight: 600;
633 overflow: hidden;
634 text-overflow: ellipsis;
635 white-space: nowrap;
636 }
637 .blob-header-size {
638 font-family: var(--font-mono);
639 font-size: 11.5px;
640 color: var(--text-muted);
641 font-variant-numeric: tabular-nums;
642 border-left: 1px solid var(--border);
643 padding-left: var(--space-2);
644 margin-left: 2px;
645 }
646 .blob-header-actions {
647 display: flex;
648 gap: 6px;
649 flex-shrink: 0;
650 }
651 .blob-pill {
652 display: inline-flex;
653 align-items: center;
654 padding: 4px 12px;
655 font-size: 12px;
656 font-weight: 500;
657 color: var(--text);
658 background: var(--bg-elevated);
659 border: 1px solid var(--border);
660 border-radius: 999px;
661 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
662 }
663 .blob-pill:hover {
664 color: var(--accent);
665 border-color: rgba(140,109,255,0.45);
666 text-decoration: none;
667 background: rgba(140,109,255,0.06);
668 }
669 .blob-pill-accent {
670 color: var(--accent);
671 border-color: rgba(140,109,255,0.35);
672 background: rgba(140,109,255,0.08);
673 }
674 .blob-pill-accent:hover {
675 background: rgba(140,109,255,0.14);
676 }
677 .blob-binary {
678 padding: var(--space-5);
679 color: var(--text-muted);
680 text-align: center;
681 font-size: 13px;
682 background: var(--bg);
683 }
684
685 /* ───────── commits list ───────── */
686 .commits-hero {
687 position: relative;
688 margin-bottom: var(--space-4);
689 padding: var(--space-5) var(--space-6);
690 background: var(--bg-elevated);
691 border: 1px solid var(--border);
692 border-radius: 16px;
693 overflow: hidden;
694 }
695 .commits-hero-orb-wrap {
696 position: absolute;
697 inset: -25% -10% auto auto;
698 width: 320px;
699 height: 320px;
700 pointer-events: none;
701 z-index: 0;
702 }
703 .commits-hero-orb {
704 position: absolute;
705 inset: 0;
706 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
707 filter: blur(80px);
708 opacity: 0.7;
709 animation: cbHeroOrb 14s ease-in-out infinite;
710 }
711 .commits-hero-inner { position: relative; z-index: 1; }
712 .commits-eyebrow {
713 font-size: 12px;
714 font-family: var(--font-mono);
715 color: var(--text-muted);
716 letter-spacing: 0.1em;
717 text-transform: uppercase;
718 margin-bottom: var(--space-2);
719 }
720 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
721 .commits-title {
722 font-family: var(--font-display);
723 font-weight: 800;
724 letter-spacing: -0.025em;
725 font-size: clamp(22px, 3vw, 30px);
726 line-height: 1.15;
727 margin: 0 0 var(--space-2);
728 color: var(--text-strong);
729 }
730 .commits-branch {
731 font-family: var(--font-mono);
732 font-size: 0.7em;
733 color: var(--text);
734 background: var(--bg-secondary);
735 border: 1px solid var(--border);
736 border-radius: 6px;
737 padding: 2px 8px;
738 font-weight: 500;
739 vertical-align: middle;
740 }
741 .commits-sub {
742 font-size: 14px;
743 color: var(--text-muted);
744 margin: 0;
745 line-height: 1.55;
746 max-width: 640px;
747 }
398a10cClaude748 .commits-toolbar {
749 display: flex;
750 justify-content: space-between;
751 align-items: center;
752 flex-wrap: wrap;
753 gap: var(--space-2);
754 margin-bottom: var(--space-3);
755 }
756 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
757 .commits-toolbar-link {
758 display: inline-flex;
759 align-items: center;
760 gap: 6px;
761 padding: 6px 12px;
762 font-size: 12.5px;
763 font-weight: 500;
764 color: var(--text-muted);
765 background: rgba(255,255,255,0.025);
766 border: 1px solid var(--border);
767 border-radius: 8px;
768 text-decoration: none;
769 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
770 }
771 .commits-toolbar-link:hover {
772 border-color: var(--border-strong);
773 color: var(--text-strong);
774 background: rgba(255,255,255,0.04);
775 text-decoration: none;
776 }
efb11c5Claude777 .commits-list-wrap {
778 background: var(--bg-elevated);
779 border: 1px solid var(--border);
398a10cClaude780 border-radius: 14px;
efb11c5Claude781 overflow: hidden;
398a10cClaude782 position: relative;
783 }
784 .commits-list-wrap::before {
785 content: '';
786 position: absolute;
787 top: 0; left: 0; right: 0;
788 height: 2px;
789 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
790 opacity: 0.55;
791 pointer-events: none;
792 }
793 .commits-day-head {
794 display: flex;
795 align-items: center;
796 gap: 10px;
797 padding: 10px 18px;
798 font-size: 11.5px;
799 font-family: var(--font-mono);
800 text-transform: uppercase;
801 letter-spacing: 0.08em;
802 color: var(--text-muted);
803 background: var(--bg-secondary);
804 border-bottom: 1px solid var(--border);
805 }
806 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
807 .commits-day-head-dot {
808 width: 6px; height: 6px;
809 border-radius: 9999px;
810 background: linear-gradient(135deg, #8c6dff, #36c5d6);
811 }
812 .commits-row {
813 display: flex;
814 align-items: flex-start;
815 gap: 14px;
816 padding: 14px 18px;
817 border-bottom: 1px solid var(--border-subtle);
818 transition: background 120ms ease;
819 }
820 .commits-row:last-child { border-bottom: none; }
821 .commits-row:hover { background: rgba(255,255,255,0.022); }
822 .commits-avatar {
823 width: 34px; height: 34px;
824 border-radius: 9999px;
825 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
826 color: #fff;
827 display: inline-flex;
828 align-items: center;
829 justify-content: center;
830 font-family: var(--font-display);
831 font-weight: 700;
832 font-size: 13.5px;
833 flex-shrink: 0;
834 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
835 }
836 .commits-row-body { flex: 1; min-width: 0; }
837 .commits-row-msg {
838 display: flex;
839 align-items: center;
840 gap: 8px;
841 flex-wrap: wrap;
842 }
843 .commits-row-msg a {
844 font-family: var(--font-display);
845 font-weight: 600;
846 font-size: 14px;
847 color: var(--text-strong);
848 text-decoration: none;
849 letter-spacing: -0.005em;
850 }
851 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
852 .commits-row-verified {
853 font-size: 9.5px;
854 padding: 1px 7px;
855 border-radius: 9999px;
856 background: rgba(52,211,153,0.16);
857 color: #6ee7b7;
858 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
859 text-transform: uppercase;
860 letter-spacing: 0.06em;
861 font-weight: 700;
862 }
863 .commits-row-meta {
864 margin-top: 4px;
865 display: flex;
866 align-items: center;
867 gap: 8px;
868 flex-wrap: wrap;
869 font-size: 12.5px;
870 color: var(--text-muted);
871 }
872 .commits-row-meta strong { color: var(--text); font-weight: 600; }
873 .commits-row-meta .sep { opacity: 0.4; }
874 .commits-row-time {
875 font-variant-numeric: tabular-nums;
876 }
877 .commits-row-side {
878 display: flex;
879 align-items: center;
880 gap: 8px;
881 flex-shrink: 0;
882 }
883 .commits-row-sha {
884 display: inline-flex;
885 align-items: center;
886 padding: 4px 10px;
887 border-radius: 9999px;
888 font-family: var(--font-mono);
889 font-size: 11.5px;
890 font-weight: 600;
891 color: var(--text-strong);
892 background: rgba(255,255,255,0.04);
893 border: 1px solid var(--border);
894 text-decoration: none;
895 letter-spacing: 0.04em;
896 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
897 }
898 .commits-row-sha:hover {
899 border-color: rgba(140,109,255,0.55);
900 color: var(--accent);
901 background: rgba(140,109,255,0.08);
902 text-decoration: none;
903 }
904 .commits-row-copy {
905 display: inline-flex;
906 align-items: center;
907 justify-content: center;
908 width: 28px; height: 28px;
909 padding: 0;
910 border-radius: 8px;
911 background: transparent;
912 border: 1px solid var(--border);
913 color: var(--text-muted);
914 cursor: pointer;
915 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude916 }
398a10cClaude917 .commits-row-copy:hover {
918 border-color: var(--border-strong);
919 color: var(--text);
920 background: rgba(255,255,255,0.04);
921 }
922 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude923 /* Push Watch link — eye icon next to the SHA chip */
924 .commits-row-watch {
925 display: inline-flex;
926 align-items: center;
927 justify-content: center;
928 width: 28px;
929 height: 28px;
930 border-radius: 6px;
931 border: 1px solid var(--border);
932 color: var(--text-muted);
933 background: transparent;
934 cursor: pointer;
935 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
936 text-decoration: none !important;
937 }
938 .commits-row-watch:hover {
939 border-color: rgba(140,109,255,0.45);
940 color: var(--accent);
941 background: rgba(140,109,255,0.08);
942 text-decoration: none !important;
943 }
efb11c5Claude944 .commits-empty {
398a10cClaude945 position: relative;
946 overflow: hidden;
947 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude948 text-align: center;
398a10cClaude949 background: var(--bg-elevated);
950 border: 1px dashed var(--border-strong);
951 border-radius: 16px;
952 }
953 .commits-empty-orb {
954 position: absolute;
955 inset: -40% 30% auto 30%;
956 height: 280px;
957 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
958 filter: blur(70px);
959 opacity: 0.7;
960 pointer-events: none;
961 z-index: 0;
962 }
963 .commits-empty-inner { position: relative; z-index: 1; }
964 .commits-empty-icon {
965 width: 56px; height: 56px;
966 border-radius: 9999px;
967 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
968 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
969 display: inline-flex;
970 align-items: center;
971 justify-content: center;
972 color: #c4b5fd;
973 margin: 0 auto 14px;
974 }
975 .commits-empty-title {
976 font-family: var(--font-display);
977 font-size: 18px;
978 font-weight: 700;
979 margin: 0 0 6px;
980 color: var(--text-strong);
981 }
982 .commits-empty-sub {
983 margin: 0 auto 0;
984 font-size: 13.5px;
efb11c5Claude985 color: var(--text-muted);
398a10cClaude986 max-width: 420px;
987 line-height: 1.5;
988 }
989
990 /* ───────── branches list ───────── */
991 .branches-list {
efb11c5Claude992 background: var(--bg-elevated);
398a10cClaude993 border: 1px solid var(--border);
994 border-radius: 14px;
995 overflow: hidden;
996 position: relative;
997 }
998 .branches-list::before {
999 content: '';
1000 position: absolute;
1001 top: 0; left: 0; right: 0;
1002 height: 2px;
1003 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1004 opacity: 0.55;
1005 pointer-events: none;
1006 }
1007 .branches-row {
1008 display: flex;
1009 align-items: center;
1010 gap: var(--space-3);
1011 padding: 14px 18px;
1012 border-bottom: 1px solid var(--border-subtle);
1013 transition: background 120ms ease;
1014 flex-wrap: wrap;
1015 }
1016 .branches-row:last-child { border-bottom: none; }
1017 .branches-row:hover { background: rgba(255,255,255,0.022); }
1018 .branches-row-icon {
1019 width: 32px; height: 32px;
1020 border-radius: 8px;
1021 background: rgba(140,109,255,0.10);
1022 color: #c4b5fd;
1023 display: inline-flex;
1024 align-items: center;
1025 justify-content: center;
1026 flex-shrink: 0;
1027 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1028 }
1029 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1030 .branches-row-name {
1031 display: inline-flex;
1032 align-items: center;
1033 gap: 8px;
1034 flex-wrap: wrap;
1035 }
1036 .branches-row-name a {
1037 font-family: var(--font-mono);
1038 font-size: 13.5px;
1039 color: var(--text-strong);
1040 font-weight: 600;
1041 text-decoration: none;
1042 }
1043 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1044 .branches-row-default {
1045 font-size: 10px;
1046 padding: 2px 8px;
1047 border-radius: 9999px;
1048 background: rgba(140,109,255,0.14);
1049 color: #c4b5fd;
1050 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1051 text-transform: uppercase;
1052 letter-spacing: 0.08em;
1053 font-weight: 700;
1054 font-family: var(--font-mono);
1055 }
1056 .branches-row-meta {
1057 display: flex;
1058 align-items: center;
1059 gap: 8px;
1060 flex-wrap: wrap;
1061 font-size: 12.5px;
1062 color: var(--text-muted);
1063 font-variant-numeric: tabular-nums;
1064 }
1065 .branches-row-meta .sep { opacity: 0.4; }
1066 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1067 .branches-row-side {
1068 display: flex;
1069 align-items: center;
1070 gap: 10px;
1071 flex-shrink: 0;
1072 flex-wrap: wrap;
1073 }
1074 .branches-row-divergence {
1075 display: inline-flex;
1076 align-items: center;
1077 gap: 8px;
1078 font-family: var(--font-mono);
1079 font-size: 11.5px;
1080 color: var(--text-muted);
1081 padding: 4px 10px;
1082 border-radius: 9999px;
1083 background: rgba(255,255,255,0.035);
1084 border: 1px solid var(--border);
1085 font-variant-numeric: tabular-nums;
1086 }
1087 .branches-row-divergence .ahead { color: #6ee7b7; }
1088 .branches-row-divergence .behind { color: #fca5a5; }
1089 .branches-row-actions {
1090 display: flex;
1091 align-items: center;
1092 gap: 6px;
1093 }
1094 .branches-btn {
1095 display: inline-flex;
1096 align-items: center;
1097 gap: 5px;
1098 padding: 6px 12px;
1099 border-radius: 9999px;
1100 font-size: 12px;
1101 font-weight: 600;
1102 text-decoration: none;
1103 border: 1px solid var(--border);
1104 background: transparent;
1105 color: var(--text-muted);
1106 cursor: pointer;
1107 font: inherit;
1108 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1109 }
1110 .branches-btn:hover {
1111 border-color: var(--border-strong);
1112 color: var(--text);
1113 background: rgba(255,255,255,0.04);
1114 text-decoration: none;
1115 }
1116 .branches-btn-danger {
1117 color: #fca5a5;
1118 border-color: rgba(248,113,113,0.30);
1119 }
1120 .branches-btn-danger:hover {
1121 border-style: dashed;
1122 border-color: rgba(248,113,113,0.65);
1123 background: rgba(248,113,113,0.06);
1124 color: #fecaca;
1125 }
1126
1127 /* ───────── tags list ───────── */
1128 .tags-list {
1129 background: var(--bg-elevated);
1130 border: 1px solid var(--border);
1131 border-radius: 14px;
1132 overflow: hidden;
1133 position: relative;
1134 }
1135 .tags-list::before {
1136 content: '';
1137 position: absolute;
1138 top: 0; left: 0; right: 0;
1139 height: 2px;
1140 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1141 opacity: 0.55;
1142 pointer-events: none;
1143 }
1144 .tags-row {
1145 display: flex;
1146 align-items: center;
1147 gap: var(--space-3);
1148 padding: 14px 18px;
1149 border-bottom: 1px solid var(--border-subtle);
1150 transition: background 120ms ease;
1151 flex-wrap: wrap;
1152 }
1153 .tags-row:last-child { border-bottom: none; }
1154 .tags-row:hover { background: rgba(255,255,255,0.022); }
1155 .tags-row-icon {
1156 width: 32px; height: 32px;
1157 border-radius: 8px;
1158 background: rgba(54,197,214,0.10);
1159 color: #67e8f9;
1160 display: inline-flex;
1161 align-items: center;
1162 justify-content: center;
1163 flex-shrink: 0;
1164 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1165 }
1166 .tags-row-main { flex: 1; min-width: 240px; }
1167 .tags-row-name {
1168 display: inline-flex;
1169 align-items: center;
1170 gap: 8px;
1171 flex-wrap: wrap;
1172 }
1173 .tags-row-version {
1174 display: inline-flex;
1175 align-items: center;
1176 padding: 3px 11px;
1177 border-radius: 9999px;
1178 font-family: var(--font-mono);
1179 font-size: 13px;
1180 font-weight: 700;
1181 color: #67e8f9;
1182 background: rgba(54,197,214,0.12);
1183 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1184 letter-spacing: 0.01em;
1185 }
1186 .tags-row-meta {
1187 margin-top: 4px;
1188 display: flex;
1189 align-items: center;
1190 gap: 8px;
1191 flex-wrap: wrap;
1192 font-size: 12.5px;
1193 color: var(--text-muted);
1194 font-variant-numeric: tabular-nums;
1195 }
1196 .tags-row-meta .sep { opacity: 0.4; }
1197 .tags-row-sha {
1198 font-family: var(--font-mono);
1199 font-size: 11.5px;
1200 color: var(--text-strong);
1201 padding: 2px 8px;
1202 border-radius: 6px;
1203 background: rgba(255,255,255,0.04);
1204 border: 1px solid var(--border);
1205 text-decoration: none;
1206 letter-spacing: 0.04em;
1207 }
1208 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1209 .tags-row-side {
1210 display: flex;
1211 align-items: center;
1212 gap: 6px;
1213 flex-shrink: 0;
1214 flex-wrap: wrap;
1215 }
1216 .tags-row-link {
1217 display: inline-flex;
1218 align-items: center;
1219 gap: 5px;
1220 padding: 6px 12px;
1221 border-radius: 9999px;
1222 font-size: 12px;
1223 font-weight: 600;
1224 text-decoration: none;
1225 color: var(--text-muted);
1226 background: rgba(255,255,255,0.025);
1227 border: 1px solid var(--border);
1228 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1229 }
1230 .tags-row-link:hover {
1231 border-color: rgba(140,109,255,0.45);
1232 color: var(--text-strong);
1233 background: rgba(140,109,255,0.06);
1234 text-decoration: none;
efb11c5Claude1235 }
1236
1237 /* ───────── commit detail ───────── */
1238 .commit-detail-card {
1239 position: relative;
1240 margin-bottom: var(--space-5);
1241 padding: var(--space-5) var(--space-5);
1242 background: var(--bg-elevated);
1243 border: 1px solid var(--border);
1244 border-radius: 14px;
1245 overflow: hidden;
1246 }
1247 .commit-detail-eyebrow {
1248 display: flex;
1249 align-items: center;
1250 gap: var(--space-2);
1251 font-size: 12px;
1252 font-family: var(--font-mono);
1253 text-transform: uppercase;
1254 letter-spacing: 0.1em;
1255 color: var(--text-muted);
1256 margin-bottom: var(--space-2);
1257 }
1258 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1259 .commit-detail-sha-pill {
1260 font-family: var(--font-mono);
1261 font-size: 11.5px;
1262 color: var(--text-strong);
1263 background: var(--bg-secondary);
1264 border: 1px solid var(--border);
1265 border-radius: 999px;
1266 padding: 2px 10px;
1267 letter-spacing: 0.04em;
1268 }
1269 .commit-detail-verify {
1270 font-size: 10px;
1271 padding: 2px 8px;
1272 border-radius: 999px;
1273 text-transform: uppercase;
1274 letter-spacing: 0.06em;
1275 font-weight: 700;
1276 color: #fff;
1277 }
1278 .commit-detail-verify-ok {
1279 background: linear-gradient(135deg, #2ea043, #34d399);
1280 }
1281 .commit-detail-verify-warn {
1282 background: linear-gradient(135deg, #d29922, #f59e0b);
1283 }
1284 .commit-detail-title {
1285 font-family: var(--font-display);
1286 font-weight: 700;
1287 letter-spacing: -0.018em;
1288 font-size: clamp(20px, 2.5vw, 26px);
1289 line-height: 1.25;
1290 margin: 0 0 var(--space-2);
1291 color: var(--text-strong);
1292 }
1293 .commit-detail-body {
1294 white-space: pre-wrap;
1295 color: var(--text-muted);
1296 font-size: 14px;
1297 line-height: 1.55;
1298 margin: 0 0 var(--space-3);
1299 font-family: var(--font-mono);
1300 background: var(--bg);
1301 border: 1px solid var(--border);
1302 border-radius: 8px;
1303 padding: var(--space-3) var(--space-4);
1304 max-height: 280px;
1305 overflow: auto;
1306 }
1307 .commit-detail-meta {
1308 display: flex;
1309 flex-wrap: wrap;
1310 gap: var(--space-3);
1311 font-size: 13px;
1312 color: var(--text-muted);
1313 margin-bottom: var(--space-3);
1314 }
1315 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1316 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1317 .commit-detail-sha-link {
1318 font-family: var(--font-mono);
1319 font-size: 12.5px;
1320 color: var(--accent);
1321 background: rgba(140,109,255,0.08);
1322 border-radius: 6px;
1323 padding: 1px 6px;
1324 margin-left: 2px;
1325 }
1326 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1327 .commit-detail-stats {
1328 display: flex;
1329 flex-wrap: wrap;
1330 align-items: center;
1331 gap: var(--space-3);
1332 padding-top: var(--space-3);
1333 border-top: 1px solid var(--border);
1334 font-size: 13px;
1335 color: var(--text-muted);
1336 }
1337 .commit-detail-stat {
1338 display: inline-flex;
1339 align-items: center;
1340 gap: 4px;
1341 font-variant-numeric: tabular-nums;
1342 }
1343 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1344 .commit-detail-stat-add strong { color: #34d399; }
1345 .commit-detail-stat-del strong { color: #f87171; }
1346 .commit-detail-stat-mark {
1347 font-family: var(--font-mono);
1348 font-weight: 700;
1349 font-size: 14px;
1350 }
1351 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1352 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1353 .commit-detail-sha-full {
1354 margin-left: auto;
1355 font-family: var(--font-mono);
1356 font-size: 11.5px;
1357 color: var(--text-faint);
1358 letter-spacing: 0.02em;
1359 overflow: hidden;
1360 text-overflow: ellipsis;
1361 white-space: nowrap;
1362 max-width: 100%;
1363 }
1364 .commit-detail-checks {
1365 margin-top: var(--space-3);
1366 padding-top: var(--space-3);
1367 border-top: 1px solid var(--border);
1368 }
1369 .commit-detail-checks-head {
1370 display: flex;
1371 align-items: center;
1372 gap: var(--space-2);
1373 font-size: 13px;
1374 color: var(--text-muted);
1375 margin-bottom: 8px;
1376 }
1377 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1378 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1379 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1380 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1381 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1382 .commit-detail-check {
1383 font-size: 11px;
1384 padding: 2px 8px;
1385 border-radius: 999px;
1386 color: #fff;
1387 font-weight: 500;
1388 }
398a10cClaude1389 .commit-detail-check a { color: inherit; text-decoration: none; }
1390 .commit-detail-check-success { background: #2ea043; }
1391 .commit-detail-check-pending { background: #d29922; }
1392 .commit-detail-check-failure { background: #da3633; }
1393
1394 /* ───────── blame ───────── */
1395 .blame-head { margin-bottom: var(--space-5); }
1396 .blame-eyebrow {
1397 display: inline-flex;
1398 align-items: center;
1399 gap: 8px;
1400 text-transform: uppercase;
1401 font-family: var(--font-mono);
1402 font-size: 11px;
1403 letter-spacing: 0.16em;
1404 color: var(--text-muted);
1405 font-weight: 600;
1406 margin-bottom: 10px;
1407 }
1408 .blame-eyebrow-dot {
1409 width: 8px; height: 8px;
1410 border-radius: 9999px;
1411 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1412 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1413 }
1414 .blame-title {
1415 font-family: var(--font-display);
1416 font-size: clamp(22px, 3vw, 30px);
1417 font-weight: 800;
1418 letter-spacing: -0.025em;
1419 line-height: 1.15;
1420 margin: 0 0 6px;
1421 color: var(--text-strong);
1422 }
1423 .blame-title code {
1424 font-family: var(--font-mono);
1425 font-size: 0.78em;
1426 color: var(--text-strong);
1427 font-weight: 700;
1428 }
1429 .blame-sub {
1430 margin: 0;
1431 font-size: 14px;
1432 color: var(--text-muted);
1433 line-height: 1.5;
1434 max-width: 700px;
1435 }
efb11c5Claude1436 .blame-toolbar {
398a10cClaude1437 display: flex;
1438 justify-content: space-between;
1439 align-items: center;
1440 gap: var(--space-3);
efb11c5Claude1441 margin-bottom: var(--space-3);
398a10cClaude1442 flex-wrap: wrap;
efb11c5Claude1443 font-size: 13px;
1444 }
398a10cClaude1445 .blame-toolbar-actions { display: flex; gap: 6px; }
1446 .blame-card {
1447 background: var(--bg-elevated);
1448 border: 1px solid var(--border);
1449 border-radius: 14px;
1450 overflow: hidden;
1451 position: relative;
1452 }
1453 .blame-card::before {
1454 content: '';
1455 position: absolute;
1456 top: 0; left: 0; right: 0;
1457 height: 2px;
1458 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1459 opacity: 0.55;
1460 pointer-events: none;
1461 }
efb11c5Claude1462 .blame-header {
1463 display: flex;
1464 align-items: center;
1465 justify-content: space-between;
1466 gap: var(--space-3);
1467 padding: 10px 14px;
1468 background: var(--bg-secondary);
1469 border-bottom: 1px solid var(--border);
1470 }
1471 .blame-header-meta {
1472 display: flex;
1473 align-items: center;
1474 gap: var(--space-2);
1475 min-width: 0;
1476 flex-wrap: wrap;
1477 }
1478 .blame-header-icon { color: var(--accent); font-size: 14px; }
1479 .blame-header-name {
1480 font-family: var(--font-mono);
1481 font-size: 13px;
1482 color: var(--text-strong);
1483 font-weight: 600;
1484 }
1485 .blame-header-tag {
1486 font-size: 10.5px;
1487 text-transform: uppercase;
1488 letter-spacing: 0.08em;
1489 font-family: var(--font-mono);
1490 background: rgba(140,109,255,0.12);
1491 color: var(--accent);
1492 border-radius: 999px;
1493 padding: 2px 8px;
1494 font-weight: 600;
1495 }
1496 .blame-header-stats {
1497 font-family: var(--font-mono);
1498 font-size: 11.5px;
1499 color: var(--text-muted);
1500 font-variant-numeric: tabular-nums;
1501 border-left: 1px solid var(--border);
1502 padding-left: var(--space-2);
1503 }
1504 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1505 .blame-table {
1506 width: 100%;
1507 border-collapse: collapse;
1508 font-family: var(--font-mono);
1509 font-size: 12.5px;
1510 line-height: 1.6;
1511 }
1512 .blame-table tr { border-bottom: 1px solid transparent; }
1513 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1514 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1515 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1516 .blame-gutter {
1517 width: 220px;
1518 min-width: 220px;
1519 padding: 0 12px;
1520 vertical-align: top;
1521 background: rgba(255,255,255,0.012);
1522 border-right: 1px solid var(--border-subtle);
1523 font-variant-numeric: tabular-nums;
1524 color: var(--text-muted);
1525 font-size: 11px;
1526 white-space: nowrap;
1527 overflow: hidden;
1528 text-overflow: ellipsis;
1529 padding-top: 2px;
1530 padding-bottom: 2px;
1531 }
1532 .blame-gutter-inner {
1533 display: inline-flex;
1534 align-items: center;
1535 gap: 7px;
1536 max-width: 100%;
1537 overflow: hidden;
1538 }
1539 .blame-gutter-sha {
1540 font-family: var(--font-mono);
1541 font-size: 10.5px;
1542 font-weight: 600;
1543 color: #c4b5fd;
1544 background: rgba(140,109,255,0.10);
1545 padding: 1px 7px;
1546 border-radius: 9999px;
1547 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1548 text-decoration: none;
1549 letter-spacing: 0.04em;
1550 flex-shrink: 0;
1551 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1552 }
1553 .blame-gutter-sha:hover {
1554 background: rgba(140,109,255,0.22);
1555 color: #ddd6fe;
1556 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1557 text-decoration: none;
1558 }
1559 .blame-gutter-author {
1560 color: var(--text);
1561 overflow: hidden;
1562 text-overflow: ellipsis;
1563 white-space: nowrap;
1564 font-size: 11px;
1565 font-family: var(--font-sans, inherit);
1566 }
1567 .blame-line-num {
1568 width: 1%;
1569 min-width: 50px;
1570 padding: 0 12px;
1571 text-align: right;
1572 color: var(--text-faint);
1573 user-select: none;
1574 border-right: 1px solid var(--border-subtle);
1575 font-variant-numeric: tabular-nums;
1576 }
1577 .blame-line-content {
1578 padding: 0 14px;
1579 white-space: pre;
1580 color: var(--text);
1581 transition: background 120ms ease;
1582 }
efb11c5Claude1583
1584 /* ───────── search ───────── */
1585 .search-hero {
1586 position: relative;
1587 margin-bottom: var(--space-4);
1588 padding: var(--space-5) var(--space-6);
1589 background: var(--bg-elevated);
1590 border: 1px solid var(--border);
1591 border-radius: 16px;
1592 overflow: hidden;
1593 }
1594 .search-eyebrow {
1595 font-size: 12px;
1596 font-family: var(--font-mono);
1597 color: var(--text-muted);
1598 letter-spacing: 0.1em;
1599 text-transform: uppercase;
1600 margin-bottom: var(--space-2);
1601 }
1602 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1603 .search-title {
1604 font-family: var(--font-display);
1605 font-weight: 800;
1606 letter-spacing: -0.025em;
1607 font-size: clamp(22px, 3vw, 30px);
1608 line-height: 1.15;
1609 margin: 0 0 var(--space-3);
1610 color: var(--text-strong);
1611 }
1612 .search-form {
1613 display: flex;
1614 gap: var(--space-2);
1615 align-items: stretch;
1616 }
1617 .search-input-wrap {
1618 position: relative;
1619 flex: 1;
1620 display: flex;
1621 align-items: center;
1622 }
1623 .search-input-icon {
1624 position: absolute;
1625 left: 12px;
1626 color: var(--text-muted);
1627 font-size: 15px;
1628 pointer-events: none;
1629 }
1630 .search-input {
1631 appearance: none;
1632 width: 100%;
1633 padding: 10px 12px 10px 34px;
1634 background: var(--bg);
1635 border: 1px solid var(--border);
1636 border-radius: 10px;
1637 color: var(--text-strong);
1638 font-size: 14px;
1639 font-family: inherit;
1640 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1641 }
1642 .search-input:focus {
1643 outline: none;
1644 border-color: var(--accent);
1645 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1646 }
1647 .search-submit { min-width: 96px; }
1648 .search-results-head {
1649 display: flex;
1650 align-items: baseline;
1651 gap: var(--space-2);
1652 margin-bottom: var(--space-3);
1653 font-size: 13px;
1654 color: var(--text-muted);
1655 }
1656 .search-results-count strong {
1657 color: var(--text-strong);
1658 font-weight: 600;
1659 font-variant-numeric: tabular-nums;
1660 }
1661 .search-results-q { color: var(--text-strong); font-weight: 600; }
1662 .search-results-head code {
1663 font-family: var(--font-mono);
1664 font-size: 12px;
1665 background: var(--bg-secondary);
1666 border: 1px solid var(--border);
1667 border-radius: 5px;
1668 padding: 1px 6px;
1669 color: var(--text);
1670 }
1671 .search-empty {
1672 padding: var(--space-5) var(--space-6);
1673 background: var(--bg-elevated);
1674 border: 1px dashed var(--border);
1675 border-radius: 12px;
1676 color: var(--text-muted);
1677 font-size: 14px;
1678 }
1679 .search-empty strong { color: var(--text-strong); }
1680 .search-results {
1681 display: flex;
1682 flex-direction: column;
1683 gap: var(--space-3);
1684 }
1685 .search-file-head {
1686 display: flex;
1687 align-items: center;
1688 justify-content: space-between;
1689 gap: var(--space-2);
1690 }
1691 .search-file-link {
1692 font-family: var(--font-mono);
1693 font-size: 13px;
1694 color: var(--text-strong);
1695 font-weight: 600;
1696 }
1697 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1698 .search-file-count {
1699 font-family: var(--font-mono);
1700 font-size: 11.5px;
1701 color: var(--text-muted);
1702 font-variant-numeric: tabular-nums;
1703 }
1704`;
1705
fc1817aClaude1706// Home page
06d5ffeClaude1707web.get("/", async (c) => {
1708 const user = c.get("user");
1709
1710 if (user) {
0316dbbClaude1711 return c.redirect("/dashboard");
06d5ffeClaude1712 }
1713
8e9f1d9Claude1714 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1715 let publicStats: PublicStats | null = null;
8e9f1d9Claude1716 try {
1717 const [repoRow] = await db
1718 .select({ n: sql<number>`count(*)::int` })
1719 .from(repositories)
1720 .where(eq(repositories.isPrivate, false));
1721 const [userRow] = await db
1722 .select({ n: sql<number>`count(*)::int` })
1723 .from(users);
1724 stats = {
1725 publicRepos: Number(repoRow?.n ?? 0),
1726 users: Number(userRow?.n ?? 0),
1727 };
1728 } catch {
1729 stats = undefined;
1730 }
1731
52ad8b1Claude1732 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1733 try {
1734 publicStats = await computePublicStats();
1735 } catch {
1736 publicStats = null;
1737 }
1738
534f04aClaude1739 // Block M1 — initial SSR snapshot for the live-now demo feed.
1740 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1741 // in try/catch so a freak module-level explosion can't take down /.
1742 let liveFeed: LandingLiveFeed | null = null;
1743 try {
1744 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1745 listQueuedAiBuildIssues(3),
1746 listRecentAutoMerges(3, 24),
1747 listRecentAiReviews(3, 24),
1748 countAiReviewsSince(24),
1749 listDemoActivityFeed(10),
1750 ]);
1751 liveFeed = {
1752 queued: queued.map((i) => ({
1753 repo: i.repo,
1754 number: i.number,
1755 title: i.title,
1756 createdAt: i.createdAt,
1757 })),
1758 merges: merges.map((m) => ({
1759 repo: m.repo,
1760 number: m.number,
1761 title: m.title,
1762 mergedAt: m.mergedAt,
1763 })),
1764 reviews: reviewList.map((r) => ({
1765 repo: r.repo,
1766 prNumber: r.prNumber,
1767 commentSnippet: r.commentSnippet,
1768 createdAt: r.createdAt,
1769 })),
1770 reviewCount,
1771 feed: feed.map((e) => ({
1772 kind: e.kind,
1773 repo: e.repo,
1774 ref: e.ref,
1775 at: e.at,
1776 })),
1777 };
1778 } catch {
1779 liveFeed = null;
1780 }
1781
29924bcClaude1782 // 2030 reboot — the public landing is a self-contained light marketing
1783 // document (its own shell + design system), rendered directly so it never
1784 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1785 // above for the legacy LandingPage and other surfaces; the new page uses the
1786 // headline counters from `stats`.
1787 void publicStats;
1788 void liveFeed;
1789 void LandingPage;
1790 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1791});
1792
06d5ffeClaude1793// New repository form
1794web.get("/new", requireAuth, (c) => {
1795 const user = c.get("user")!;
1796 const error = c.req.query("error");
1797
1798 return c.html(
1799 <Layout title="New repository" user={user}>
efb11c5Claude1800 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1801 <div class="new-repo-hero">
1802 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1803 <div class="new-repo-hero-orb" />
1804 </div>
1805 <div class="new-repo-hero-inner">
1806 <div class="new-repo-eyebrow">
1807 <strong>Create</strong> · {user.username}
1808 </div>
1809 <h1 class="new-repo-title">
1810 Spin up a <span class="gradient-text">repository</span>.
1811 </h1>
1812 <p class="new-repo-sub">
1813 Push your first commit, and Gluecron wires up gate checks, AI review,
1814 and auto-merge from the moment your branch lands.
1815 </p>
1816 </div>
1817 </div>
06d5ffeClaude1818 <div class="new-repo-form">
efb11c5Claude1819 {error && (
1820 <div class="new-repo-error" role="alert">
1821 {decodeURIComponent(error)}
1822 </div>
1823 )}
1824 <form method="post" action="/new" class="new-repo-form-grid">
1825 <div class="new-repo-row">
1826 <label class="new-repo-label">Owner</label>
1827 <input
1828 type="text"
1829 value={user.username}
1830 disabled
1831 aria-label="Owner"
1832 class="new-repo-input new-repo-input-disabled"
1833 />
06d5ffeClaude1834 </div>
efb11c5Claude1835 <div class="new-repo-row">
1836 <label class="new-repo-label" for="name">
1837 Repository name
1838 </label>
06d5ffeClaude1839 <input
1840 type="text"
1841 id="name"
1842 name="name"
1843 required
1844 pattern="^[a-zA-Z0-9._-]+$"
1845 placeholder="my-project"
1846 autocomplete="off"
efb11c5Claude1847 class="new-repo-input"
06d5ffeClaude1848 />
efb11c5Claude1849 <p class="new-repo-hint">
1850 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1851 <code>{user.username}/&lt;name&gt;</code>.
1852 </p>
06d5ffeClaude1853 </div>
efb11c5Claude1854 <div class="new-repo-row">
1855 <label class="new-repo-label" for="description">
1856 Description{" "}
1857 <span class="new-repo-label-optional">(optional)</span>
1858 </label>
06d5ffeClaude1859 <input
1860 type="text"
1861 id="description"
1862 name="description"
1863 placeholder="A short description of your repository"
efb11c5Claude1864 class="new-repo-input"
06d5ffeClaude1865 />
1866 </div>
efb11c5Claude1867 <div class="new-repo-row">
1868 <span class="new-repo-label">Visibility</span>
1869 <div class="new-repo-visibility">
1870 <label class="new-repo-vis-card">
1871 <input
1872 type="radio"
1873 name="visibility"
1874 value="public"
1875 checked
1876 class="new-repo-vis-radio"
1877 />
1878 <span class="new-repo-vis-body">
1879 <span class="new-repo-vis-label">Public</span>
1880 <span class="new-repo-vis-desc">
1881 Anyone can see this repository. You choose who can commit.
1882 </span>
1883 </span>
1884 </label>
1885 <label class="new-repo-vis-card">
1886 <input
1887 type="radio"
1888 name="visibility"
1889 value="private"
1890 class="new-repo-vis-radio"
1891 />
1892 <span class="new-repo-vis-body">
1893 <span class="new-repo-vis-label">Private</span>
1894 <span class="new-repo-vis-desc">
1895 Only you (and collaborators you invite) can see this
1896 repository.
1897 </span>
1898 </span>
1899 </label>
1900 </div>
1901 </div>
398a10cClaude1902 <div class="new-repo-row">
1903 <span class="new-repo-label">
1904 Starter content{" "}
1905 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1906 </span>
1907 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1908 <label class="new-repo-template-chip">
1909 <input type="radio" name="starter" value="empty" checked />
1910 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1911 Empty
1912 </label>
1913 <label class="new-repo-template-chip">
1914 <input type="radio" name="starter" value="readme" />
1915 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1916 README
1917 </label>
1918 <label class="new-repo-template-chip">
1919 <input type="radio" name="starter" value="readme-mit" />
1920 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1921 README + MIT
1922 </label>
1923 <label class="new-repo-template-chip">
1924 <input type="radio" name="starter" value="node" />
1925 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1926 Node + .gitignore
1927 </label>
1928 </div>
1929 <p class="new-repo-hint">
1930 Just a UI hint — push your own commits to fill the repo.
1931 </p>
1932 </div>
efb11c5Claude1933 <div class="new-repo-callout">
1934 <div class="new-repo-callout-eyebrow">AI-native by default</div>
1935 <p class="new-repo-callout-body">
1936 Every push is gate-checked and reviewed by Claude automatically.
1937 Label an issue <code>ai-build</code> and Gluecron will open the PR
1938 for you.
1939 </p>
1940 </div>
1941 <div class="new-repo-actions">
398a10cClaude1942 <button type="submit" class="btn new-repo-submit">
efb11c5Claude1943 Create repository
1944 </button>
1945 <a href="/dashboard" class="btn new-repo-cancel">
1946 Cancel
1947 </a>
06d5ffeClaude1948 </div>
1949 </form>
1950 </div>
1951 </Layout>
1952 );
1953});
1954
1955web.post("/new", requireAuth, async (c) => {
1956 const user = c.get("user")!;
1957 const body = await c.req.parseBody();
1958 const name = String(body.name || "").trim();
1959 const description = String(body.description || "").trim();
1960 const isPrivate = body.visibility === "private";
1961
1962 if (!name) {
1963 return c.redirect("/new?error=Repository+name+is+required");
1964 }
1965
c63b860Claude1966 // P4 — plan-quota gate. Fail-open inside the helper so a billing
1967 // outage never blocks repo creation.
1968 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
1969 const gate = await checkRepoCreateAllowed(user.id);
1970 if (!gate.ok) {
1971 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
1972 }
1973
06d5ffeClaude1974 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
1975 return c.redirect("/new?error=Invalid+repository+name");
1976 }
1977
1978 if (await repoExists(user.username, name)) {
1979 return c.redirect("/new?error=Repository+already+exists");
1980 }
1981
1982 const diskPath = await initBareRepo(user.username, name);
1983
3ef4c9dClaude1984 const [newRepo] = await db
1985 .insert(repositories)
1986 .values({
1987 name,
1988 ownerId: user.id,
1989 description: description || null,
1990 isPrivate,
1991 diskPath,
1992 })
1993 .returning();
1994
1995 if (newRepo) {
1996 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
1997 await bootstrapRepository({
1998 repositoryId: newRepo.id,
1999 ownerUserId: user.id,
2000 defaultBranch: "main",
2001 });
2002 }
06d5ffeClaude2003
2004 return c.redirect(`/${user.username}/${name}`);
2005});
2006
2007// User profile
fc1817aClaude2008web.get("/:owner", async (c) => {
06d5ffeClaude2009 const { owner: ownerName } = c.req.param();
2010 const user = c.get("user");
2011
2012 // Avoid clashing with fixed routes
2013 if (
2014 ["login", "register", "logout", "new", "settings", "api"].includes(
2015 ownerName
2016 )
2017 ) {
2018 return c.notFound();
2019 }
2020
2021 let ownerUser;
2022 try {
2023 const [found] = await db
2024 .select()
2025 .from(users)
2026 .where(eq(users.username, ownerName))
2027 .limit(1);
2028 ownerUser = found;
2029 } catch {
2030 // DB not available — check if repos exist on disk
2031 ownerUser = null;
2032 }
2033
2034 // Even without DB, show repos if they exist on disk
2035 let repos: any[] = [];
2036 if (ownerUser) {
2037 const allRepos = await db
2038 .select()
2039 .from(repositories)
2040 .where(eq(repositories.ownerId, ownerUser.id))
2041 .orderBy(desc(repositories.updatedAt));
2042
2043 // Show public repos to everyone, private only to owner
2044 repos =
2045 user?.id === ownerUser.id
2046 ? allRepos
2047 : allRepos.filter((r) => !r.isPrivate);
2048 }
2049
7aa8b99Claude2050 // Block J4 — follow counts + viewer's follow state
2051 let followState = {
2052 followers: 0,
2053 following: 0,
2054 viewerFollows: false,
2055 };
2056 if (ownerUser) {
2057 try {
2058 const { followCounts, isFollowing } = await import("../lib/follows");
2059 const counts = await followCounts(ownerUser.id);
2060 followState.followers = counts.followers;
2061 followState.following = counts.following;
2062 if (user && user.id !== ownerUser.id) {
2063 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2064 }
2065 } catch {
2066 // DB hiccup — fall back to zeros.
2067 }
2068 }
2069 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2070
d412586Claude2071 // Block J5 — profile README. Render owner/owner repo's README on the
2072 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2073 // back to "<user>/.github" for org-style profile repos.
2074 let profileReadmeHtml: string | null = null;
2075 try {
2076 const candidates = [ownerName, ".github"];
2077 for (const rname of candidates) {
2078 if (await repoExists(ownerName, rname)) {
2079 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2080 const md = await getReadme(ownerName, rname, ref);
2081 if (md) {
2082 profileReadmeHtml = renderMarkdown(md);
2083 break;
2084 }
2085 }
2086 }
2087 } catch {
2088 profileReadmeHtml = null;
2089 }
2090
efb11c5Claude2091 const displayName = ownerUser?.displayName || ownerName;
2092 const memberSince = ownerUser?.createdAt
2093 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2094 : null;
2095
fc1817aClaude2096 return c.html(
06d5ffeClaude2097 <Layout title={ownerName} user={user}>
efb11c5Claude2098 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2099 <div class="profile-hero">
2100 <div class="profile-hero-orb-wrap" aria-hidden="true">
2101 <div class="profile-hero-orb" />
06d5ffeClaude2102 </div>
efb11c5Claude2103 <div class="profile-hero-inner">
2104 <div class="profile-hero-avatar" aria-hidden="true">
2105 {displayName[0].toUpperCase()}
2106 </div>
2107 <div class="profile-hero-text">
2108 <div class="profile-eyebrow">
2109 <strong>Developer</strong>
2110 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2111 <>
2112 {" "}· Joined{" "}
2113 {memberSince.toLocaleDateString("en-US", {
2114 month: "short",
2115 year: "numeric",
2116 })}
2117 </>
2118 )}
2119 </div>
2120 <h1 class="profile-name">
2121 <span class="gradient-text">{displayName}</span>
2122 </h1>
2123 <div class="profile-handle">@{ownerName}</div>
2124 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2125 <div class="profile-meta">
2126 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2127 <strong>{followState.followers}</strong> follower
2128 {followState.followers === 1 ? "" : "s"}
2129 </a>
2130 <a href={`/${ownerName}/following`} class="profile-meta-link">
2131 <strong>{followState.following}</strong> following
2132 </a>
2133 <a href={`/${ownerName}`} class="profile-meta-link">
2134 <strong>{repos.length}</strong> repo
2135 {repos.length === 1 ? "" : "s"}
2136 </a>
2137 {canFollow && (
2138 <form
2139 method="post"
2140 action={`/${ownerName}/${
2141 followState.viewerFollows ? "unfollow" : "follow"
2142 }`}
2143 class="profile-follow-form"
7aa8b99Claude2144 >
efb11c5Claude2145 <button
2146 type="submit"
2147 class={`btn ${
2148 followState.viewerFollows ? "" : "btn-primary"
2149 } btn-sm`}
2150 >
2151 {followState.viewerFollows ? "Unfollow" : "Follow"}
2152 </button>
2153 </form>
2154 )}
2155 </div>
7aa8b99Claude2156 </div>
06d5ffeClaude2157 </div>
2158 </div>
d412586Claude2159 {profileReadmeHtml && (
efb11c5Claude2160 <div class="profile-readme">
2161 <div class="profile-readme-head">
2162 <span class="profile-readme-icon">{"☰"}</span>
2163 <span>{ownerName}/{ownerName} README.md</span>
2164 </div>
2165 <div
2166 class="markdown-body profile-readme-body"
2167 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2168 />
2169 </div>
d412586Claude2170 )}
efb11c5Claude2171 <div class="profile-section-head">
2172 <h2 class="profile-section-title">Repositories</h2>
2173 <span class="profile-section-count">{repos.length}</span>
2174 </div>
06d5ffeClaude2175 {repos.length === 0 ? (
efb11c5Claude2176 <div class="profile-empty">
2177 <p class="profile-empty-text">
2178 No repositories yet
2179 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2180 </p>
2181 {user?.id === ownerUser?.id && (
2182 <a href="/new" class="btn btn-primary btn-sm">
2183 + Create your first
2184 </a>
2185 )}
2186 </div>
06d5ffeClaude2187 ) : (
2188 <div class="card-grid">
2189 {repos.map((repo) => (
2190 <RepoCard repo={repo} ownerName={ownerName} />
2191 ))}
2192 </div>
2193 )}
fc1817aClaude2194 </Layout>
2195 );
2196});
2197
06d5ffeClaude2198// Star/unstar a repo
2199web.post("/:owner/:repo/star", requireAuth, async (c) => {
2200 const { owner: ownerName, repo: repoName } = c.req.param();
2201 const user = c.get("user")!;
2202
2203 try {
2204 const [ownerUser] = await db
2205 .select()
2206 .from(users)
2207 .where(eq(users.username, ownerName))
2208 .limit(1);
2209 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2210
2211 const [repo] = await db
2212 .select()
2213 .from(repositories)
2214 .where(
2215 and(
2216 eq(repositories.ownerId, ownerUser.id),
2217 eq(repositories.name, repoName)
2218 )
2219 )
2220 .limit(1);
2221 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2222
2223 // Toggle star
2224 const [existing] = await db
2225 .select()
2226 .from(stars)
2227 .where(
2228 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2229 )
2230 .limit(1);
2231
2232 if (existing) {
2233 await db.delete(stars).where(eq(stars.id, existing.id));
2234 await db
2235 .update(repositories)
2236 .set({ starCount: Math.max(0, repo.starCount - 1) })
2237 .where(eq(repositories.id, repo.id));
2238 } else {
2239 await db.insert(stars).values({
2240 userId: user.id,
2241 repositoryId: repo.id,
2242 });
2243 await db
2244 .update(repositories)
2245 .set({ starCount: repo.starCount + 1 })
2246 .where(eq(repositories.id, repo.id));
2247 }
2248 } catch {
2249 // DB error — ignore
2250 }
2251
2252 return c.redirect(`/${ownerName}/${repoName}`);
2253});
2254
fc1817aClaude2255// Repository overview — file tree at HEAD
2256web.get("/:owner/:repo", async (c) => {
2257 const { owner, repo } = c.req.param();
06d5ffeClaude2258 const user = c.get("user");
fc1817aClaude2259
f1dc7c7Claude2260 // ── Loading skeleton (flag-gated) ──
2261 // Renders an SSR'd shell with file-tree + README placeholders when
2262 // `?skeleton=1` is set. Lets the user see the page structure before
2263 // git ops finish. Behind a flag for now so we never flash before the
2264 // real content lands.
2265 if (c.req.query("skeleton") === "1") {
2266 return c.html(
2267 <Layout title={`${owner}/${repo}`} user={user}>
2268 <style
2269 dangerouslySetInnerHTML={{
2270 __html: `
2271 .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; }
2272 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2273 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2274 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2275 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2276 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2277 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2278 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2279 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2280 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2281 .repo-skel-readme { height: 320px; border-radius: 12px; }
2282 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2283 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2284 `,
2285 }}
2286 />
2287 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2288 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2289 <div class="repo-skel-grid" aria-hidden="true">
2290 <div>
2291 <div class="repo-skel repo-skel-branch" />
2292 <div class="repo-skel-tree">
2293 {Array.from({ length: 8 }).map(() => (
2294 <div class="repo-skel repo-skel-tree-row" />
2295 ))}
2296 </div>
2297 <div class="repo-skel repo-skel-readme" />
2298 </div>
2299 <aside class="repo-skel-side">
2300 <div class="repo-skel repo-skel-side-card" />
2301 <div class="repo-skel repo-skel-side-card" />
2302 </aside>
2303 </div>
2304 <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">
2305 Loading {owner}/{repo}…
2306 </span>
2307 </Layout>
2308 );
2309 }
2310
8f50ed0Claude2311 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2312 trackByName(owner, repo, "view", {
2313 userId: user?.id || null,
2314 path: `/${owner}/${repo}`,
2315 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2316 userAgent: c.req.header("user-agent") || null,
2317 referer: c.req.header("referer") || null,
a28cedeClaude2318 }).catch((err) => {
2319 console.warn(
2320 `[web] view tracking failed for ${owner}/${repo}:`,
2321 err instanceof Error ? err.message : err
2322 );
2323 });
8f50ed0Claude2324
fc1817aClaude2325 if (!(await repoExists(owner, repo))) {
2326 return c.html(
06d5ffeClaude2327 <Layout title="Not Found" user={user}>
fc1817aClaude2328 <div class="empty-state">
2329 <h2>Repository not found</h2>
2330 <p>
2331 {owner}/{repo} does not exist.
2332 </p>
2333 </div>
2334 </Layout>,
2335 404
2336 );
2337 }
2338
05b973eClaude2339 // Parallelize all independent operations
2340 const [defaultBranch, branches] = await Promise.all([
2341 getDefaultBranch(owner, repo).then((b) => b || "main"),
2342 listBranches(owner, repo),
2343 ]);
2344 const [tree, starInfo] = await Promise.all([
2345 getTree(owner, repo, defaultBranch),
2346 // Star info fetched in parallel with tree
2347 (async () => {
2348 try {
2349 const [ownerUser] = await db
2350 .select()
2351 .from(users)
2352 .where(eq(users.username, owner))
2353 .limit(1);
71cd5ecClaude2354 if (!ownerUser)
2355 return {
2356 starCount: 0,
2357 starred: false,
2358 archived: false,
2359 isTemplate: false,
544d842Claude2360 forkCount: 0,
2361 description: null as string | null,
2362 pushedAt: null as Date | null,
2363 createdAt: null as Date | null,
cb5a796Claude2364 repoId: null as string | null,
2365 repoOwnerId: null as string | null,
71cd5ecClaude2366 };
05b973eClaude2367 const [repoRow] = await db
2368 .select()
2369 .from(repositories)
2370 .where(
2371 and(
2372 eq(repositories.ownerId, ownerUser.id),
2373 eq(repositories.name, repo)
2374 )
06d5ffeClaude2375 )
05b973eClaude2376 .limit(1);
71cd5ecClaude2377 if (!repoRow)
2378 return {
2379 starCount: 0,
2380 starred: false,
2381 archived: false,
2382 isTemplate: false,
544d842Claude2383 forkCount: 0,
2384 description: null as string | null,
2385 pushedAt: null as Date | null,
2386 createdAt: null as Date | null,
cb5a796Claude2387 repoId: null as string | null,
2388 repoOwnerId: null as string | null,
71cd5ecClaude2389 };
05b973eClaude2390 let starred = false;
06d5ffeClaude2391 if (user) {
2392 const [star] = await db
2393 .select()
2394 .from(stars)
2395 .where(
2396 and(
2397 eq(stars.userId, user.id),
2398 eq(stars.repositoryId, repoRow.id)
2399 )
2400 )
2401 .limit(1);
2402 starred = !!star;
2403 }
71cd5ecClaude2404 return {
2405 starCount: repoRow.starCount,
2406 starred,
2407 archived: repoRow.isArchived,
2408 isTemplate: repoRow.isTemplate,
544d842Claude2409 forkCount: repoRow.forkCount,
2410 description: repoRow.description as string | null,
2411 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2412 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2413 repoId: repoRow.id as string,
2414 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2415 };
05b973eClaude2416 } catch {
71cd5ecClaude2417 return {
2418 starCount: 0,
2419 starred: false,
2420 archived: false,
2421 isTemplate: false,
544d842Claude2422 forkCount: 0,
2423 description: null as string | null,
2424 pushedAt: null as Date | null,
2425 createdAt: null as Date | null,
cb5a796Claude2426 repoId: null as string | null,
2427 repoOwnerId: null as string | null,
71cd5ecClaude2428 };
06d5ffeClaude2429 }
05b973eClaude2430 })(),
2431 ]);
544d842Claude2432 const {
2433 starCount,
2434 starred,
2435 archived,
2436 isTemplate,
2437 forkCount,
2438 description,
2439 pushedAt,
2440 createdAt,
cb5a796Claude2441 repoId,
2442 repoOwnerId,
544d842Claude2443 } = starInfo;
2444
91a0204Claude2445 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2446 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2447 // and the badge simply doesn't render.
2448 let healthScore: HealthScore | null = null;
2449 if (repoId) {
2450 try {
2451 healthScore = await computeHealthScore(repoId);
2452 } catch {
2453 // swallow — badge is optional
2454 }
2455 }
2456
cb5a796Claude2457 // Pending-comments banner data (lazy + best-effort). Only the repo
2458 // owner sees the banner, so non-owner views skip the DB hit entirely.
2459 let repoHomePendingCount = 0;
2460 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2461 try {
2462 const { countPendingForRepo } = await import(
2463 "../lib/comment-moderation"
2464 );
2465 repoHomePendingCount = await countPendingForRepo(repoId);
2466 } catch {
2467 /* swallow */
2468 }
2469 }
2470
8c790e0Claude2471 // Push Watch discoverability — fetch the most recent push within 24 h.
2472 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2473 const recentPush: RecentPush | null = repoId
2474 ? await getRecentPush(repoId)
2475 : null;
2476
544d842Claude2477 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2478 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2479 const repoHomeCss = `
2480 .repo-home-hero {
2481 position: relative;
2482 margin-bottom: var(--space-5);
2483 padding: var(--space-5) var(--space-6);
2484 background: var(--bg-elevated);
2485 border: 1px solid var(--border);
2486 border-radius: 16px;
2487 overflow: hidden;
2488 }
2489 .repo-home-hero::before {
2490 content: '';
2491 position: absolute;
2492 top: 0; left: 0; right: 0;
2493 height: 2px;
2494 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2495 opacity: 0.7;
2496 pointer-events: none;
2497 }
2498 .repo-home-hero-orb-wrap {
2499 position: absolute;
2500 inset: -25% -10% auto auto;
2501 width: 360px;
2502 height: 360px;
2503 pointer-events: none;
2504 z-index: 0;
2505 }
2506 .repo-home-hero-orb {
2507 position: absolute;
2508 inset: 0;
2509 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
2510 filter: blur(80px);
2511 opacity: 0.7;
2512 animation: repoHomeOrb 14s ease-in-out infinite;
2513 }
2514 @keyframes repoHomeOrb {
2515 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
2516 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
2517 }
2518 @media (prefers-reduced-motion: reduce) {
2519 .repo-home-hero-orb { animation: none; }
2520 }
2521 .repo-home-hero-inner {
2522 position: relative;
2523 z-index: 1;
2524 }
2525 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
2526 .repo-home-eyebrow {
2527 font-size: 12px;
2528 font-family: var(--font-mono);
2529 color: var(--text-muted);
2530 letter-spacing: 0.1em;
2531 text-transform: uppercase;
2532 margin-bottom: var(--space-2);
2533 }
2534 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
2535 .repo-home-description {
2536 font-size: 15px;
2537 line-height: 1.55;
2538 color: var(--text);
2539 margin: 0;
2540 max-width: 720px;
2541 }
2542 .repo-home-description-empty {
2543 font-size: 14px;
2544 color: var(--text-muted);
2545 font-style: italic;
2546 margin: 0;
2547 }
2548 .repo-home-stat-row {
2549 display: flex;
2550 flex-wrap: wrap;
2551 gap: var(--space-4);
2552 margin-top: var(--space-3);
2553 font-size: 13px;
2554 color: var(--text-muted);
2555 }
2556 .repo-home-stat {
2557 display: inline-flex;
2558 align-items: center;
2559 gap: 6px;
2560 }
2561 .repo-home-stat strong {
2562 color: var(--text-strong);
2563 font-weight: 600;
2564 font-variant-numeric: tabular-nums;
2565 }
2566 .repo-home-stat .repo-home-stat-icon {
2567 color: var(--text-faint);
2568 font-size: 14px;
2569 line-height: 1;
2570 }
2571 .repo-home-stat a {
2572 color: var(--text-muted);
2573 transition: color var(--t-fast) var(--ease);
2574 }
2575 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
2576
2577 /* Two-column layout: file tree + sidebar */
2578 .repo-home-grid {
2579 display: grid;
2580 grid-template-columns: minmax(0, 1fr) 280px;
2581 gap: var(--space-5);
2582 align-items: start;
2583 }
2584 @media (max-width: 960px) {
2585 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
2586 }
2587 .repo-home-main { min-width: 0; }
2588
2589 /* Sidebar card */
2590 .repo-home-side {
2591 display: flex;
2592 flex-direction: column;
2593 gap: var(--space-4);
2594 }
2595 .repo-home-side-card {
2596 background: var(--bg-elevated);
2597 border: 1px solid var(--border);
2598 border-radius: 12px;
2599 padding: var(--space-4);
2600 }
2601 .repo-home-side-title {
2602 font-size: 11px;
2603 font-family: var(--font-mono);
2604 letter-spacing: 0.12em;
2605 text-transform: uppercase;
2606 color: var(--text-muted);
2607 margin: 0 0 var(--space-3);
2608 font-weight: 600;
2609 }
2610 .repo-home-side-row {
2611 display: flex;
2612 justify-content: space-between;
2613 align-items: center;
2614 gap: var(--space-2);
2615 font-size: 13px;
2616 padding: 6px 0;
2617 border-top: 1px solid var(--border);
2618 }
2619 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
2620 .repo-home-side-key {
2621 color: var(--text-muted);
2622 display: inline-flex;
2623 align-items: center;
2624 gap: 6px;
2625 }
2626 .repo-home-side-val {
2627 color: var(--text-strong);
2628 font-weight: 500;
2629 font-variant-numeric: tabular-nums;
2630 max-width: 60%;
2631 text-align: right;
2632 overflow: hidden;
2633 text-overflow: ellipsis;
2634 white-space: nowrap;
2635 }
2636 .repo-home-side-val a { color: var(--text-strong); }
2637 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
2638
2639 /* Clone / Code tabs */
2640 .repo-home-clone {
2641 background: var(--bg-elevated);
2642 border: 1px solid var(--border);
2643 border-radius: 12px;
2644 overflow: hidden;
2645 }
2646 .repo-home-clone-tabs {
2647 display: flex;
2648 gap: 0;
2649 background: var(--bg-secondary);
2650 border-bottom: 1px solid var(--border);
2651 padding: 0 var(--space-2);
2652 }
2653 .repo-home-clone-tab {
2654 appearance: none;
2655 background: transparent;
2656 border: 0;
2657 border-bottom: 2px solid transparent;
2658 padding: 9px 12px;
2659 font-size: 12px;
2660 font-weight: 500;
2661 color: var(--text-muted);
2662 cursor: pointer;
2663 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2664 font-family: inherit;
2665 margin-bottom: -1px;
2666 }
2667 .repo-home-clone-tab:hover { color: var(--text-strong); }
2668 .repo-home-clone-tab[aria-selected="true"] {
2669 color: var(--text-strong);
2670 border-bottom-color: var(--accent);
2671 }
2672 .repo-home-clone-body {
2673 padding: var(--space-3);
2674 display: flex;
2675 align-items: center;
2676 gap: var(--space-2);
2677 }
2678 .repo-home-clone-input {
2679 flex: 1;
2680 min-width: 0;
2681 font-family: var(--font-mono);
2682 font-size: 12px;
2683 background: var(--bg);
2684 border: 1px solid var(--border);
2685 border-radius: 8px;
2686 padding: 8px 10px;
2687 color: var(--text-strong);
2688 overflow: hidden;
2689 text-overflow: ellipsis;
2690 white-space: nowrap;
2691 }
2692 .repo-home-clone-copy {
2693 appearance: none;
2694 background: var(--bg-secondary);
2695 border: 1px solid var(--border);
2696 color: var(--text-strong);
2697 border-radius: 8px;
2698 padding: 8px 12px;
2699 font-size: 12px;
2700 font-weight: 600;
2701 cursor: pointer;
2702 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2703 font-family: inherit;
2704 }
2705 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
2706 .repo-home-clone-pane { display: none; }
2707 .repo-home-clone-pane[data-active="true"] { display: flex; }
2708
2709 /* README card */
2710 .repo-home-readme {
2711 margin-top: var(--space-5);
2712 background: var(--bg-elevated);
2713 border: 1px solid var(--border);
2714 border-radius: 12px;
2715 overflow: hidden;
2716 }
2717 .repo-home-readme-head {
2718 display: flex;
2719 align-items: center;
2720 gap: 8px;
2721 padding: 10px 16px;
2722 background: var(--bg-secondary);
2723 border-bottom: 1px solid var(--border);
2724 font-size: 13px;
2725 color: var(--text-muted);
2726 }
2727 .repo-home-readme-head .repo-home-readme-icon {
2728 color: var(--accent);
2729 font-size: 14px;
2730 }
2731 .repo-home-readme-body {
2732 padding: var(--space-5) var(--space-6);
2733 }
2734
2735 /* Empty-state CTA */
2736 .repo-home-empty {
2737 position: relative;
2738 margin-top: var(--space-4);
2739 background: var(--bg-elevated);
2740 border: 1px solid var(--border);
2741 border-radius: 16px;
2742 padding: var(--space-6);
2743 overflow: hidden;
2744 }
2745 .repo-home-empty::before {
2746 content: '';
2747 position: absolute;
2748 top: 0; left: 0; right: 0;
2749 height: 2px;
2750 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2751 opacity: 0.7;
2752 pointer-events: none;
2753 }
2754 .repo-home-empty-eyebrow {
2755 font-size: 12px;
2756 font-family: var(--font-mono);
2757 color: var(--accent);
2758 letter-spacing: 0.12em;
2759 text-transform: uppercase;
2760 margin-bottom: var(--space-2);
2761 font-weight: 600;
2762 }
2763 .repo-home-empty-title {
2764 font-family: var(--font-display);
2765 font-weight: 800;
2766 letter-spacing: -0.025em;
2767 font-size: clamp(22px, 3vw, 30px);
2768 line-height: 1.1;
2769 margin: 0 0 var(--space-2);
2770 color: var(--text-strong);
2771 }
2772 .repo-home-empty-sub {
2773 color: var(--text-muted);
2774 font-size: 14px;
2775 line-height: 1.55;
2776 max-width: 640px;
2777 margin: 0 0 var(--space-4);
2778 }
2779 .repo-home-empty-snippet {
2780 background: var(--bg);
2781 border: 1px solid var(--border);
2782 border-radius: 10px;
2783 padding: var(--space-3) var(--space-4);
2784 font-family: var(--font-mono);
2785 font-size: 12.5px;
2786 line-height: 1.7;
2787 color: var(--text-strong);
2788 overflow-x: auto;
2789 white-space: pre;
2790 margin: 0;
2791 }
2792 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2793 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2794
91a0204Claude2795 /* Health score badge */
2796 .repo-health-badge {
2797 display: inline-flex;
2798 align-items: center;
2799 gap: 5px;
2800 padding: 3px 10px;
2801 border-radius: 999px;
2802 font-size: 11.5px;
2803 font-weight: 700;
2804 font-family: var(--font-mono);
2805 text-decoration: none;
2806 border: 1px solid transparent;
2807 transition: filter 120ms ease, opacity 120ms ease;
2808 vertical-align: middle;
2809 }
2810 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
2811 .repo-health-badge-elite {
2812 background: rgba(52,211,153,0.15);
2813 color: #6ee7b7;
2814 border-color: rgba(52,211,153,0.30);
2815 }
2816 .repo-health-badge-strong {
2817 background: rgba(96,165,250,0.15);
2818 color: #93c5fd;
2819 border-color: rgba(96,165,250,0.30);
2820 }
2821 .repo-health-badge-improving {
2822 background: rgba(251,191,36,0.15);
2823 color: #fde68a;
2824 border-color: rgba(251,191,36,0.30);
2825 }
2826 .repo-health-badge-needs-attention {
2827 background: rgba(248,113,113,0.15);
2828 color: #fca5a5;
2829 border-color: rgba(248,113,113,0.30);
2830 }
2831
2832 /* Three-option empty-state panel */
2833 .repo-empty-options {
2834 display: grid;
2835 grid-template-columns: repeat(3, 1fr);
2836 gap: var(--space-3);
2837 margin-top: var(--space-5);
2838 }
2839 @media (max-width: 760px) {
2840 .repo-empty-options { grid-template-columns: 1fr; }
2841 }
2842 .repo-empty-option {
2843 position: relative;
2844 background: var(--bg-elevated);
2845 border: 1px solid var(--border);
2846 border-radius: 14px;
2847 padding: var(--space-4) var(--space-4);
2848 display: flex;
2849 flex-direction: column;
2850 gap: var(--space-2);
2851 overflow: hidden;
2852 transition: border-color 140ms ease, background 140ms ease;
2853 }
2854 .repo-empty-option:hover {
2855 border-color: rgba(140,109,255,0.45);
2856 background: rgba(140,109,255,0.04);
2857 }
2858 .repo-empty-option::before {
2859 content: '';
2860 position: absolute;
2861 top: 0; left: 0; right: 0;
2862 height: 2px;
2863 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2864 opacity: 0.55;
2865 pointer-events: none;
2866 }
2867 .repo-empty-option-label {
2868 font-size: 10px;
2869 font-family: var(--font-mono);
2870 font-weight: 700;
2871 letter-spacing: 0.12em;
2872 text-transform: uppercase;
2873 color: var(--accent);
2874 margin-bottom: 2px;
2875 }
2876 .repo-empty-option-title {
2877 font-family: var(--font-display);
2878 font-weight: 700;
2879 font-size: 16px;
2880 letter-spacing: -0.01em;
2881 color: var(--text-strong);
2882 margin: 0;
2883 }
2884 .repo-empty-option-sub {
2885 font-size: 13px;
2886 color: var(--text-muted);
2887 line-height: 1.5;
2888 margin: 0;
2889 flex: 1;
2890 }
2891 .repo-empty-option-snippet {
2892 background: var(--bg);
2893 border: 1px solid var(--border);
2894 border-radius: 8px;
2895 padding: var(--space-2) var(--space-3);
2896 font-family: var(--font-mono);
2897 font-size: 11.5px;
2898 line-height: 1.75;
2899 color: var(--text-strong);
2900 overflow-x: auto;
2901 white-space: pre;
2902 margin: var(--space-1) 0 0;
2903 }
2904 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
2905 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
2906 .repo-empty-option-cta {
2907 display: inline-flex;
2908 align-items: center;
2909 gap: 6px;
2910 margin-top: auto;
2911 padding-top: var(--space-2);
2912 font-size: 13px;
2913 font-weight: 600;
2914 color: var(--accent);
2915 text-decoration: none;
2916 transition: color 120ms ease;
2917 }
2918 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
2919
544d842Claude2920 @media (max-width: 720px) {
2921 .repo-home-hero { padding: var(--space-4) var(--space-4); }
2922 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
2923 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude2924 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
2925 .repo-home-side-val { max-width: 55%; }
2926 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
2927 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
2928 .repo-home-side-card { padding: var(--space-3); }
544d842Claude2929 }
2930 `;
2931 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude2932 // SSH URL — port-aware:
2933 // Standard port 22 → git@host:owner/repo.git
2934 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude2935 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
2936 try {
2937 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude2938 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
2939 ? host
2940 : "localhost";
2941 const sshPort = config.sshPort;
2942 if (sshPort === 22) {
2943 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
2944 } else {
2945 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude2946 }
2947 } catch {
2948 // Fall through to default.
2949 }
2950 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
2951 const formatRelative = (date: Date | null): string => {
2952 if (!date) return "never";
2953 const ms = Date.now() - date.getTime();
2954 const s = Math.max(0, Math.round(ms / 1000));
2955 if (s < 60) return "just now";
2956 const m = Math.round(s / 60);
2957 if (m < 60) return `${m} min ago`;
2958 const h = Math.round(m / 60);
2959 if (h < 24) return `${h}h ago`;
2960 const d = Math.round(h / 24);
2961 if (d < 30) return `${d}d ago`;
2962 const mo = Math.round(d / 30);
2963 if (mo < 12) return `${mo}mo ago`;
2964 const y = Math.round(d / 365);
2965 return `${y}y ago`;
2966 };
06d5ffeClaude2967
fc1817aClaude2968 if (tree.length === 0) {
5acce80Claude2969 const repoOgDesc = description
2970 ? `${owner}/${repo} on Gluecron — ${description}`
2971 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude2972 return c.html(
5acce80Claude2973 <Layout
2974 title={`${owner}/${repo}`}
2975 user={user}
2976 description={repoOgDesc}
2977 ogTitle={`${owner}/${repo} — Gluecron`}
2978 ogDescription={repoOgDesc}
2979 twitterCard="summary"
2980 >
544d842Claude2981 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude2982 <style dangerouslySetInnerHTML={{ __html: `
2983 .empty-options-grid {
2984 display: grid;
2985 grid-template-columns: repeat(3, 1fr);
2986 gap: var(--space-4);
2987 margin-top: var(--space-4);
2988 }
2989 @media (max-width: 800px) {
2990 .empty-options-grid { grid-template-columns: 1fr; }
2991 }
2992 .empty-option-card {
2993 position: relative;
2994 background: var(--bg-elevated);
2995 border: 1px solid var(--border);
2996 border-radius: 14px;
2997 padding: var(--space-5);
2998 display: flex;
2999 flex-direction: column;
3000 gap: var(--space-3);
3001 overflow: hidden;
3002 transition: border-color 140ms ease, box-shadow 140ms ease;
3003 }
3004 .empty-option-card:hover {
3005 border-color: rgba(140,109,255,0.45);
3006 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3007 }
3008 .empty-option-card::before {
3009 content: '';
3010 position: absolute;
3011 top: 0; left: 0; right: 0;
3012 height: 2px;
3013 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3014 opacity: 0.55;
3015 pointer-events: none;
3016 }
3017 .empty-option-label {
3018 font-size: 10.5px;
3019 font-family: var(--font-mono);
3020 text-transform: uppercase;
3021 letter-spacing: 0.14em;
3022 color: var(--accent);
3023 font-weight: 700;
3024 }
3025 .empty-option-title {
3026 font-family: var(--font-display);
3027 font-weight: 700;
3028 font-size: 17px;
3029 letter-spacing: -0.01em;
3030 color: var(--text-strong);
3031 margin: 0;
3032 line-height: 1.25;
3033 }
3034 .empty-option-body {
3035 font-size: 13px;
3036 color: var(--text-muted);
3037 line-height: 1.55;
3038 margin: 0;
3039 flex: 1;
3040 }
3041 .empty-option-snippet {
3042 background: var(--bg);
3043 border: 1px solid var(--border);
3044 border-radius: 8px;
3045 padding: var(--space-3) var(--space-4);
3046 font-family: var(--font-mono);
3047 font-size: 11.5px;
3048 line-height: 1.75;
3049 color: var(--text-strong);
3050 overflow-x: auto;
3051 white-space: pre;
3052 margin: 0;
3053 }
3054 .empty-option-snippet .ec { color: var(--text-faint); }
3055 .empty-option-snippet .em { color: var(--accent); }
3056 .empty-option-cta {
3057 display: inline-flex;
3058 align-items: center;
3059 gap: 6px;
3060 padding: 8px 16px;
3061 font-size: 13px;
3062 font-weight: 600;
3063 color: var(--text-strong);
3064 background: var(--bg-secondary);
3065 border: 1px solid var(--border);
3066 border-radius: 9999px;
3067 text-decoration: none;
3068 align-self: flex-start;
3069 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3070 }
3071 .empty-option-cta:hover {
3072 border-color: rgba(140,109,255,0.55);
3073 background: rgba(140,109,255,0.08);
3074 color: var(--accent);
3075 text-decoration: none;
3076 }
3077 .empty-option-cta-accent {
3078 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3079 border-color: transparent;
3080 color: #fff;
3081 }
3082 .empty-option-cta-accent:hover {
3083 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3084 border-color: transparent;
3085 color: #fff;
3086 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3087 }
3088 ` }} />
544d842Claude3089 <div class="repo-home-hero">
3090 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3091 <div class="repo-home-hero-orb" />
3092 </div>
3093 <div class="repo-home-hero-inner">
3094 <div class="repo-home-eyebrow">
3095 <strong>Repository</strong> · {owner}
3096 </div>
91a0204Claude3097 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3098 <RepoHeader
3099 owner={owner}
3100 repo={repo}
3101 starCount={starCount}
3102 starred={starred}
3103 forkCount={forkCount}
3104 currentUser={user?.username}
3105 archived={archived}
3106 isTemplate={isTemplate}
8c790e0Claude3107 recentPush={recentPush}
91a0204Claude3108 />
3109 {healthScore && (() => {
3110 const gradeLabel: Record<string, string> = {
3111 elite: "Elite", strong: "Strong",
3112 improving: "Improving", "needs-attention": "Needs Attention",
3113 };
3114 return (
3115 <a
3116 href={`/${owner}/${repo}/insights/health`}
3117 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3118 title={`Health score: ${healthScore!.total}/100`}
3119 >
3120 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3121 </a>
3122 );
3123 })()}
3124 </div>
544d842Claude3125 {description ? (
3126 <p class="repo-home-description">{description}</p>
3127 ) : (
3128 <p class="repo-home-description-empty">
3129 No description yet — push a README to tell the world what this
3130 ships.
3131 </p>
3132 )}
3133 </div>
3134 </div>
fc1817aClaude3135 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3136 <div style="margin-top:var(--space-4)">
3137 <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)">
3138 Getting started
3139 </div>
544d842Claude3140 <h2 class="repo-home-empty-title">
91a0204Claude3141 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3142 </h2>
3143 <p class="repo-home-empty-sub">
91a0204Claude3144 Choose how you want to kick things off. Every push wires up gate
3145 checks and AI review automatically.
544d842Claude3146 </p>
91a0204Claude3147 <div class="empty-options-grid">
3148 <div class="empty-option-card">
3149 <div class="empty-option-label">Option A</div>
3150 <h3 class="empty-option-title">Push your first commit</h3>
3151 <p class="empty-option-body">
3152 Wire up an existing project in seconds. Run these commands from
3153 your project directory.
3154 </p>
3155 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3156<span class="em">git remote add</span> origin {cloneHttpsUrl}
3157<span class="em">git branch</span> -M main
3158<span class="em">git push</span> -u origin main</pre>
3159 </div>
3160 <div class="empty-option-card">
3161 <div class="empty-option-label">Option B</div>
3162 <h3 class="empty-option-title">Import from GitHub</h3>
3163 <p class="empty-option-body">
3164 Mirror an existing GitHub repository here in one click. Gluecron
3165 syncs the full history and branches.
3166 </p>
3167 <a href="/import" class="empty-option-cta">
3168 Import repository
3169 </a>
3170 </div>
3171 <div class="empty-option-card">
3172 <div class="empty-option-label">Option C</div>
3173 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3174 <p class="empty-option-body">
3175 Let AI write your first feature. Describe what you want to build
3176 and Gluecron opens a pull request with the code.
3177 </p>
3178 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3179 Let AI write your first feature
3180 </a>
3181 </div>
3182 </div>
fc1817aClaude3183 </div>
3184 </Layout>
3185 );
3186 }
3187
3188 const readme = await getReadme(owner, repo, defaultBranch);
3189
544d842Claude3190 // Sidebar facts — derived from data we already have.
3191 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3192 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3193
5acce80Claude3194 const repoOgDesc = description
3195 ? `${owner}/${repo} on Gluecron — ${description}`
3196 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3197
fc1817aClaude3198 return c.html(
5acce80Claude3199 <Layout
3200 title={`${owner}/${repo}`}
3201 user={user}
3202 description={repoOgDesc}
3203 ogTitle={`${owner}/${repo} — Gluecron`}
3204 ogDescription={repoOgDesc}
3205 twitterCard="summary"
3206 >
544d842Claude3207 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3208 <div class="repo-home-hero">
3209 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3210 <div class="repo-home-hero-orb" />
3211 </div>
3212 <div class="repo-home-hero-inner">
3213 <div class="repo-home-eyebrow">
3214 <strong>Repository</strong> · {owner}
3215 </div>
91a0204Claude3216 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3217 <RepoHeader
3218 owner={owner}
3219 repo={repo}
3220 starCount={starCount}
3221 starred={starred}
3222 forkCount={forkCount}
3223 currentUser={user?.username}
3224 archived={archived}
3225 isTemplate={isTemplate}
8c790e0Claude3226 recentPush={recentPush}
91a0204Claude3227 />
3228 {healthScore && (() => {
3229 const gradeLabel: Record<string, string> = {
3230 elite: "Elite", strong: "Strong",
3231 improving: "Improving", "needs-attention": "Needs Attention",
3232 };
3233 return (
3234 <a
3235 href={`/${owner}/${repo}/insights/health`}
3236 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3237 title={`Health score: ${healthScore!.total}/100`}
3238 >
3239 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3240 </a>
3241 );
3242 })()}
3243 </div>
544d842Claude3244 {description ? (
3245 <p class="repo-home-description">{description}</p>
3246 ) : (
3247 <p class="repo-home-description-empty">
3248 No description yet.
3249 </p>
3250 )}
3251 <div class="repo-home-stat-row" aria-label="Repository stats">
3252 <span class="repo-home-stat" title="Default branch">
3253 <span class="repo-home-stat-icon">{"⎇"}</span>
3254 <strong>{defaultBranch}</strong>
3255 </span>
3256 <a
3257 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3258 class="repo-home-stat"
3259 title="Browse all branches"
3260 >
3261 <span class="repo-home-stat-icon">{"⊢"}</span>
3262 <strong>{branches.length}</strong>{" "}
3263 branch{branches.length === 1 ? "" : "es"}
3264 </a>
3265 <span class="repo-home-stat" title="Top-level entries">
3266 <span class="repo-home-stat-icon">{"■"}</span>
3267 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3268 {dirCount > 0 && (
3269 <>
3270 {" · "}
3271 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3272 </>
3273 )}
3274 </span>
3275 {pushedAt && (
3276 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3277 <span class="repo-home-stat-icon">{"↻"}</span>
3278 Updated <strong>{formatRelative(pushedAt)}</strong>
3279 </span>
3280 )}
3281 </div>
3282 </div>
3283 </div>
71cd5ecClaude3284 {isTemplate && user && user.username !== owner && (
3285 <div
3286 class="panel"
dc26881CC LABS App3287 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3288 >
3289 <div style="font-size:13px">
3290 <strong>Template repository.</strong> Create a new repository from
3291 this template's files.
3292 </div>
3293 <form
001af43Claude3294 method="post"
71cd5ecClaude3295 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3296 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3297 >
3298 <input
3299 type="text"
3300 name="name"
3301 placeholder="new-repo-name"
3302 required
2c3ba6ecopilot-swe-agent[bot]3303 aria-label="New repository name"
71cd5ecClaude3304 style="width:200px"
3305 />
3306 <button type="submit" class="btn btn-primary">
3307 Use this template
3308 </button>
3309 </form>
3310 </div>
3311 )}
fc1817aClaude3312 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3313 <RepoHomePendingBanner
3314 owner={owner}
3315 repo={repo}
3316 count={repoHomePendingCount}
3317 />
c6018a5Claude3318 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3319 row sits just below the nav as a slim CTA strip. Scoped under
3320 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3321 <style
3322 dangerouslySetInnerHTML={{
3323 __html: `
3324 .repo-ai-cta-row {
3325 display: flex;
3326 flex-wrap: wrap;
3327 gap: 8px;
3328 margin: 12px 0 18px;
3329 padding: 10px 14px;
3330 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3331 border: 1px solid var(--border);
3332 border-radius: 10px;
3333 position: relative;
3334 overflow: hidden;
3335 }
3336 .repo-ai-cta-row::before {
3337 content: '';
3338 position: absolute;
3339 top: 0; left: 0; right: 0;
3340 height: 1px;
3341 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3342 opacity: 0.7;
3343 pointer-events: none;
3344 }
3345 .repo-ai-cta-label {
3346 font-size: 11px;
3347 font-weight: 600;
3348 letter-spacing: 0.06em;
3349 text-transform: uppercase;
3350 color: var(--accent);
3351 align-self: center;
3352 padding-right: 8px;
3353 border-right: 1px solid var(--border);
3354 margin-right: 4px;
3355 }
3356 .repo-ai-cta {
3357 display: inline-flex;
3358 align-items: center;
3359 gap: 6px;
3360 padding: 5px 10px;
3361 font-size: 12.5px;
3362 font-weight: 500;
3363 color: var(--text);
3364 background: var(--bg);
3365 border: 1px solid var(--border);
3366 border-radius: 7px;
3367 text-decoration: none;
3368 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3369 }
3370 .repo-ai-cta:hover {
3371 border-color: rgba(140,109,255,0.45);
3372 color: var(--text-strong);
3373 background: rgba(140,109,255,0.06);
3374 text-decoration: none;
3375 }
3376 .repo-ai-cta-icon {
3377 opacity: 0.75;
3378 font-size: 12px;
3379 }
3380 @media (max-width: 640px) {
3381 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3382 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3383 }
3384 `,
3385 }}
3386 />
3387 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3388 <span class="repo-ai-cta-label">AI surfaces</span>
3389 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3390 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3391 Chat
3392 </a>
3393 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3394 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3395 Previews
3396 </a>
3397 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3398 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3399 Migrations
3400 </a>
3401 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3402 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3403 Semantic search
3404 </a>
3405 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3406 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3407 AI release notes
3408 </a>
3646bfeClaude3409 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3410 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3411 Dev environment
3412 </a>
c6018a5Claude3413 </nav>
544d842Claude3414 <div class="repo-home-grid">
3415 <div class="repo-home-main">
3416 <BranchSwitcher
3417 owner={owner}
3418 repo={repo}
3419 currentRef={defaultBranch}
3420 branches={branches}
3421 pathType="tree"
3422 />
3423 <FileTable
3424 entries={tree}
3425 owner={owner}
3426 repo={repo}
3427 ref={defaultBranch}
3428 path=""
3429 />
3430 {readme && (() => {
3431 const readmeHtml = renderMarkdown(readme);
3432 return (
3433 <div class="repo-home-readme">
3434 <div class="repo-home-readme-head">
3435 <span class="repo-home-readme-icon">{"☰"}</span>
3436 <span>README.md</span>
3437 </div>
3438 <style>{markdownCss}</style>
3439 <div class="markdown-body repo-home-readme-body">
3440 {html([readmeHtml] as unknown as TemplateStringsArray)}
3441 </div>
3442 </div>
3443 );
3444 })()}
3445 </div>
3446 <aside class="repo-home-side" aria-label="Repository details">
3447 <div class="repo-home-clone">
3448 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
3449 <button
3450 type="button"
3451 class="repo-home-clone-tab"
3452 role="tab"
3453 aria-selected="true"
3454 data-pane="https"
3455 data-repo-home-clone-tab
3456 >
3457 HTTPS
3458 </button>
3459 <button
3460 type="button"
3461 class="repo-home-clone-tab"
3462 role="tab"
3463 aria-selected="false"
3464 data-pane="ssh"
3465 data-repo-home-clone-tab
3466 >
3467 SSH
3468 </button>
3469 <button
3470 type="button"
3471 class="repo-home-clone-tab"
3472 role="tab"
3473 aria-selected="false"
3474 data-pane="cli"
3475 data-repo-home-clone-tab
3476 >
3477 CLI
3478 </button>
3479 </div>
3480 <div
3481 class="repo-home-clone-pane"
3482 data-pane="https"
3483 data-active="true"
3484 role="tabpanel"
3485 >
3486 <div class="repo-home-clone-body">
3487 <input
3488 class="repo-home-clone-input"
3489 type="text"
3490 value={cloneHttpsUrl}
3491 readonly
3492 aria-label="HTTPS clone URL"
3493 data-repo-home-clone-input
3494 />
3495 <button
3496 type="button"
3497 class="repo-home-clone-copy"
3498 data-repo-home-copy={cloneHttpsUrl}
3499 >
3500 Copy
3501 </button>
3502 </div>
3503 </div>
3504 <div
3505 class="repo-home-clone-pane"
3506 data-pane="ssh"
3507 data-active="false"
3508 role="tabpanel"
3509 >
3510 <div class="repo-home-clone-body">
3511 <input
3512 class="repo-home-clone-input"
3513 type="text"
3514 value={cloneSshUrl}
3515 readonly
3516 aria-label="SSH clone URL"
3517 data-repo-home-clone-input
3518 />
3519 <button
3520 type="button"
3521 class="repo-home-clone-copy"
3522 data-repo-home-copy={cloneSshUrl}
3523 >
3524 Copy
3525 </button>
3526 </div>
3527 </div>
3528 <div
3529 class="repo-home-clone-pane"
3530 data-pane="cli"
3531 data-active="false"
3532 role="tabpanel"
3533 >
3534 <div class="repo-home-clone-body">
3535 <input
3536 class="repo-home-clone-input"
3537 type="text"
3538 value={cloneCliCmd}
3539 readonly
3540 aria-label="Gluecron CLI clone command"
3541 data-repo-home-clone-input
3542 />
3543 <button
3544 type="button"
3545 class="repo-home-clone-copy"
3546 data-repo-home-copy={cloneCliCmd}
3547 >
3548 Copy
3549 </button>
3550 </div>
79136bbClaude3551 </div>
fc1817aClaude3552 </div>
544d842Claude3553 <div class="repo-home-side-card">
3554 <h3 class="repo-home-side-title">About</h3>
3555 <div class="repo-home-side-row">
3556 <span class="repo-home-side-key">Default branch</span>
3557 <span class="repo-home-side-val">{defaultBranch}</span>
3558 </div>
3559 <div class="repo-home-side-row">
3560 <span class="repo-home-side-key">Branches</span>
3561 <span class="repo-home-side-val">
3562 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
3563 {branches.length}
3564 </a>
3565 </span>
3566 </div>
3567 <div class="repo-home-side-row">
3568 <span class="repo-home-side-key">Stars</span>
3569 <span class="repo-home-side-val">{starCount}</span>
3570 </div>
3571 <div class="repo-home-side-row">
3572 <span class="repo-home-side-key">Forks</span>
3573 <span class="repo-home-side-val">{forkCount}</span>
3574 </div>
3575 {pushedAt && (
3576 <div class="repo-home-side-row">
3577 <span class="repo-home-side-key">Last push</span>
3578 <span class="repo-home-side-val">
3579 {formatRelative(pushedAt)}
3580 </span>
3581 </div>
3582 )}
3583 {createdAt && (
3584 <div class="repo-home-side-row">
3585 <span class="repo-home-side-key">Created</span>
3586 <span class="repo-home-side-val">
3587 {formatRelative(createdAt)}
3588 </span>
3589 </div>
3590 )}
3591 {(archived || isTemplate) && (
3592 <div class="repo-home-side-row">
3593 <span class="repo-home-side-key">State</span>
3594 <span class="repo-home-side-val">
3595 {archived ? "Archived" : "Template"}
3596 </span>
3597 </div>
3598 )}
3599 </div>
f1dc38bClaude3600 {/* Claude AI — quick-access card for authenticated users */}
3601 {user && (
3602 <div class="repo-home-side-card" style="margin-top:12px">
3603 <h3 class="repo-home-side-title" style="margin-bottom:10px">
3604 ✨ Claude AI
3605 </h3>
3606 <a
3607 href={`/${owner}/${repo}/claude`}
3608 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"
3609 >
3610 Claude Code sessions
3611 </a>
3612 <a
3613 href={`/${owner}/${repo}/ask`}
3614 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
3615 >
3616 Ask AI about this repo
3617 </a>
3618 </div>
3619 )}
544d842Claude3620 </aside>
3621 </div>
3622 <script
3623 dangerouslySetInnerHTML={{
3624 __html: `
3625 (function(){
3626 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
3627 tabs.forEach(function(tab){
3628 tab.addEventListener('click', function(){
3629 var target = tab.getAttribute('data-pane');
3630 tabs.forEach(function(t){
3631 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
3632 });
3633 var panes = document.querySelectorAll('.repo-home-clone-pane');
3634 panes.forEach(function(p){
3635 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
3636 });
3637 });
3638 });
3639 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
3640 copyBtns.forEach(function(btn){
3641 btn.addEventListener('click', function(){
3642 var text = btn.getAttribute('data-repo-home-copy') || '';
3643 var done = function(){
3644 var prev = btn.textContent;
3645 btn.textContent = 'Copied';
3646 setTimeout(function(){ btn.textContent = prev; }, 1200);
3647 };
3648 if (navigator.clipboard && navigator.clipboard.writeText) {
3649 navigator.clipboard.writeText(text).then(done, done);
3650 } else {
3651 var ta = document.createElement('textarea');
3652 ta.value = text;
3653 document.body.appendChild(ta);
3654 ta.select();
3655 try { document.execCommand('copy'); } catch (e) {}
3656 document.body.removeChild(ta);
3657 done();
3658 }
3659 });
3660 });
3661 })();
3662 `,
3663 }}
3664 />
fc1817aClaude3665 </Layout>
3666 );
3667});
3668
3669// Browse tree at ref/path
3670web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
3671 const { owner, repo } = c.req.param();
06d5ffeClaude3672 const user = c.get("user");
fc1817aClaude3673 const refAndPath = c.req.param("ref");
3674
3675 const branches = await listBranches(owner, repo);
3676 let ref = "";
3677 let treePath = "";
3678
3679 for (const branch of branches) {
3680 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
3681 ref = branch;
3682 treePath = refAndPath.slice(branch.length + 1);
3683 break;
3684 }
3685 }
3686
3687 if (!ref) {
3688 const slashIdx = refAndPath.indexOf("/");
3689 if (slashIdx === -1) {
3690 ref = refAndPath;
3691 } else {
3692 ref = refAndPath.slice(0, slashIdx);
3693 treePath = refAndPath.slice(slashIdx + 1);
3694 }
3695 }
3696
3697 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude3698 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3699 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude3700
3701 return c.html(
06d5ffeClaude3702 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude3703 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3704 <RepoHeader owner={owner} repo={repo} />
3705 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3706 <div class="tree-header">
3707 <div class="tree-header-row">
3708 <BranchSwitcher
3709 owner={owner}
3710 repo={repo}
3711 currentRef={ref}
3712 branches={branches}
3713 pathType="tree"
3714 subPath={treePath}
3715 />
3716 <div class="tree-header-stats">
3717 <span class="tree-stat" title="Entries in this directory">
3718 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3719 {dirCount > 0 && (
3720 <>
3721 {" · "}
3722 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3723 </>
3724 )}
3725 </span>
3726 <a
3727 href={`/${owner}/${repo}/search`}
3728 class="tree-stat tree-stat-link"
3729 title="Search code in this repository"
3730 >
3731 {"⌕"} Search
3732 </a>
3733 </div>
3734 </div>
3735 <div class="tree-breadcrumb-row">
3736 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
3737 </div>
3738 </div>
fc1817aClaude3739 <FileTable
3740 entries={tree}
3741 owner={owner}
3742 repo={repo}
3743 ref={ref}
3744 path={treePath}
3745 />
3746 </Layout>
3747 );
3748});
3749
06d5ffeClaude3750// View file blob with syntax highlighting
fc1817aClaude3751web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
3752 const { owner, repo } = c.req.param();
06d5ffeClaude3753 const user = c.get("user");
fc1817aClaude3754 const refAndPath = c.req.param("ref");
3755
3756 const branches = await listBranches(owner, repo);
3757 let ref = "";
3758 let filePath = "";
3759
3760 for (const branch of branches) {
3761 if (refAndPath.startsWith(branch + "/")) {
3762 ref = branch;
3763 filePath = refAndPath.slice(branch.length + 1);
3764 break;
3765 }
3766 }
3767
3768 if (!ref) {
3769 const slashIdx = refAndPath.indexOf("/");
3770 if (slashIdx === -1) return c.text("Not found", 404);
3771 ref = refAndPath.slice(0, slashIdx);
3772 filePath = refAndPath.slice(slashIdx + 1);
3773 }
3774
3775 const blob = await getBlob(owner, repo, ref, filePath);
3776 if (!blob) {
3777 return c.html(
06d5ffeClaude3778 <Layout title="Not Found" user={user}>
fc1817aClaude3779 <div class="empty-state">
3780 <h2>File not found</h2>
3781 </div>
3782 </Layout>,
3783 404
3784 );
3785 }
3786
06d5ffeClaude3787 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3788 const lineCount = blob.isBinary
3789 ? 0
3790 : (blob.content.endsWith("\n")
3791 ? blob.content.split("\n").length - 1
3792 : blob.content.split("\n").length);
3793 const formatBytes = (n: number): string => {
3794 if (n < 1024) return `${n} B`;
3795 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3796 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3797 };
fc1817aClaude3798
3799 return c.html(
06d5ffeClaude3800 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3801 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3802 <RepoHeader owner={owner} repo={repo} />
3803 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3804 <div class="blob-toolbar">
3805 <BranchSwitcher
3806 owner={owner}
3807 repo={repo}
3808 currentRef={ref}
3809 branches={branches}
3810 pathType="blob"
3811 subPath={filePath}
3812 />
3813 <div class="blob-breadcrumb">
3814 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3815 </div>
3816 </div>
3817 <div class="blob-view blob-card">
3818 <div class="blob-header blob-header-polished">
3819 <div class="blob-header-meta">
3820 <span class="blob-header-icon" aria-hidden="true">
3821 {"📄"}
3822 </span>
3823 <span class="blob-header-name">{fileName}</span>
3824 <span class="blob-header-size">
3825 {formatBytes(blob.size)}
3826 {!blob.isBinary && (
3827 <>
3828 {" · "}
3829 {lineCount} line{lineCount === 1 ? "" : "s"}
3830 </>
3831 )}
3832 </span>
3833 </div>
3834 <div class="blob-header-actions">
3835 <a
3836 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
3837 class="blob-pill"
3838 >
79136bbClaude3839 Raw
3840 </a>
efb11c5Claude3841 <a
3842 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
3843 class="blob-pill"
3844 >
79136bbClaude3845 Blame
3846 </a>
efb11c5Claude3847 <a
3848 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
3849 class="blob-pill"
3850 >
16b325cClaude3851 History
3852 </a>
0074234Claude3853 {user && (
efb11c5Claude3854 <a
3855 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
3856 class="blob-pill blob-pill-accent"
3857 >
0074234Claude3858 Edit
3859 </a>
3860 )}
efb11c5Claude3861 </div>
fc1817aClaude3862 </div>
3863 {blob.isBinary ? (
efb11c5Claude3864 <div class="blob-binary">
fc1817aClaude3865 Binary file not shown.
3866 </div>
06d5ffeClaude3867 ) : (() => {
3868 const { html: highlighted, language } = highlightCode(
3869 blob.content,
3870 fileName
3871 );
3872 if (language) {
3873 return (
3874 <HighlightedCode
3875 highlightedHtml={highlighted}
efb11c5Claude3876 lineCount={lineCount}
06d5ffeClaude3877 />
3878 );
3879 }
3880 const lines = blob.content.split("\n");
3881 if (lines[lines.length - 1] === "") lines.pop();
3882 return <PlainCode lines={lines} />;
3883 })()}
fc1817aClaude3884 </div>
3885 </Layout>
3886 );
3887});
3888
398a10cClaude3889// ─── Branches list ────────────────────────────────────────────────────────
3890// Lightweight `git for-each-ref` enrichment so each row shows last-commit
3891// author + relative time + ahead/behind vs the default branch. No DB. All
3892// data comes from git plumbing; failures degrade gracefully (counts omitted).
3893web.get("/:owner/:repo/branches", async (c) => {
3894 const { owner, repo } = c.req.param();
3895 const user = c.get("user");
3896
3897 if (!(await repoExists(owner, repo))) return c.notFound();
3898
3899 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3900 const branches = await listBranches(owner, repo);
3901 const repoDir = getRepoPath(owner, repo);
3902
3903 type BranchRow = {
3904 name: string;
3905 isDefault: boolean;
3906 sha: string;
3907 subject: string;
3908 author: string;
3909 date: string;
3910 ahead: number;
3911 behind: number;
3912 };
3913
3914 const runGit = async (args: string[]): Promise<string> => {
3915 try {
3916 const proc = Bun.spawn(["git", ...args], {
3917 cwd: repoDir,
3918 stdout: "pipe",
3919 stderr: "pipe",
3920 });
3921 const out = await new Response(proc.stdout).text();
3922 await proc.exited;
3923 return out;
3924 } catch {
3925 return "";
3926 }
3927 };
3928
3929 const meta = await runGit([
3930 "for-each-ref",
3931 "--sort=-committerdate",
3932 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
3933 "refs/heads/",
3934 ]);
3935 const metaByName: Record<
3936 string,
3937 { sha: string; subject: string; author: string; date: string }
3938 > = {};
3939 for (const line of meta.split("\n").filter(Boolean)) {
3940 const [name, sha, subject, author, date] = line.split("\0");
3941 metaByName[name] = { sha, subject, author, date };
3942 }
3943
3944 const branchOrder = [...branches].sort((a, b) => {
3945 if (a === defaultBranch) return -1;
3946 if (b === defaultBranch) return 1;
3947 const aDate = metaByName[a]?.date || "";
3948 const bDate = metaByName[b]?.date || "";
3949 return bDate.localeCompare(aDate);
3950 });
3951
3952 const rows: BranchRow[] = [];
3953 for (const name of branchOrder) {
3954 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
3955 let ahead = 0;
3956 let behind = 0;
3957 if (name !== defaultBranch && metaByName[defaultBranch]) {
3958 const out = await runGit([
3959 "rev-list",
3960 "--left-right",
3961 "--count",
3962 `${defaultBranch}...${name}`,
3963 ]);
3964 const parts = out.trim().split(/\s+/);
3965 if (parts.length === 2) {
3966 behind = parseInt(parts[0], 10) || 0;
3967 ahead = parseInt(parts[1], 10) || 0;
3968 }
3969 }
3970 rows.push({
3971 name,
3972 isDefault: name === defaultBranch,
3973 sha: m.sha,
3974 subject: m.subject,
3975 author: m.author,
3976 date: m.date,
3977 ahead,
3978 behind,
3979 });
3980 }
3981
3982 const relative = (iso: string): string => {
3983 if (!iso) return "—";
3984 const d = new Date(iso);
3985 if (Number.isNaN(d.getTime())) return "—";
3986 const diff = Date.now() - d.getTime();
3987 const m = Math.floor(diff / 60000);
3988 if (m < 1) return "just now";
3989 if (m < 60) return `${m} min ago`;
3990 const h = Math.floor(m / 60);
3991 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
3992 const dd = Math.floor(h / 24);
3993 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
3994 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3995 };
3996
3997 const success = c.req.query("success");
3998 const error = c.req.query("error");
3999
4000 return c.html(
4001 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4002 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4003 <RepoHeader owner={owner} repo={repo} />
4004 <RepoNav owner={owner} repo={repo} active="code" />
4005 <div
4006 class="branches-wrap"
eed4684Claude4007 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4008 >
4009 <header class="branches-head" style="margin-bottom:var(--space-5)">
4010 <div
4011 class="branches-eyebrow"
4012 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"
4013 >
4014 <span
4015 class="branches-eyebrow-dot"
4016 aria-hidden="true"
4017 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)"
4018 />
4019 Repository · Branches
4020 </div>
4021 <h1
4022 class="branches-title"
4023 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)"
4024 >
4025 <span class="gradient-text">{rows.length}</span>{" "}
4026 branch{rows.length === 1 ? "" : "es"}
4027 </h1>
4028 <p
4029 class="branches-sub"
4030 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4031 >
4032 All work-in-progress lines for{" "}
4033 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4034 counts are relative to{" "}
4035 <code style="font-size:12.5px">{defaultBranch}</code>.
4036 </p>
4037 </header>
4038
4039 {success && (
4040 <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">
4041 {decodeURIComponent(success)}
4042 </div>
4043 )}
4044 {error && (
4045 <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">
4046 {decodeURIComponent(error)}
4047 </div>
4048 )}
4049
4050 {rows.length === 0 ? (
4051 <div class="commits-empty">
4052 <div class="commits-empty-orb" aria-hidden="true" />
4053 <div class="commits-empty-inner">
4054 <div class="commits-empty-icon" aria-hidden="true">
4055 <svg
4056 width="22"
4057 height="22"
4058 viewBox="0 0 24 24"
4059 fill="none"
4060 stroke="currentColor"
4061 stroke-width="2"
4062 stroke-linecap="round"
4063 stroke-linejoin="round"
4064 >
4065 <line x1="6" y1="3" x2="6" y2="15" />
4066 <circle cx="18" cy="6" r="3" />
4067 <circle cx="6" cy="18" r="3" />
4068 <path d="M18 9a9 9 0 0 1-9 9" />
4069 </svg>
4070 </div>
4071 <h3 class="commits-empty-title">No branches yet</h3>
4072 <p class="commits-empty-sub">
4073 Push your first commit to create the default branch.
4074 </p>
4075 </div>
4076 </div>
4077 ) : (
4078 <div class="branches-list">
4079 {rows.map((r) => (
4080 <div class="branches-row">
4081 <div class="branches-row-icon" aria-hidden="true">
4082 <svg
4083 width="14"
4084 height="14"
4085 viewBox="0 0 24 24"
4086 fill="none"
4087 stroke="currentColor"
4088 stroke-width="2"
4089 stroke-linecap="round"
4090 stroke-linejoin="round"
4091 >
4092 <line x1="6" y1="3" x2="6" y2="15" />
4093 <circle cx="18" cy="6" r="3" />
4094 <circle cx="6" cy="18" r="3" />
4095 <path d="M18 9a9 9 0 0 1-9 9" />
4096 </svg>
4097 </div>
4098 <div class="branches-row-main">
4099 <div class="branches-row-name">
4100 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4101 {r.isDefault && (
4102 <span
4103 class="branches-row-default"
4104 title="Default branch"
4105 >
4106 Default
4107 </span>
4108 )}
4109 </div>
4110 <div class="branches-row-meta">
4111 {r.subject ? (
4112 <>
4113 <strong>{r.author || "—"}</strong>
4114 <span class="sep">·</span>
4115 <span
4116 title={r.date ? new Date(r.date).toISOString() : ""}
4117 >
4118 updated {relative(r.date)}
4119 </span>
4120 {r.sha && (
4121 <>
4122 <span class="sep">·</span>
4123 <a
4124 href={`/${owner}/${repo}/commit/${r.sha}`}
4125 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4126 >
4127 {r.sha.slice(0, 7)}
4128 </a>
4129 </>
4130 )}
4131 </>
4132 ) : (
4133 <span>No commit metadata</span>
4134 )}
4135 </div>
4136 </div>
4137 <div class="branches-row-side">
4138 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4139 <span
4140 class="branches-row-divergence"
4141 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4142 >
4143 <span class="ahead">{r.ahead} ahead</span>
4144 <span style="opacity:0.4">|</span>
4145 <span class="behind">{r.behind} behind</span>
4146 </span>
4147 )}
4148 <div class="branches-row-actions">
4149 <a
4150 href={`/${owner}/${repo}/commits/${r.name}`}
4151 class="branches-btn"
4152 title="View commits on this branch"
4153 >
4154 Commits
4155 </a>
4156 {!r.isDefault &&
4157 user &&
4158 user.username === owner && (
4159 <form
4160 method="post"
4161 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4162 style="margin:0"
4163 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4164 >
4165 <button
4166 type="submit"
4167 class="branches-btn branches-btn-danger"
4168 >
4169 Delete
4170 </button>
4171 </form>
4172 )}
4173 </div>
4174 </div>
4175 </div>
4176 ))}
4177 </div>
4178 )}
4179 </div>
4180 </Layout>
4181 );
4182});
4183
4184// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4185// that are not merged into the default branch — matches the explicit
4186// confirmation on the row's delete button.
4187web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4188 const { owner, repo } = c.req.param();
4189 const branchName = decodeURIComponent(c.req.param("name"));
4190 const user = c.get("user")!;
4191
4192 // Owner-only check (mirrors collaborators.tsx pattern).
4193 const [ownerRow] = await db
4194 .select()
4195 .from(users)
4196 .where(eq(users.username, owner))
4197 .limit(1);
4198 if (!ownerRow || ownerRow.id !== user.id) {
4199 return c.redirect(
4200 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4201 );
4202 }
4203
4204 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4205 if (branchName === defaultBranch) {
4206 return c.redirect(
4207 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4208 );
4209 }
4210
4211 const branches = await listBranches(owner, repo);
4212 if (!branches.includes(branchName)) {
4213 return c.redirect(
4214 `/${owner}/${repo}/branches?error=Branch+not+found`
4215 );
4216 }
4217
4218 try {
4219 const repoDir = getRepoPath(owner, repo);
4220 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4221 cwd: repoDir,
4222 stdout: "pipe",
4223 stderr: "pipe",
4224 });
4225 await proc.exited;
4226 if (proc.exitCode !== 0) {
4227 return c.redirect(
4228 `/${owner}/${repo}/branches?error=Delete+failed`
4229 );
4230 }
4231 } catch {
4232 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4233 }
4234
4235 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4236});
4237
4238// ─── Tags list ────────────────────────────────────────────────────────────
4239// Pulls from `listTags` (sorted newest first). For each tag we look up an
4240// associated release row so the "View release" CTA can deep-link directly
4241// without making the user hunt for it.
4242web.get("/:owner/:repo/tags", async (c) => {
4243 const { owner, repo } = c.req.param();
4244 const user = c.get("user");
4245
4246 if (!(await repoExists(owner, repo))) return c.notFound();
4247
4248 const tags = await listTags(owner, repo);
4249
4250 // Map tags -> releases. Best-effort; releases table may not exist in
4251 // every test setup, so any error falls through with an empty set.
4252 const tagsWithReleases = new Set<string>();
4253 try {
4254 const [ownerRow] = await db
4255 .select()
4256 .from(users)
4257 .where(eq(users.username, owner))
4258 .limit(1);
4259 if (ownerRow) {
4260 const [repoRow] = await db
4261 .select()
4262 .from(repositories)
4263 .where(
4264 and(
4265 eq(repositories.ownerId, ownerRow.id),
4266 eq(repositories.name, repo)
4267 )
4268 )
4269 .limit(1);
4270 if (repoRow) {
4271 // Raw SQL so we don't need to import the releases schema here.
4272 const result = await db.execute(
4273 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4274 );
4275 const rows: any[] = (result as any).rows || (result as any) || [];
4276 for (const row of rows) {
4277 const tag = row?.tag;
4278 if (typeof tag === "string") tagsWithReleases.add(tag);
4279 }
4280 }
4281 }
4282 } catch {
4283 // No releases table or DB error — leave set empty.
4284 }
4285
4286 const relative = (iso: string): string => {
4287 if (!iso) return "—";
4288 const d = new Date(iso);
4289 if (Number.isNaN(d.getTime())) return "—";
4290 const diff = Date.now() - d.getTime();
4291 const m = Math.floor(diff / 60000);
4292 if (m < 1) return "just now";
4293 if (m < 60) return `${m} min ago`;
4294 const h = Math.floor(m / 60);
4295 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4296 const dd = Math.floor(h / 24);
4297 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4298 return d.toLocaleDateString("en-US", {
4299 month: "short",
4300 day: "numeric",
4301 year: "numeric",
4302 });
4303 };
4304
4305 return c.html(
4306 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4307 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4308 <RepoHeader owner={owner} repo={repo} />
4309 <RepoNav owner={owner} repo={repo} active="code" />
4310 <div
4311 class="tags-wrap"
eed4684Claude4312 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4313 >
4314 <header class="tags-head" style="margin-bottom:var(--space-5)">
4315 <div
4316 class="tags-eyebrow"
4317 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"
4318 >
4319 <span
4320 class="tags-eyebrow-dot"
4321 aria-hidden="true"
4322 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)"
4323 />
4324 Repository · Tags
4325 </div>
4326 <h1
4327 class="tags-title"
4328 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)"
4329 >
4330 <span class="gradient-text">{tags.length}</span>{" "}
4331 tag{tags.length === 1 ? "" : "s"}
4332 </h1>
4333 <p
4334 class="tags-sub"
4335 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4336 >
4337 Named points in the history — typically releases, milestones,
4338 or shipped versions. Click a tag to browse the tree at that
4339 revision.
4340 </p>
4341 </header>
4342
4343 {tags.length === 0 ? (
4344 <div class="commits-empty">
4345 <div class="commits-empty-orb" aria-hidden="true" />
4346 <div class="commits-empty-inner">
4347 <div class="commits-empty-icon" aria-hidden="true">
4348 <svg
4349 width="22"
4350 height="22"
4351 viewBox="0 0 24 24"
4352 fill="none"
4353 stroke="currentColor"
4354 stroke-width="2"
4355 stroke-linecap="round"
4356 stroke-linejoin="round"
4357 >
4358 <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" />
4359 <line x1="7" y1="7" x2="7.01" y2="7" />
4360 </svg>
4361 </div>
4362 <h3 class="commits-empty-title">No tags yet</h3>
4363 <p class="commits-empty-sub">
4364 Tag a commit to mark a release or milestone. From the CLI:{" "}
4365 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4366 </p>
4367 </div>
4368 </div>
4369 ) : (
4370 <div class="tags-list">
4371 {tags.map((t) => {
4372 const hasRelease = tagsWithReleases.has(t.name);
4373 return (
4374 <div class="tags-row">
4375 <div class="tags-row-icon" aria-hidden="true">
4376 <svg
4377 width="14"
4378 height="14"
4379 viewBox="0 0 24 24"
4380 fill="none"
4381 stroke="currentColor"
4382 stroke-width="2"
4383 stroke-linecap="round"
4384 stroke-linejoin="round"
4385 >
4386 <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" />
4387 <line x1="7" y1="7" x2="7.01" y2="7" />
4388 </svg>
4389 </div>
4390 <div class="tags-row-main">
4391 <div class="tags-row-name">
4392 <a
4393 href={`/${owner}/${repo}/tree/${t.name}`}
4394 style="text-decoration:none"
4395 >
4396 <span class="tags-row-version">{t.name}</span>
4397 </a>
4398 </div>
4399 <div class="tags-row-meta">
4400 <span
4401 title={t.date ? new Date(t.date).toISOString() : ""}
4402 >
4403 Tagged {relative(t.date)}
4404 </span>
4405 <span class="sep">·</span>
4406 <a
4407 href={`/${owner}/${repo}/commit/${t.sha}`}
4408 class="tags-row-sha"
4409 title={t.sha}
4410 >
4411 {t.sha.slice(0, 7)}
4412 </a>
4413 </div>
4414 </div>
4415 <div class="tags-row-side">
4416 <a
4417 href={`/${owner}/${repo}/tree/${t.name}`}
4418 class="tags-row-link"
4419 >
4420 Browse files
4421 </a>
4422 {hasRelease && (
4423 <a
4424 href={`/${owner}/${repo}/releases/tag/${t.name}`}
4425 class="tags-row-link"
4426 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
4427 >
4428 View release
4429 </a>
4430 )}
4431 </div>
4432 </div>
4433 );
4434 })}
4435 </div>
4436 )}
4437 </div>
4438 </Layout>
4439 );
4440});
4441
fc1817aClaude4442// Commit log
4443web.get("/:owner/:repo/commits/:ref?", async (c) => {
4444 const { owner, repo } = c.req.param();
06d5ffeClaude4445 const user = c.get("user");
fc1817aClaude4446 const ref =
4447 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude4448 const branches = await listBranches(owner, repo);
fc1817aClaude4449
4450 const commits = await listCommits(owner, repo, ref, 50);
4451
3951454Claude4452 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude4453 // Also resolve repoId here so we can query the recent push for the
4454 // Push Watch live/watch indicator in the header.
3951454Claude4455 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude4456 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude4457 try {
4458 const [ownerRow] = await db
4459 .select()
4460 .from(users)
4461 .where(eq(users.username, owner))
4462 .limit(1);
4463 if (ownerRow) {
4464 const [repoRow] = await db
4465 .select()
4466 .from(repositories)
4467 .where(
4468 and(
4469 eq(repositories.ownerId, ownerRow.id),
4470 eq(repositories.name, repo)
4471 )
4472 )
4473 .limit(1);
8c790e0Claude4474 if (repoRow) {
4475 // Fetch verifications and recent push in parallel.
4476 const [verRows, rp] = await Promise.all([
4477 commits.length > 0
4478 ? db
4479 .select()
4480 .from(commitVerifications)
4481 .where(
4482 and(
4483 eq(commitVerifications.repositoryId, repoRow.id),
4484 inArray(
4485 commitVerifications.commitSha,
4486 commits.map((c) => c.sha)
4487 )
4488 )
4489 )
4490 : Promise.resolve([]),
4491 getRecentPush(repoRow.id),
4492 ]);
4493 for (const r of verRows) {
3951454Claude4494 verifications[r.commitSha] = {
4495 verified: r.verified,
4496 reason: r.reason,
4497 };
4498 }
8c790e0Claude4499 commitsPageRecentPush = rp;
3951454Claude4500 }
4501 }
4502 } catch {
4503 // DB unavailable — skip the badges gracefully.
4504 }
4505
fc1817aClaude4506 return c.html(
06d5ffeClaude4507 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude4508 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude4509 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude4510 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude4511 <div class="commits-hero">
4512 <div class="commits-hero-orb-wrap" aria-hidden="true">
4513 <div class="commits-hero-orb" />
fc1817aClaude4514 </div>
efb11c5Claude4515 <div class="commits-hero-inner">
4516 <div class="commits-eyebrow">
4517 <strong>History</strong> · {owner}/{repo}
4518 </div>
4519 <h1 class="commits-title">
4520 <span class="gradient-text">{commits.length}</span> recent commit
4521 {commits.length === 1 ? "" : "s"} on{" "}
4522 <span class="commits-branch">{ref}</span>
4523 </h1>
4524 <p class="commits-sub">
4525 Browse the project's history. Click any commit to see the full
4526 diff, AI review notes, and signature status.
4527 </p>
4528 </div>
4529 </div>
4530 <div class="commits-toolbar">
4531 <BranchSwitcher
3951454Claude4532 owner={owner}
4533 repo={repo}
efb11c5Claude4534 currentRef={ref}
4535 branches={branches}
4536 pathType="commits"
3951454Claude4537 />
398a10cClaude4538 <div class="commits-toolbar-actions">
4539 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
4540 {"⊢"} Branches
4541 </a>
4542 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
4543 {"#"} Tags
4544 </a>
4545 </div>
efb11c5Claude4546 </div>
4547 {commits.length === 0 ? (
4548 <div class="commits-empty">
398a10cClaude4549 <div class="commits-empty-orb" aria-hidden="true" />
4550 <div class="commits-empty-inner">
4551 <div class="commits-empty-icon" aria-hidden="true">
4552 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4553 <circle cx="12" cy="12" r="4" />
4554 <line x1="1.05" y1="12" x2="7" y2="12" />
4555 <line x1="17.01" y1="12" x2="22.96" y2="12" />
4556 </svg>
4557 </div>
4558 <h3 class="commits-empty-title">No commits yet</h3>
4559 <p class="commits-empty-sub">
4560 This branch is empty. Push your first commit, or use the
4561 web editor to create a file.
4562 </p>
4563 </div>
efb11c5Claude4564 </div>
4565 ) : (
4566 <div class="commits-list-wrap">
398a10cClaude4567 {(() => {
4568 // Group commits by day for the section headers.
4569 const dayLabel = (iso: string): string => {
4570 const d = new Date(iso);
4571 if (Number.isNaN(d.getTime())) return "Unknown";
4572 const today = new Date();
4573 const yesterday = new Date(today);
4574 yesterday.setDate(today.getDate() - 1);
4575 const sameDay = (a: Date, b: Date) =>
4576 a.getFullYear() === b.getFullYear() &&
4577 a.getMonth() === b.getMonth() &&
4578 a.getDate() === b.getDate();
4579 if (sameDay(d, today)) return "Today";
4580 if (sameDay(d, yesterday)) return "Yesterday";
4581 return d.toLocaleDateString("en-US", {
4582 month: "long",
4583 day: "numeric",
4584 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
4585 });
4586 };
4587 const relative = (iso: string): string => {
4588 const d = new Date(iso);
4589 if (Number.isNaN(d.getTime())) return "";
4590 const diff = Date.now() - d.getTime();
4591 const m = Math.floor(diff / 60000);
4592 if (m < 1) return "just now";
4593 if (m < 60) return `${m} min ago`;
4594 const h = Math.floor(m / 60);
4595 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4596 const dd = Math.floor(h / 24);
4597 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4598 return d.toLocaleDateString("en-US", {
4599 month: "short",
4600 day: "numeric",
4601 });
4602 };
4603 const initial = (name: string): string =>
4604 (name || "?").trim().charAt(0).toUpperCase() || "?";
4605 // Build groups preserving order.
4606 const groups: Array<{ label: string; items: typeof commits }> = [];
4607 let lastLabel = "";
4608 for (const cm of commits) {
4609 const label = dayLabel(cm.date);
4610 if (label !== lastLabel) {
4611 groups.push({ label, items: [] });
4612 lastLabel = label;
4613 }
4614 groups[groups.length - 1].items.push(cm);
4615 }
4616 return groups.map((g) => (
4617 <>
4618 <div class="commits-day-head">
4619 <span class="commits-day-head-dot" aria-hidden="true" />
4620 Commits on {g.label}
4621 </div>
4622 {g.items.map((cm) => {
4623 const v = verifications[cm.sha];
4624 return (
4625 <div class="commits-row">
4626 <div class="commits-avatar" aria-hidden="true">
4627 {initial(cm.author)}
4628 </div>
4629 <div class="commits-row-body">
4630 <div class="commits-row-msg">
4631 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
4632 {cm.message}
4633 </a>
4634 {v?.verified && (
4635 <span
4636 class="commits-row-verified"
4637 title="Signed with a registered key"
4638 >
4639 Verified
4640 </span>
4641 )}
4642 </div>
4643 <div class="commits-row-meta">
4644 <strong>{cm.author}</strong>
4645 <span class="sep">·</span>
4646 <span
4647 class="commits-row-time"
4648 title={new Date(cm.date).toISOString()}
4649 >
4650 committed {relative(cm.date)}
4651 </span>
4652 {cm.parentShas.length > 1 && (
4653 <>
4654 <span class="sep">·</span>
4655 <span>merge of {cm.parentShas.length} parents</span>
4656 </>
4657 )}
4658 </div>
4659 </div>
4660 <div class="commits-row-side">
4661 <a
4662 href={`/${owner}/${repo}/commit/${cm.sha}`}
4663 class="commits-row-sha"
4664 title={cm.sha}
4665 >
4666 {cm.sha.slice(0, 7)}
4667 </a>
8c790e0Claude4668 <a
4669 href={`/${owner}/${repo}/push/${cm.sha}`}
4670 class="commits-row-watch"
4671 title="Watch gate + deploy results for this push"
4672 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
4673 >
4674 <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">
4675 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
4676 <circle cx="12" cy="12" r="3" />
4677 </svg>
4678 </a>
398a10cClaude4679 <button
4680 type="button"
4681 class="commits-row-copy"
4682 data-copy-sha={cm.sha}
4683 title="Copy full SHA"
4684 aria-label={`Copy SHA ${cm.sha}`}
4685 >
4686 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4687 <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" />
4688 <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" />
4689 </svg>
4690 </button>
4691 </div>
4692 </div>
4693 );
4694 })}
4695 </>
4696 ));
4697 })()}
efb11c5Claude4698 </div>
fc1817aClaude4699 )}
398a10cClaude4700 <script
4701 dangerouslySetInnerHTML={{
4702 __html: `
4703 (function(){
4704 document.addEventListener('click', function(e){
4705 var t = e.target; if (!t) return;
4706 var btn = t.closest && t.closest('[data-copy-sha]');
4707 if (!btn) return;
4708 e.preventDefault();
4709 var sha = btn.getAttribute('data-copy-sha') || '';
4710 if (!navigator.clipboard) return;
4711 navigator.clipboard.writeText(sha).then(function(){
4712 btn.classList.add('is-copied');
4713 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
4714 }).catch(function(){});
4715 });
4716 })();
4717 `,
4718 }}
4719 />
fc1817aClaude4720 </Layout>
4721 );
4722});
4723
4724// Single commit with diff
4725web.get("/:owner/:repo/commit/:sha", async (c) => {
4726 const { owner, repo, sha } = c.req.param();
06d5ffeClaude4727 const user = c.get("user");
fc1817aClaude4728
05b973eClaude4729 // Fetch commit, full message, and diff in parallel
4730 const [commit, fullMessage, diffResult] = await Promise.all([
4731 getCommit(owner, repo, sha),
4732 getCommitFullMessage(owner, repo, sha),
4733 getDiff(owner, repo, sha),
4734 ]);
fc1817aClaude4735 if (!commit) {
4736 return c.html(
06d5ffeClaude4737 <Layout title="Not Found" user={user}>
fc1817aClaude4738 <div class="empty-state">
4739 <h2>Commit not found</h2>
4740 </div>
4741 </Layout>,
4742 404
4743 );
4744 }
4745
3951454Claude4746 // Block J3 — try to verify this commit's signature.
4747 let verification:
4748 | { verified: boolean; reason: string; signatureType: string | null }
4749 | null = null;
0cdfd89Claude4750 // Block J8 — external CI commit statuses rollup.
4751 let statusCombined:
4752 | {
4753 state: "pending" | "success" | "failure";
4754 total: number;
4755 contexts: Array<{
4756 context: string;
4757 state: string;
4758 description: string | null;
4759 targetUrl: string | null;
4760 }>;
4761 }
4762 | null = null;
3951454Claude4763 try {
4764 const [ownerRow] = await db
4765 .select()
4766 .from(users)
4767 .where(eq(users.username, owner))
4768 .limit(1);
4769 if (ownerRow) {
4770 const [repoRow] = await db
4771 .select()
4772 .from(repositories)
4773 .where(
4774 and(
4775 eq(repositories.ownerId, ownerRow.id),
4776 eq(repositories.name, repo)
4777 )
4778 )
4779 .limit(1);
4780 if (repoRow) {
4781 const { verifyCommit } = await import("../lib/signatures");
4782 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
4783 verification = {
4784 verified: v.verified,
4785 reason: v.reason,
4786 signatureType: v.signatureType,
4787 };
0cdfd89Claude4788 try {
4789 const { combinedStatus } = await import("../lib/commit-statuses");
4790 const combined = await combinedStatus(repoRow.id, commit.sha);
4791 if (combined.total > 0) {
4792 statusCombined = {
4793 state: combined.state as any,
4794 total: combined.total,
4795 contexts: combined.contexts.map((c) => ({
4796 context: c.context,
4797 state: c.state,
4798 description: c.description,
4799 targetUrl: c.targetUrl,
4800 })),
4801 };
4802 }
4803 } catch {
4804 statusCombined = null;
4805 }
3951454Claude4806 }
4807 }
4808 } catch {
4809 verification = null;
4810 }
4811
05b973eClaude4812 const { files, raw } = diffResult;
fc1817aClaude4813
efb11c5Claude4814 // Diff stats: count additions / deletions across all files for the
4815 // header summary bar. Computed here from the parsed diff so we don't
4816 // touch the DiffView component.
4817 let additions = 0;
4818 let deletions = 0;
4819 for (const f of files) {
4820 const hunks = (f as any).hunks as Array<any> | undefined;
4821 if (Array.isArray(hunks)) {
4822 for (const h of hunks) {
4823 const lines = (h?.lines || []) as Array<any>;
4824 for (const ln of lines) {
4825 const t = ln?.type || ln?.kind;
4826 if (t === "add" || t === "added" || t === "+") additions += 1;
4827 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
4828 deletions += 1;
4829 }
4830 }
4831 }
4832 }
4833 // Fall back: scan raw if file-level counting yielded zero (it's just a
4834 // header polish — never let a parsing miss break the page).
4835 if (additions === 0 && deletions === 0 && typeof raw === "string") {
4836 for (const line of raw.split("\n")) {
4837 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
4838 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
4839 }
4840 }
4841 const fileCount = files.length;
4842
fc1817aClaude4843 return c.html(
06d5ffeClaude4844 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude4845 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4846 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude4847 <div class="commit-detail-card">
4848 <div class="commit-detail-eyebrow">
4849 <strong>Commit</strong>
4850 <span class="commit-detail-sha-pill" title={commit.sha}>
4851 {commit.sha.slice(0, 7)}
4852 </span>
3951454Claude4853 {verification && verification.reason !== "unsigned" && (
4854 <span
efb11c5Claude4855 class={`commit-detail-verify ${
3951454Claude4856 verification.verified
efb11c5Claude4857 ? "commit-detail-verify-ok"
4858 : "commit-detail-verify-warn"
3951454Claude4859 }`}
4860 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
4861 >
4862 {verification.verified ? "Verified" : verification.reason}
4863 </span>
4864 )}
fc1817aClaude4865 </div>
efb11c5Claude4866 <h1 class="commit-detail-title">{commit.message}</h1>
4867 {fullMessage !== commit.message && (
4868 <pre class="commit-detail-body">{fullMessage}</pre>
4869 )}
4870 <div class="commit-detail-meta">
4871 <span class="commit-detail-author">
4872 <strong>{commit.author}</strong> committed on{" "}
4873 {new Date(commit.date).toLocaleDateString("en-US", {
4874 month: "long",
4875 day: "numeric",
4876 year: "numeric",
4877 })}
4878 </span>
fc1817aClaude4879 {commit.parentShas.length > 0 && (
efb11c5Claude4880 <span class="commit-detail-parents">
4881 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
4882 {commit.parentShas.map((p, idx) => (
4883 <>
4884 {idx > 0 && " "}
4885 <a
4886 href={`/${owner}/${repo}/commit/${p}`}
4887 class="commit-detail-sha-link"
4888 >
4889 {p.slice(0, 7)}
4890 </a>
4891 </>
fc1817aClaude4892 ))}
4893 </span>
4894 )}
4895 </div>
efb11c5Claude4896 <div class="commit-detail-stats">
4897 <span class="commit-detail-stat">
4898 <strong>{fileCount}</strong>{" "}
4899 file{fileCount === 1 ? "" : "s"} changed
4900 </span>
4901 <span class="commit-detail-stat commit-detail-stat-add">
4902 <span class="commit-detail-stat-mark">+</span>
4903 <strong>{additions}</strong>
4904 </span>
4905 <span class="commit-detail-stat commit-detail-stat-del">
4906 <span class="commit-detail-stat-mark">−</span>
4907 <strong>{deletions}</strong>
4908 </span>
4909 <span class="commit-detail-sha-full" title="Full SHA">
4910 {commit.sha}
4911 </span>
4912 </div>
0cdfd89Claude4913 {statusCombined && (
efb11c5Claude4914 <div class="commit-detail-checks">
4915 <div class="commit-detail-checks-head">
4916 <strong>Checks</strong>
4917 <span class="commit-detail-checks-summary">
4918 {statusCombined.total} total ·{" "}
4919 <span
4920 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
4921 >
4922 {statusCombined.state}
4923 </span>
0cdfd89Claude4924 </span>
efb11c5Claude4925 </div>
4926 <div class="commit-detail-check-row">
0cdfd89Claude4927 {statusCombined.contexts.map((cx) => (
4928 <span
efb11c5Claude4929 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude4930 title={cx.description || cx.context}
4931 >
4932 {cx.targetUrl ? (
efb11c5Claude4933 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude4934 {cx.context}: {cx.state}
4935 </a>
4936 ) : (
4937 <>
4938 {cx.context}: {cx.state}
4939 </>
4940 )}
4941 </span>
4942 ))}
4943 </div>
4944 </div>
4945 )}
fc1817aClaude4946 </div>
ea9ed4cClaude4947 <DiffView
4948 raw={raw}
4949 files={files}
4950 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
4951 />
fc1817aClaude4952 </Layout>
4953 );
4954});
4955
79136bbClaude4956// Raw file download
4957web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
4958 const { owner, repo } = c.req.param();
4959 const refAndPath = c.req.param("ref");
4960
4961 const branches = await listBranches(owner, repo);
4962 let ref = "";
4963 let filePath = "";
4964
4965 for (const branch of branches) {
4966 if (refAndPath.startsWith(branch + "/")) {
4967 ref = branch;
4968 filePath = refAndPath.slice(branch.length + 1);
4969 break;
4970 }
4971 }
4972
4973 if (!ref) {
4974 const slashIdx = refAndPath.indexOf("/");
4975 if (slashIdx === -1) return c.text("Not found", 404);
4976 ref = refAndPath.slice(0, slashIdx);
4977 filePath = refAndPath.slice(slashIdx + 1);
4978 }
4979
4980 const data = await getRawBlob(owner, repo, ref, filePath);
4981 if (!data) return c.text("Not found", 404);
4982
4983 const fileName = filePath.split("/").pop() || "file";
772a24fClaude4984 return new Response(data as BodyInit, {
79136bbClaude4985 headers: {
4986 "Content-Type": "application/octet-stream",
4987 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude4988 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude4989 },
4990 });
4991});
4992
4993// Blame view
4994web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
4995 const { owner, repo } = c.req.param();
4996 const user = c.get("user");
4997 const refAndPath = c.req.param("ref");
4998
4999 const branches = await listBranches(owner, repo);
5000 let ref = "";
5001 let filePath = "";
5002
5003 for (const branch of branches) {
5004 if (refAndPath.startsWith(branch + "/")) {
5005 ref = branch;
5006 filePath = refAndPath.slice(branch.length + 1);
5007 break;
5008 }
5009 }
5010
5011 if (!ref) {
5012 const slashIdx = refAndPath.indexOf("/");
5013 if (slashIdx === -1) return c.text("Not found", 404);
5014 ref = refAndPath.slice(0, slashIdx);
5015 filePath = refAndPath.slice(slashIdx + 1);
5016 }
5017
5018 const blameLines = await getBlame(owner, repo, ref, filePath);
5019 if (blameLines.length === 0) {
5020 return c.html(
5021 <Layout title="Not Found" user={user}>
5022 <div class="empty-state">
5023 <h2>File not found</h2>
5024 </div>
5025 </Layout>,
5026 404
5027 );
5028 }
5029
5030 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5031 // Unique contributors (by author) tracked once for the header chip.
5032 const blameAuthors = new Set<string>();
5033 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5034
5035 return c.html(
5036 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5037 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5038 <RepoHeader owner={owner} repo={repo} />
5039 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5040 <header class="blame-head">
5041 <div class="blame-eyebrow">
5042 <span class="blame-eyebrow-dot" aria-hidden="true" />
5043 Blame · Line-by-line history
5044 </div>
5045 <h1 class="blame-title">
5046 <code>{fileName}</code>
5047 </h1>
5048 <p class="blame-sub">
5049 Each line is annotated with the commit that last touched it.
5050 Click any SHA to jump to that commit and see the surrounding
5051 change.
5052 </p>
5053 </header>
efb11c5Claude5054 <div class="blame-toolbar">
5055 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5056 </div>
398a10cClaude5057 <div class="blame-card">
5058 <div class="blame-header">
efb11c5Claude5059 <div class="blame-header-meta">
5060 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5061 <span class="blame-header-name">{fileName}</span>
5062 <span class="blame-header-tag">Blame</span>
5063 <span class="blame-header-stats">
5064 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5065 {blameAuthors.size} contributor
5066 {blameAuthors.size === 1 ? "" : "s"}
5067 </span>
5068 </div>
5069 <div class="blame-header-actions">
5070 <a
5071 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5072 class="blob-pill"
5073 >
5074 Normal view
5075 </a>
5076 <a
5077 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5078 class="blob-pill"
5079 >
5080 Raw
5081 </a>
5082 </div>
79136bbClaude5083 </div>
398a10cClaude5084 <div style="overflow-x:auto">
5085 <table class="blame-table">
79136bbClaude5086 <tbody>
5087 {blameLines.map((line, i) => {
5088 const showInfo =
5089 i === 0 || blameLines[i - 1].sha !== line.sha;
5090 return (
398a10cClaude5091 <tr class={showInfo ? "blame-row-first" : ""}>
5092 <td class="blame-gutter">
79136bbClaude5093 {showInfo && (
398a10cClaude5094 <span class="blame-gutter-inner">
79136bbClaude5095 <a
5096 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5097 class="blame-gutter-sha"
5098 title={`Commit ${line.sha}`}
79136bbClaude5099 >
5100 {line.sha.slice(0, 7)}
398a10cClaude5101 </a>
5102 <span class="blame-gutter-author" title={line.author}>
5103 {line.author}
5104 </span>
5105 </span>
79136bbClaude5106 )}
5107 </td>
398a10cClaude5108 <td class="blame-line-num">{line.lineNum}</td>
5109 <td class="blame-line-content">{line.content}</td>
79136bbClaude5110 </tr>
5111 );
5112 })}
5113 </tbody>
5114 </table>
5115 </div>
5116 </div>
5117 </Layout>
5118 );
5119});
5120
5121// Search
5122web.get("/:owner/:repo/search", async (c) => {
5123 const { owner, repo } = c.req.param();
5124 const user = c.get("user");
5125 const q = c.req.query("q") || "";
5126
5127 if (!(await repoExists(owner, repo))) return c.notFound();
5128
5129 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5130 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5131
5132 if (q.trim()) {
5133 results = await searchCode(owner, repo, defaultBranch, q.trim());
5134 }
5135
5136 return c.html(
5137 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude5138 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5139 <RepoHeader owner={owner} repo={repo} />
5140 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5141 <div class="search-hero">
5142 <div class="search-eyebrow">
5143 <strong>Search</strong> · {owner}/{repo}
5144 </div>
5145 <h1 class="search-title">
5146 Find any line in <span class="gradient-text">{repo}</span>
5147 </h1>
5148 <form
5149 method="get"
5150 action={`/${owner}/${repo}/search`}
5151 class="search-form"
5152 role="search"
5153 >
5154 <div class="search-input-wrap">
5155 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5156 <input
5157 type="text"
5158 name="q"
5159 value={q}
5160 placeholder="Search code on the default branch…"
5161 aria-label="Search code"
5162 class="search-input"
5163 autocomplete="off"
5164 autofocus
5165 />
5166 </div>
5167 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5168 Search
5169 </button>
efb11c5Claude5170 </form>
5171 </div>
79136bbClaude5172 {q && (
efb11c5Claude5173 <div class="search-results-head">
5174 <span class="search-results-count">
5175 <strong>{results.length}</strong> result
5176 {results.length !== 1 ? "s" : ""}
5177 </span>
5178 <span class="search-results-query">
5179 for <span class="search-results-q">"{q}"</span> on{" "}
5180 <code>{defaultBranch}</code>
5181 </span>
5182 </div>
79136bbClaude5183 )}
efb11c5Claude5184 {q && results.length === 0 ? (
5185 <div class="search-empty">
5186 <p>
5187 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5188 you're on the right branch.
5189 </p>
5190 </div>
5191 ) : results.length > 0 ? (
79136bbClaude5192 <div class="search-results">
5193 {(() => {
5194 // Group by file
5195 const grouped: Record<
5196 string,
5197 Array<{ lineNum: number; line: string }>
5198 > = {};
5199 for (const r of results) {
5200 if (!grouped[r.file]) grouped[r.file] = [];
5201 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5202 }
5203 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude5204 <div class="search-file diff-file">
5205 <div class="search-file-head diff-file-header">
79136bbClaude5206 <a
5207 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude5208 class="search-file-link"
79136bbClaude5209 >
5210 {file}
5211 </a>
efb11c5Claude5212 <span class="search-file-count">
5213 {matches.length} match{matches.length === 1 ? "" : "es"}
5214 </span>
79136bbClaude5215 </div>
5216 <div class="blob-code">
5217 <table>
5218 <tbody>
5219 {matches.map((m) => (
5220 <tr>
5221 <td class="line-num">{m.lineNum}</td>
5222 <td class="line-content">{m.line}</td>
5223 </tr>
5224 ))}
5225 </tbody>
5226 </table>
5227 </div>
5228 </div>
5229 ));
5230 })()}
5231 </div>
efb11c5Claude5232 ) : null}
79136bbClaude5233 </Layout>
5234 );
5235});
5236
fc1817aClaude5237export default web;