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.tsxBlame5129 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,
16} from "../db/schema";
fc1817aClaude17import { Layout } from "../views/layout";
cb5a796Claude18import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude19import {
20 RepoHeader,
21 RepoNav,
22 Breadcrumb,
23 FileTable,
06d5ffeClaude24 RepoCard,
25 BranchSwitcher,
26 HighlightedCode,
27 PlainCode,
fc1817aClaude28} from "../views/components";
ea9ed4cClaude29import { DiffView } from "../views/diff-view";
fc1817aClaude30import {
31 getTree,
32 getBlob,
33 listCommits,
34 getCommit,
35 getCommitFullMessage,
36 getDiff,
37 getReadme,
38 getDefaultBranch,
39 listBranches,
398a10cClaude40 listTags,
fc1817aClaude41 repoExists,
06d5ffeClaude42 initBareRepo,
79136bbClaude43 getBlame,
44 getRawBlob,
45 searchCode,
398a10cClaude46 getRepoPath,
fc1817aClaude47} from "../git/repository";
79136bbClaude48import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude49import { highlightCode } from "../lib/highlight";
91a0204Claude50import { computeHealthScore } from "../lib/health-score";
51import type { HealthScore } from "../lib/health-score";
06d5ffeClaude52import { softAuth, requireAuth } from "../middleware/auth";
53import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude54import { trackByName } from "../lib/traffic";
534f04aClaude55import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude56import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude57import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude58import {
59 listQueuedAiBuildIssues,
60 listRecentAutoMerges,
61 listRecentAiReviews,
62 countAiReviewsSince,
63 listDemoActivityFeed,
64} from "../lib/demo-activity";
91a0204Claude65import { computeHealthScore, type HealthScore } from "../lib/health-score";
fc1817aClaude66
06d5ffeClaude67const web = new Hono<AuthEnv>();
68
69// Soft auth on all web routes — c.get("user") available but may be null
70web.use("*", softAuth);
fc1817aClaude71
efb11c5Claude72/**
73 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
74 *
75 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
76 * scope is route-local and `layout.tsx` is locked. Each polished handler
77 * injects this via a `<style>` tag; the rules are namespaced by surface
78 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
79 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
80 * `.repo-home-*` styling Agent A already shipped.
81 */
82const codeBrowseCss = `
83 /* ───────── shared primitives ───────── */
84 .cb-hairline::before,
85 .new-repo-hero::before,
86 .profile-hero::before,
87 .commits-hero::before,
88 .commit-detail-card::before,
89 .search-hero::before {
90 content: '';
91 position: absolute;
92 top: 0; left: 0; right: 0;
93 height: 2px;
94 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
95 opacity: 0.7;
96 pointer-events: none;
97 }
98 @keyframes cbHeroOrb {
99 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
100 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
101 }
102 @media (prefers-reduced-motion: reduce) {
103 .new-repo-hero-orb,
104 .profile-hero-orb,
105 .commits-hero-orb { animation: none; }
106 }
107
108 /* ───────── new-repo ───────── */
109 .new-repo-hero {
110 position: relative;
111 margin-bottom: var(--space-5);
112 padding: var(--space-5) var(--space-6);
113 background: var(--bg-elevated);
114 border: 1px solid var(--border);
115 border-radius: 16px;
116 overflow: hidden;
117 }
118 .new-repo-hero-orb-wrap {
119 position: absolute;
120 inset: -25% -10% auto auto;
121 width: 360px;
122 height: 360px;
123 pointer-events: none;
124 z-index: 0;
125 }
126 .new-repo-hero-orb {
127 position: absolute;
128 inset: 0;
129 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
130 filter: blur(80px);
131 opacity: 0.7;
132 animation: cbHeroOrb 14s ease-in-out infinite;
133 }
134 .new-repo-hero-inner { position: relative; z-index: 1; }
135 .new-repo-eyebrow {
136 font-size: 12px;
137 font-family: var(--font-mono);
138 color: var(--text-muted);
139 letter-spacing: 0.1em;
140 text-transform: uppercase;
141 margin-bottom: var(--space-2);
142 }
143 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
144 .new-repo-title {
145 font-family: var(--font-display);
146 font-weight: 800;
147 letter-spacing: -0.028em;
148 font-size: clamp(28px, 4vw, 40px);
149 line-height: 1.05;
150 margin: 0 0 var(--space-2);
151 color: var(--text-strong);
152 }
153 .new-repo-sub {
154 font-size: 15px;
155 color: var(--text-muted);
156 margin: 0;
157 line-height: 1.55;
158 max-width: 620px;
159 }
160 .new-repo-form {
161 max-width: 680px;
162 }
163 .new-repo-error {
164 background: rgba(218, 54, 51, 0.12);
165 border: 1px solid rgba(218, 54, 51, 0.35);
166 color: #ffb3b3;
167 padding: 10px 14px;
168 border-radius: 10px;
169 margin-bottom: var(--space-4);
170 font-size: 14px;
171 }
172 .new-repo-form-grid {
173 display: flex;
174 flex-direction: column;
175 gap: var(--space-4);
176 }
177 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
178 .new-repo-label {
179 font-size: 13px;
180 color: var(--text-strong);
181 font-weight: 600;
182 }
183 .new-repo-label-optional {
184 color: var(--text-muted);
185 font-weight: 400;
186 font-size: 12px;
187 }
188 .new-repo-input {
189 appearance: none;
190 width: 100%;
191 background: var(--bg);
192 border: 1px solid var(--border);
193 color: var(--text-strong);
194 border-radius: 10px;
195 padding: 10px 12px;
196 font-size: 14px;
197 font-family: inherit;
198 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
199 }
200 .new-repo-input:focus {
201 outline: none;
202 border-color: var(--accent);
203 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
204 }
205 .new-repo-input-disabled {
206 color: var(--text-muted);
207 background: var(--bg-secondary);
208 cursor: not-allowed;
209 }
210 .new-repo-hint {
211 font-size: 12px;
212 color: var(--text-muted);
213 margin: 4px 0 0;
214 }
215 .new-repo-hint code {
216 font-family: var(--font-mono);
217 font-size: 11.5px;
218 background: var(--bg-secondary);
219 border: 1px solid var(--border);
220 border-radius: 5px;
221 padding: 1px 5px;
222 color: var(--text-strong);
223 }
224 .new-repo-visibility {
225 display: grid;
226 grid-template-columns: 1fr 1fr;
227 gap: var(--space-2);
228 }
229 @media (max-width: 600px) {
230 .new-repo-visibility { grid-template-columns: 1fr; }
231 }
232 .new-repo-vis-card {
233 display: flex;
234 gap: 10px;
235 padding: 14px;
236 background: var(--bg);
237 border: 1px solid var(--border);
238 border-radius: 12px;
239 cursor: pointer;
240 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
241 }
242 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
243 .new-repo-vis-card:has(input:checked) {
244 border-color: rgba(140,109,255,0.55);
245 background: rgba(140,109,255,0.06);
246 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
247 }
248 .new-repo-vis-radio {
249 margin-top: 3px;
250 accent-color: var(--accent);
251 }
252 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
253 .new-repo-vis-label {
254 font-size: 14px;
255 font-weight: 600;
256 color: var(--text-strong);
257 }
258 .new-repo-vis-desc {
259 font-size: 12.5px;
260 color: var(--text-muted);
261 line-height: 1.45;
262 }
263 .new-repo-callout {
264 margin-top: var(--space-2);
265 padding: var(--space-3) var(--space-4);
266 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
267 border: 1px solid rgba(140,109,255,0.2);
268 border-radius: 12px;
269 }
270 .new-repo-callout-eyebrow {
271 font-size: 11px;
272 font-family: var(--font-mono);
273 text-transform: uppercase;
274 letter-spacing: 0.12em;
275 color: var(--accent);
276 font-weight: 700;
277 margin-bottom: 4px;
278 }
279 .new-repo-callout-body {
280 font-size: 13px;
281 color: var(--text);
282 line-height: 1.5;
283 margin: 0;
284 }
285 .new-repo-callout-body code {
286 font-family: var(--font-mono);
287 font-size: 12px;
288 color: var(--accent);
289 background: rgba(140,109,255,0.1);
290 border-radius: 4px;
291 padding: 1px 5px;
292 }
398a10cClaude293 .new-repo-templates {
294 display: flex;
295 flex-wrap: wrap;
296 gap: 8px;
297 margin-top: 4px;
298 }
299 .new-repo-template-chip {
300 position: relative;
301 display: inline-flex;
302 align-items: center;
303 gap: 6px;
304 padding: 8px 14px;
305 background: var(--bg);
306 border: 1px solid var(--border);
307 border-radius: 999px;
308 font-size: 13px;
309 color: var(--text);
310 cursor: pointer;
311 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
312 }
313 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
314 .new-repo-template-chip:hover {
315 border-color: var(--border-strong, var(--border));
316 color: var(--text-strong);
317 }
318 .new-repo-template-chip:has(input:checked) {
319 border-color: rgba(140,109,255,0.55);
320 background: rgba(140,109,255,0.10);
321 color: var(--text-strong);
322 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
323 }
324 .new-repo-template-chip-dot {
325 width: 8px; height: 8px;
326 border-radius: 999px;
327 background: var(--text-faint, var(--text-muted));
328 transition: background 140ms ease, box-shadow 140ms ease;
329 }
330 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
331 background: linear-gradient(135deg, #8c6dff, #36c5d6);
332 box-shadow: 0 0 8px rgba(140,109,255,0.6);
333 }
efb11c5Claude334 .new-repo-actions {
335 display: flex;
336 gap: var(--space-2);
337 margin-top: var(--space-2);
398a10cClaude338 align-items: center;
339 }
340 .new-repo-submit {
341 min-width: 180px;
342 border: 1px solid rgba(140,109,255,0.45);
343 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
344 color: #fff;
345 font-weight: 700;
346 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
347 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
348 }
349 .new-repo-submit:hover {
350 transform: translateY(-1px);
351 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
352 filter: brightness(1.06);
353 }
354 .new-repo-submit:focus-visible {
355 outline: 3px solid rgba(140,109,255,0.45);
356 outline-offset: 2px;
efb11c5Claude357 }
358
359 /* ───────── profile ───────── */
360 .profile-hero {
361 position: relative;
362 margin-bottom: var(--space-5);
363 padding: var(--space-5) var(--space-6);
364 background: var(--bg-elevated);
365 border: 1px solid var(--border);
366 border-radius: 16px;
367 overflow: hidden;
368 }
369 .profile-hero-orb-wrap {
370 position: absolute;
371 inset: -25% -10% auto auto;
372 width: 360px;
373 height: 360px;
374 pointer-events: none;
375 z-index: 0;
376 }
377 .profile-hero-orb {
378 position: absolute;
379 inset: 0;
380 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
381 filter: blur(80px);
382 opacity: 0.7;
383 animation: cbHeroOrb 14s ease-in-out infinite;
384 }
385 .profile-hero-inner {
386 position: relative;
387 z-index: 1;
388 display: flex;
389 align-items: flex-start;
390 gap: var(--space-5);
391 }
392 .profile-hero-avatar {
393 flex: 0 0 auto;
394 width: 88px;
395 height: 88px;
396 border-radius: 50%;
397 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
398 color: #fff;
399 display: flex;
400 align-items: center;
401 justify-content: center;
402 font-size: 38px;
403 font-weight: 700;
404 font-family: var(--font-display);
405 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
406 }
407 .profile-hero-text { flex: 1; min-width: 0; }
408 .profile-eyebrow {
409 font-size: 12px;
410 font-family: var(--font-mono);
411 color: var(--text-muted);
412 letter-spacing: 0.1em;
413 text-transform: uppercase;
414 margin-bottom: var(--space-2);
415 }
416 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
417 .profile-name {
418 font-family: var(--font-display);
419 font-weight: 800;
420 letter-spacing: -0.028em;
421 font-size: clamp(28px, 3.6vw, 36px);
422 line-height: 1.05;
423 margin: 0 0 4px;
424 color: var(--text-strong);
425 }
426 .profile-handle {
427 font-family: var(--font-mono);
428 font-size: 13px;
429 color: var(--text-muted);
430 margin-bottom: var(--space-2);
431 }
432 .profile-bio {
433 font-size: 14.5px;
434 color: var(--text);
435 line-height: 1.55;
436 margin: 0 0 var(--space-3);
437 max-width: 640px;
438 }
439 .profile-meta {
440 display: flex;
441 align-items: center;
442 gap: var(--space-4);
443 flex-wrap: wrap;
444 font-size: 13px;
445 }
446 .profile-meta-link {
447 color: var(--text-muted);
448 transition: color var(--t-fast, 0.15s) ease;
449 }
450 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
451 .profile-meta-link strong {
452 color: var(--text-strong);
453 font-weight: 600;
454 font-variant-numeric: tabular-nums;
455 }
456 .profile-follow-form { margin: 0; }
457 @media (max-width: 600px) {
458 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
459 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
460 }
461 .profile-readme {
462 margin-bottom: var(--space-6);
463 background: var(--bg-elevated);
464 border: 1px solid var(--border);
465 border-radius: 12px;
466 overflow: hidden;
467 }
468 .profile-readme-head {
469 display: flex;
470 align-items: center;
471 gap: 8px;
472 padding: 10px 16px;
473 background: var(--bg-secondary);
474 border-bottom: 1px solid var(--border);
475 font-size: 13px;
476 color: var(--text-muted);
477 }
478 .profile-readme-icon { color: var(--accent); font-size: 14px; }
479 .profile-readme-body { padding: var(--space-5) var(--space-6); }
480 .profile-section-head {
481 display: flex;
482 align-items: baseline;
483 gap: var(--space-2);
484 margin-bottom: var(--space-3);
485 }
486 .profile-section-title {
487 font-family: var(--font-display);
488 font-weight: 700;
489 font-size: 20px;
490 letter-spacing: -0.015em;
491 margin: 0;
492 color: var(--text-strong);
493 }
494 .profile-section-count {
495 font-family: var(--font-mono);
496 font-size: 12px;
497 color: var(--text-muted);
498 background: var(--bg-secondary);
499 border: 1px solid var(--border);
500 border-radius: 999px;
501 padding: 2px 10px;
502 font-variant-numeric: tabular-nums;
503 }
504 .profile-empty {
505 display: flex;
506 align-items: center;
507 justify-content: space-between;
508 gap: var(--space-3);
509 padding: var(--space-4) var(--space-5);
510 background: var(--bg-elevated);
511 border: 1px dashed var(--border);
512 border-radius: 12px;
513 }
514 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
515
516 /* ───────── tree (file browser) ───────── */
517 .tree-header {
518 margin-bottom: var(--space-3);
519 display: flex;
520 flex-direction: column;
521 gap: var(--space-2);
522 }
523 .tree-header-row {
524 display: flex;
525 align-items: center;
526 justify-content: space-between;
527 gap: var(--space-3);
528 flex-wrap: wrap;
529 }
530 .tree-header-stats {
531 display: flex;
532 align-items: center;
533 gap: var(--space-3);
534 font-size: 12px;
535 color: var(--text-muted);
536 }
537 .tree-stat strong {
538 color: var(--text-strong);
539 font-weight: 600;
540 font-variant-numeric: tabular-nums;
541 }
542 .tree-stat-link {
543 color: var(--text-muted);
544 padding: 4px 10px;
545 border-radius: 999px;
546 border: 1px solid var(--border);
547 background: var(--bg-elevated);
548 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
549 }
550 .tree-stat-link:hover {
551 color: var(--accent);
552 border-color: rgba(140,109,255,0.45);
553 text-decoration: none;
554 }
555 .tree-breadcrumb-row {
556 font-size: 13px;
557 }
558
559 /* ───────── blob (file viewer) ───────── */
560 .blob-toolbar {
561 margin-bottom: var(--space-3);
562 display: flex;
563 flex-direction: column;
564 gap: var(--space-2);
565 }
566 .blob-breadcrumb { font-size: 13px; }
567 .blob-card {
568 background: var(--bg-elevated);
569 border: 1px solid var(--border);
570 border-radius: 12px;
571 overflow: hidden;
572 }
573 .blob-header-polished {
574 display: flex;
575 align-items: center;
576 justify-content: space-between;
577 gap: var(--space-3);
578 padding: 10px 14px;
579 background: var(--bg-secondary);
580 border-bottom: 1px solid var(--border);
581 }
582 .blob-header-meta {
583 display: flex;
584 align-items: center;
585 gap: var(--space-2);
586 min-width: 0;
587 flex: 1;
588 }
589 .blob-header-icon { font-size: 14px; opacity: 0.85; }
590 .blob-header-name {
591 font-family: var(--font-mono);
592 font-size: 13px;
593 color: var(--text-strong);
594 font-weight: 600;
595 overflow: hidden;
596 text-overflow: ellipsis;
597 white-space: nowrap;
598 }
599 .blob-header-size {
600 font-family: var(--font-mono);
601 font-size: 11.5px;
602 color: var(--text-muted);
603 font-variant-numeric: tabular-nums;
604 border-left: 1px solid var(--border);
605 padding-left: var(--space-2);
606 margin-left: 2px;
607 }
608 .blob-header-actions {
609 display: flex;
610 gap: 6px;
611 flex-shrink: 0;
612 }
613 .blob-pill {
614 display: inline-flex;
615 align-items: center;
616 padding: 4px 12px;
617 font-size: 12px;
618 font-weight: 500;
619 color: var(--text);
620 background: var(--bg-elevated);
621 border: 1px solid var(--border);
622 border-radius: 999px;
623 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
624 }
625 .blob-pill:hover {
626 color: var(--accent);
627 border-color: rgba(140,109,255,0.45);
628 text-decoration: none;
629 background: rgba(140,109,255,0.06);
630 }
631 .blob-pill-accent {
632 color: var(--accent);
633 border-color: rgba(140,109,255,0.35);
634 background: rgba(140,109,255,0.08);
635 }
636 .blob-pill-accent:hover {
637 background: rgba(140,109,255,0.14);
638 }
639 .blob-binary {
640 padding: var(--space-5);
641 color: var(--text-muted);
642 text-align: center;
643 font-size: 13px;
644 background: var(--bg);
645 }
646
647 /* ───────── commits list ───────── */
648 .commits-hero {
649 position: relative;
650 margin-bottom: var(--space-4);
651 padding: var(--space-5) var(--space-6);
652 background: var(--bg-elevated);
653 border: 1px solid var(--border);
654 border-radius: 16px;
655 overflow: hidden;
656 }
657 .commits-hero-orb-wrap {
658 position: absolute;
659 inset: -25% -10% auto auto;
660 width: 320px;
661 height: 320px;
662 pointer-events: none;
663 z-index: 0;
664 }
665 .commits-hero-orb {
666 position: absolute;
667 inset: 0;
668 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
669 filter: blur(80px);
670 opacity: 0.7;
671 animation: cbHeroOrb 14s ease-in-out infinite;
672 }
673 .commits-hero-inner { position: relative; z-index: 1; }
674 .commits-eyebrow {
675 font-size: 12px;
676 font-family: var(--font-mono);
677 color: var(--text-muted);
678 letter-spacing: 0.1em;
679 text-transform: uppercase;
680 margin-bottom: var(--space-2);
681 }
682 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
683 .commits-title {
684 font-family: var(--font-display);
685 font-weight: 800;
686 letter-spacing: -0.025em;
687 font-size: clamp(22px, 3vw, 30px);
688 line-height: 1.15;
689 margin: 0 0 var(--space-2);
690 color: var(--text-strong);
691 }
692 .commits-branch {
693 font-family: var(--font-mono);
694 font-size: 0.7em;
695 color: var(--text);
696 background: var(--bg-secondary);
697 border: 1px solid var(--border);
698 border-radius: 6px;
699 padding: 2px 8px;
700 font-weight: 500;
701 vertical-align: middle;
702 }
703 .commits-sub {
704 font-size: 14px;
705 color: var(--text-muted);
706 margin: 0;
707 line-height: 1.55;
708 max-width: 640px;
709 }
398a10cClaude710 .commits-toolbar {
711 display: flex;
712 justify-content: space-between;
713 align-items: center;
714 flex-wrap: wrap;
715 gap: var(--space-2);
716 margin-bottom: var(--space-3);
717 }
718 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
719 .commits-toolbar-link {
720 display: inline-flex;
721 align-items: center;
722 gap: 6px;
723 padding: 6px 12px;
724 font-size: 12.5px;
725 font-weight: 500;
726 color: var(--text-muted);
727 background: rgba(255,255,255,0.025);
728 border: 1px solid var(--border);
729 border-radius: 8px;
730 text-decoration: none;
731 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
732 }
733 .commits-toolbar-link:hover {
734 border-color: var(--border-strong);
735 color: var(--text-strong);
736 background: rgba(255,255,255,0.04);
737 text-decoration: none;
738 }
efb11c5Claude739 .commits-list-wrap {
740 background: var(--bg-elevated);
741 border: 1px solid var(--border);
398a10cClaude742 border-radius: 14px;
efb11c5Claude743 overflow: hidden;
398a10cClaude744 position: relative;
745 }
746 .commits-list-wrap::before {
747 content: '';
748 position: absolute;
749 top: 0; left: 0; right: 0;
750 height: 2px;
751 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
752 opacity: 0.55;
753 pointer-events: none;
754 }
755 .commits-day-head {
756 display: flex;
757 align-items: center;
758 gap: 10px;
759 padding: 10px 18px;
760 font-size: 11.5px;
761 font-family: var(--font-mono);
762 text-transform: uppercase;
763 letter-spacing: 0.08em;
764 color: var(--text-muted);
765 background: var(--bg-secondary);
766 border-bottom: 1px solid var(--border);
767 }
768 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
769 .commits-day-head-dot {
770 width: 6px; height: 6px;
771 border-radius: 9999px;
772 background: linear-gradient(135deg, #8c6dff, #36c5d6);
773 }
774 .commits-row {
775 display: flex;
776 align-items: flex-start;
777 gap: 14px;
778 padding: 14px 18px;
779 border-bottom: 1px solid var(--border-subtle);
780 transition: background 120ms ease;
781 }
782 .commits-row:last-child { border-bottom: none; }
783 .commits-row:hover { background: rgba(255,255,255,0.022); }
784 .commits-avatar {
785 width: 34px; height: 34px;
786 border-radius: 9999px;
787 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
788 color: #fff;
789 display: inline-flex;
790 align-items: center;
791 justify-content: center;
792 font-family: var(--font-display);
793 font-weight: 700;
794 font-size: 13.5px;
795 flex-shrink: 0;
796 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
797 }
798 .commits-row-body { flex: 1; min-width: 0; }
799 .commits-row-msg {
800 display: flex;
801 align-items: center;
802 gap: 8px;
803 flex-wrap: wrap;
804 }
805 .commits-row-msg a {
806 font-family: var(--font-display);
807 font-weight: 600;
808 font-size: 14px;
809 color: var(--text-strong);
810 text-decoration: none;
811 letter-spacing: -0.005em;
812 }
813 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
814 .commits-row-verified {
815 font-size: 9.5px;
816 padding: 1px 7px;
817 border-radius: 9999px;
818 background: rgba(52,211,153,0.16);
819 color: #6ee7b7;
820 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
821 text-transform: uppercase;
822 letter-spacing: 0.06em;
823 font-weight: 700;
824 }
825 .commits-row-meta {
826 margin-top: 4px;
827 display: flex;
828 align-items: center;
829 gap: 8px;
830 flex-wrap: wrap;
831 font-size: 12.5px;
832 color: var(--text-muted);
833 }
834 .commits-row-meta strong { color: var(--text); font-weight: 600; }
835 .commits-row-meta .sep { opacity: 0.4; }
836 .commits-row-time {
837 font-variant-numeric: tabular-nums;
838 }
839 .commits-row-side {
840 display: flex;
841 align-items: center;
842 gap: 8px;
843 flex-shrink: 0;
844 }
845 .commits-row-sha {
846 display: inline-flex;
847 align-items: center;
848 padding: 4px 10px;
849 border-radius: 9999px;
850 font-family: var(--font-mono);
851 font-size: 11.5px;
852 font-weight: 600;
853 color: var(--text-strong);
854 background: rgba(255,255,255,0.04);
855 border: 1px solid var(--border);
856 text-decoration: none;
857 letter-spacing: 0.04em;
858 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
859 }
860 .commits-row-sha:hover {
861 border-color: rgba(140,109,255,0.55);
862 color: var(--accent);
863 background: rgba(140,109,255,0.08);
864 text-decoration: none;
865 }
866 .commits-row-copy {
867 display: inline-flex;
868 align-items: center;
869 justify-content: center;
870 width: 28px; height: 28px;
871 padding: 0;
872 border-radius: 8px;
873 background: transparent;
874 border: 1px solid var(--border);
875 color: var(--text-muted);
876 cursor: pointer;
877 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude878 }
398a10cClaude879 .commits-row-copy:hover {
880 border-color: var(--border-strong);
881 color: var(--text);
882 background: rgba(255,255,255,0.04);
883 }
884 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
efb11c5Claude885 .commits-empty {
398a10cClaude886 position: relative;
887 overflow: hidden;
888 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude889 text-align: center;
398a10cClaude890 background: var(--bg-elevated);
891 border: 1px dashed var(--border-strong);
892 border-radius: 16px;
893 }
894 .commits-empty-orb {
895 position: absolute;
896 inset: -40% 30% auto 30%;
897 height: 280px;
898 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
899 filter: blur(70px);
900 opacity: 0.7;
901 pointer-events: none;
902 z-index: 0;
903 }
904 .commits-empty-inner { position: relative; z-index: 1; }
905 .commits-empty-icon {
906 width: 56px; height: 56px;
907 border-radius: 9999px;
908 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
909 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
910 display: inline-flex;
911 align-items: center;
912 justify-content: center;
913 color: #c4b5fd;
914 margin: 0 auto 14px;
915 }
916 .commits-empty-title {
917 font-family: var(--font-display);
918 font-size: 18px;
919 font-weight: 700;
920 margin: 0 0 6px;
921 color: var(--text-strong);
922 }
923 .commits-empty-sub {
924 margin: 0 auto 0;
925 font-size: 13.5px;
efb11c5Claude926 color: var(--text-muted);
398a10cClaude927 max-width: 420px;
928 line-height: 1.5;
929 }
930
931 /* ───────── branches list ───────── */
932 .branches-list {
efb11c5Claude933 background: var(--bg-elevated);
398a10cClaude934 border: 1px solid var(--border);
935 border-radius: 14px;
936 overflow: hidden;
937 position: relative;
938 }
939 .branches-list::before {
940 content: '';
941 position: absolute;
942 top: 0; left: 0; right: 0;
943 height: 2px;
944 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
945 opacity: 0.55;
946 pointer-events: none;
947 }
948 .branches-row {
949 display: flex;
950 align-items: center;
951 gap: var(--space-3);
952 padding: 14px 18px;
953 border-bottom: 1px solid var(--border-subtle);
954 transition: background 120ms ease;
955 flex-wrap: wrap;
956 }
957 .branches-row:last-child { border-bottom: none; }
958 .branches-row:hover { background: rgba(255,255,255,0.022); }
959 .branches-row-icon {
960 width: 32px; height: 32px;
961 border-radius: 8px;
962 background: rgba(140,109,255,0.10);
963 color: #c4b5fd;
964 display: inline-flex;
965 align-items: center;
966 justify-content: center;
967 flex-shrink: 0;
968 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
969 }
970 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
971 .branches-row-name {
972 display: inline-flex;
973 align-items: center;
974 gap: 8px;
975 flex-wrap: wrap;
976 }
977 .branches-row-name a {
978 font-family: var(--font-mono);
979 font-size: 13.5px;
980 color: var(--text-strong);
981 font-weight: 600;
982 text-decoration: none;
983 }
984 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
985 .branches-row-default {
986 font-size: 10px;
987 padding: 2px 8px;
988 border-radius: 9999px;
989 background: rgba(140,109,255,0.14);
990 color: #c4b5fd;
991 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
992 text-transform: uppercase;
993 letter-spacing: 0.08em;
994 font-weight: 700;
995 font-family: var(--font-mono);
996 }
997 .branches-row-meta {
998 display: flex;
999 align-items: center;
1000 gap: 8px;
1001 flex-wrap: wrap;
1002 font-size: 12.5px;
1003 color: var(--text-muted);
1004 font-variant-numeric: tabular-nums;
1005 }
1006 .branches-row-meta .sep { opacity: 0.4; }
1007 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1008 .branches-row-side {
1009 display: flex;
1010 align-items: center;
1011 gap: 10px;
1012 flex-shrink: 0;
1013 flex-wrap: wrap;
1014 }
1015 .branches-row-divergence {
1016 display: inline-flex;
1017 align-items: center;
1018 gap: 8px;
1019 font-family: var(--font-mono);
1020 font-size: 11.5px;
1021 color: var(--text-muted);
1022 padding: 4px 10px;
1023 border-radius: 9999px;
1024 background: rgba(255,255,255,0.035);
1025 border: 1px solid var(--border);
1026 font-variant-numeric: tabular-nums;
1027 }
1028 .branches-row-divergence .ahead { color: #6ee7b7; }
1029 .branches-row-divergence .behind { color: #fca5a5; }
1030 .branches-row-actions {
1031 display: flex;
1032 align-items: center;
1033 gap: 6px;
1034 }
1035 .branches-btn {
1036 display: inline-flex;
1037 align-items: center;
1038 gap: 5px;
1039 padding: 6px 12px;
1040 border-radius: 9999px;
1041 font-size: 12px;
1042 font-weight: 600;
1043 text-decoration: none;
1044 border: 1px solid var(--border);
1045 background: transparent;
1046 color: var(--text-muted);
1047 cursor: pointer;
1048 font: inherit;
1049 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1050 }
1051 .branches-btn:hover {
1052 border-color: var(--border-strong);
1053 color: var(--text);
1054 background: rgba(255,255,255,0.04);
1055 text-decoration: none;
1056 }
1057 .branches-btn-danger {
1058 color: #fca5a5;
1059 border-color: rgba(248,113,113,0.30);
1060 }
1061 .branches-btn-danger:hover {
1062 border-style: dashed;
1063 border-color: rgba(248,113,113,0.65);
1064 background: rgba(248,113,113,0.06);
1065 color: #fecaca;
1066 }
1067
1068 /* ───────── tags list ───────── */
1069 .tags-list {
1070 background: var(--bg-elevated);
1071 border: 1px solid var(--border);
1072 border-radius: 14px;
1073 overflow: hidden;
1074 position: relative;
1075 }
1076 .tags-list::before {
1077 content: '';
1078 position: absolute;
1079 top: 0; left: 0; right: 0;
1080 height: 2px;
1081 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1082 opacity: 0.55;
1083 pointer-events: none;
1084 }
1085 .tags-row {
1086 display: flex;
1087 align-items: center;
1088 gap: var(--space-3);
1089 padding: 14px 18px;
1090 border-bottom: 1px solid var(--border-subtle);
1091 transition: background 120ms ease;
1092 flex-wrap: wrap;
1093 }
1094 .tags-row:last-child { border-bottom: none; }
1095 .tags-row:hover { background: rgba(255,255,255,0.022); }
1096 .tags-row-icon {
1097 width: 32px; height: 32px;
1098 border-radius: 8px;
1099 background: rgba(54,197,214,0.10);
1100 color: #67e8f9;
1101 display: inline-flex;
1102 align-items: center;
1103 justify-content: center;
1104 flex-shrink: 0;
1105 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1106 }
1107 .tags-row-main { flex: 1; min-width: 240px; }
1108 .tags-row-name {
1109 display: inline-flex;
1110 align-items: center;
1111 gap: 8px;
1112 flex-wrap: wrap;
1113 }
1114 .tags-row-version {
1115 display: inline-flex;
1116 align-items: center;
1117 padding: 3px 11px;
1118 border-radius: 9999px;
1119 font-family: var(--font-mono);
1120 font-size: 13px;
1121 font-weight: 700;
1122 color: #67e8f9;
1123 background: rgba(54,197,214,0.12);
1124 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1125 letter-spacing: 0.01em;
1126 }
1127 .tags-row-meta {
1128 margin-top: 4px;
1129 display: flex;
1130 align-items: center;
1131 gap: 8px;
1132 flex-wrap: wrap;
1133 font-size: 12.5px;
1134 color: var(--text-muted);
1135 font-variant-numeric: tabular-nums;
1136 }
1137 .tags-row-meta .sep { opacity: 0.4; }
1138 .tags-row-sha {
1139 font-family: var(--font-mono);
1140 font-size: 11.5px;
1141 color: var(--text-strong);
1142 padding: 2px 8px;
1143 border-radius: 6px;
1144 background: rgba(255,255,255,0.04);
1145 border: 1px solid var(--border);
1146 text-decoration: none;
1147 letter-spacing: 0.04em;
1148 }
1149 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1150 .tags-row-side {
1151 display: flex;
1152 align-items: center;
1153 gap: 6px;
1154 flex-shrink: 0;
1155 flex-wrap: wrap;
1156 }
1157 .tags-row-link {
1158 display: inline-flex;
1159 align-items: center;
1160 gap: 5px;
1161 padding: 6px 12px;
1162 border-radius: 9999px;
1163 font-size: 12px;
1164 font-weight: 600;
1165 text-decoration: none;
1166 color: var(--text-muted);
1167 background: rgba(255,255,255,0.025);
1168 border: 1px solid var(--border);
1169 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1170 }
1171 .tags-row-link:hover {
1172 border-color: rgba(140,109,255,0.45);
1173 color: var(--text-strong);
1174 background: rgba(140,109,255,0.06);
1175 text-decoration: none;
efb11c5Claude1176 }
1177
1178 /* ───────── commit detail ───────── */
1179 .commit-detail-card {
1180 position: relative;
1181 margin-bottom: var(--space-5);
1182 padding: var(--space-5) var(--space-5);
1183 background: var(--bg-elevated);
1184 border: 1px solid var(--border);
1185 border-radius: 14px;
1186 overflow: hidden;
1187 }
1188 .commit-detail-eyebrow {
1189 display: flex;
1190 align-items: center;
1191 gap: var(--space-2);
1192 font-size: 12px;
1193 font-family: var(--font-mono);
1194 text-transform: uppercase;
1195 letter-spacing: 0.1em;
1196 color: var(--text-muted);
1197 margin-bottom: var(--space-2);
1198 }
1199 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1200 .commit-detail-sha-pill {
1201 font-family: var(--font-mono);
1202 font-size: 11.5px;
1203 color: var(--text-strong);
1204 background: var(--bg-secondary);
1205 border: 1px solid var(--border);
1206 border-radius: 999px;
1207 padding: 2px 10px;
1208 letter-spacing: 0.04em;
1209 }
1210 .commit-detail-verify {
1211 font-size: 10px;
1212 padding: 2px 8px;
1213 border-radius: 999px;
1214 text-transform: uppercase;
1215 letter-spacing: 0.06em;
1216 font-weight: 700;
1217 color: #fff;
1218 }
1219 .commit-detail-verify-ok {
1220 background: linear-gradient(135deg, #2ea043, #34d399);
1221 }
1222 .commit-detail-verify-warn {
1223 background: linear-gradient(135deg, #d29922, #f59e0b);
1224 }
1225 .commit-detail-title {
1226 font-family: var(--font-display);
1227 font-weight: 700;
1228 letter-spacing: -0.018em;
1229 font-size: clamp(20px, 2.5vw, 26px);
1230 line-height: 1.25;
1231 margin: 0 0 var(--space-2);
1232 color: var(--text-strong);
1233 }
1234 .commit-detail-body {
1235 white-space: pre-wrap;
1236 color: var(--text-muted);
1237 font-size: 14px;
1238 line-height: 1.55;
1239 margin: 0 0 var(--space-3);
1240 font-family: var(--font-mono);
1241 background: var(--bg);
1242 border: 1px solid var(--border);
1243 border-radius: 8px;
1244 padding: var(--space-3) var(--space-4);
1245 max-height: 280px;
1246 overflow: auto;
1247 }
1248 .commit-detail-meta {
1249 display: flex;
1250 flex-wrap: wrap;
1251 gap: var(--space-3);
1252 font-size: 13px;
1253 color: var(--text-muted);
1254 margin-bottom: var(--space-3);
1255 }
1256 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1257 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1258 .commit-detail-sha-link {
1259 font-family: var(--font-mono);
1260 font-size: 12.5px;
1261 color: var(--accent);
1262 background: rgba(140,109,255,0.08);
1263 border-radius: 6px;
1264 padding: 1px 6px;
1265 margin-left: 2px;
1266 }
1267 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1268 .commit-detail-stats {
1269 display: flex;
1270 flex-wrap: wrap;
1271 align-items: center;
1272 gap: var(--space-3);
1273 padding-top: var(--space-3);
1274 border-top: 1px solid var(--border);
1275 font-size: 13px;
1276 color: var(--text-muted);
1277 }
1278 .commit-detail-stat {
1279 display: inline-flex;
1280 align-items: center;
1281 gap: 4px;
1282 font-variant-numeric: tabular-nums;
1283 }
1284 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1285 .commit-detail-stat-add strong { color: #34d399; }
1286 .commit-detail-stat-del strong { color: #f87171; }
1287 .commit-detail-stat-mark {
1288 font-family: var(--font-mono);
1289 font-weight: 700;
1290 font-size: 14px;
1291 }
1292 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1293 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1294 .commit-detail-sha-full {
1295 margin-left: auto;
1296 font-family: var(--font-mono);
1297 font-size: 11.5px;
1298 color: var(--text-faint);
1299 letter-spacing: 0.02em;
1300 overflow: hidden;
1301 text-overflow: ellipsis;
1302 white-space: nowrap;
1303 max-width: 100%;
1304 }
1305 .commit-detail-checks {
1306 margin-top: var(--space-3);
1307 padding-top: var(--space-3);
1308 border-top: 1px solid var(--border);
1309 }
1310 .commit-detail-checks-head {
1311 display: flex;
1312 align-items: center;
1313 gap: var(--space-2);
1314 font-size: 13px;
1315 color: var(--text-muted);
1316 margin-bottom: 8px;
1317 }
1318 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1319 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1320 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1321 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1322 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1323 .commit-detail-check {
1324 font-size: 11px;
1325 padding: 2px 8px;
1326 border-radius: 999px;
1327 color: #fff;
1328 font-weight: 500;
1329 }
398a10cClaude1330 .commit-detail-check a { color: inherit; text-decoration: none; }
1331 .commit-detail-check-success { background: #2ea043; }
1332 .commit-detail-check-pending { background: #d29922; }
1333 .commit-detail-check-failure { background: #da3633; }
1334
1335 /* ───────── blame ───────── */
1336 .blame-head { margin-bottom: var(--space-5); }
1337 .blame-eyebrow {
1338 display: inline-flex;
1339 align-items: center;
1340 gap: 8px;
1341 text-transform: uppercase;
1342 font-family: var(--font-mono);
1343 font-size: 11px;
1344 letter-spacing: 0.16em;
1345 color: var(--text-muted);
1346 font-weight: 600;
1347 margin-bottom: 10px;
1348 }
1349 .blame-eyebrow-dot {
1350 width: 8px; height: 8px;
1351 border-radius: 9999px;
1352 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1353 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1354 }
1355 .blame-title {
1356 font-family: var(--font-display);
1357 font-size: clamp(22px, 3vw, 30px);
1358 font-weight: 800;
1359 letter-spacing: -0.025em;
1360 line-height: 1.15;
1361 margin: 0 0 6px;
1362 color: var(--text-strong);
1363 }
1364 .blame-title code {
1365 font-family: var(--font-mono);
1366 font-size: 0.78em;
1367 color: var(--text-strong);
1368 font-weight: 700;
1369 }
1370 .blame-sub {
1371 margin: 0;
1372 font-size: 14px;
1373 color: var(--text-muted);
1374 line-height: 1.5;
1375 max-width: 700px;
1376 }
efb11c5Claude1377 .blame-toolbar {
398a10cClaude1378 display: flex;
1379 justify-content: space-between;
1380 align-items: center;
1381 gap: var(--space-3);
efb11c5Claude1382 margin-bottom: var(--space-3);
398a10cClaude1383 flex-wrap: wrap;
efb11c5Claude1384 font-size: 13px;
1385 }
398a10cClaude1386 .blame-toolbar-actions { display: flex; gap: 6px; }
1387 .blame-card {
1388 background: var(--bg-elevated);
1389 border: 1px solid var(--border);
1390 border-radius: 14px;
1391 overflow: hidden;
1392 position: relative;
1393 }
1394 .blame-card::before {
1395 content: '';
1396 position: absolute;
1397 top: 0; left: 0; right: 0;
1398 height: 2px;
1399 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1400 opacity: 0.55;
1401 pointer-events: none;
1402 }
efb11c5Claude1403 .blame-header {
1404 display: flex;
1405 align-items: center;
1406 justify-content: space-between;
1407 gap: var(--space-3);
1408 padding: 10px 14px;
1409 background: var(--bg-secondary);
1410 border-bottom: 1px solid var(--border);
1411 }
1412 .blame-header-meta {
1413 display: flex;
1414 align-items: center;
1415 gap: var(--space-2);
1416 min-width: 0;
1417 flex-wrap: wrap;
1418 }
1419 .blame-header-icon { color: var(--accent); font-size: 14px; }
1420 .blame-header-name {
1421 font-family: var(--font-mono);
1422 font-size: 13px;
1423 color: var(--text-strong);
1424 font-weight: 600;
1425 }
1426 .blame-header-tag {
1427 font-size: 10.5px;
1428 text-transform: uppercase;
1429 letter-spacing: 0.08em;
1430 font-family: var(--font-mono);
1431 background: rgba(140,109,255,0.12);
1432 color: var(--accent);
1433 border-radius: 999px;
1434 padding: 2px 8px;
1435 font-weight: 600;
1436 }
1437 .blame-header-stats {
1438 font-family: var(--font-mono);
1439 font-size: 11.5px;
1440 color: var(--text-muted);
1441 font-variant-numeric: tabular-nums;
1442 border-left: 1px solid var(--border);
1443 padding-left: var(--space-2);
1444 }
1445 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1446 .blame-table {
1447 width: 100%;
1448 border-collapse: collapse;
1449 font-family: var(--font-mono);
1450 font-size: 12.5px;
1451 line-height: 1.6;
1452 }
1453 .blame-table tr { border-bottom: 1px solid transparent; }
1454 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1455 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1456 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1457 .blame-gutter {
1458 width: 220px;
1459 min-width: 220px;
1460 padding: 0 12px;
1461 vertical-align: top;
1462 background: rgba(255,255,255,0.012);
1463 border-right: 1px solid var(--border-subtle);
1464 font-variant-numeric: tabular-nums;
1465 color: var(--text-muted);
1466 font-size: 11px;
1467 white-space: nowrap;
1468 overflow: hidden;
1469 text-overflow: ellipsis;
1470 padding-top: 2px;
1471 padding-bottom: 2px;
1472 }
1473 .blame-gutter-inner {
1474 display: inline-flex;
1475 align-items: center;
1476 gap: 7px;
1477 max-width: 100%;
1478 overflow: hidden;
1479 }
1480 .blame-gutter-sha {
1481 font-family: var(--font-mono);
1482 font-size: 10.5px;
1483 font-weight: 600;
1484 color: #c4b5fd;
1485 background: rgba(140,109,255,0.10);
1486 padding: 1px 7px;
1487 border-radius: 9999px;
1488 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1489 text-decoration: none;
1490 letter-spacing: 0.04em;
1491 flex-shrink: 0;
1492 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1493 }
1494 .blame-gutter-sha:hover {
1495 background: rgba(140,109,255,0.22);
1496 color: #ddd6fe;
1497 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1498 text-decoration: none;
1499 }
1500 .blame-gutter-author {
1501 color: var(--text);
1502 overflow: hidden;
1503 text-overflow: ellipsis;
1504 white-space: nowrap;
1505 font-size: 11px;
1506 font-family: var(--font-sans, inherit);
1507 }
1508 .blame-line-num {
1509 width: 1%;
1510 min-width: 50px;
1511 padding: 0 12px;
1512 text-align: right;
1513 color: var(--text-faint);
1514 user-select: none;
1515 border-right: 1px solid var(--border-subtle);
1516 font-variant-numeric: tabular-nums;
1517 }
1518 .blame-line-content {
1519 padding: 0 14px;
1520 white-space: pre;
1521 color: var(--text);
1522 transition: background 120ms ease;
1523 }
efb11c5Claude1524
1525 /* ───────── search ───────── */
1526 .search-hero {
1527 position: relative;
1528 margin-bottom: var(--space-4);
1529 padding: var(--space-5) var(--space-6);
1530 background: var(--bg-elevated);
1531 border: 1px solid var(--border);
1532 border-radius: 16px;
1533 overflow: hidden;
1534 }
1535 .search-eyebrow {
1536 font-size: 12px;
1537 font-family: var(--font-mono);
1538 color: var(--text-muted);
1539 letter-spacing: 0.1em;
1540 text-transform: uppercase;
1541 margin-bottom: var(--space-2);
1542 }
1543 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1544 .search-title {
1545 font-family: var(--font-display);
1546 font-weight: 800;
1547 letter-spacing: -0.025em;
1548 font-size: clamp(22px, 3vw, 30px);
1549 line-height: 1.15;
1550 margin: 0 0 var(--space-3);
1551 color: var(--text-strong);
1552 }
1553 .search-form {
1554 display: flex;
1555 gap: var(--space-2);
1556 align-items: stretch;
1557 }
1558 .search-input-wrap {
1559 position: relative;
1560 flex: 1;
1561 display: flex;
1562 align-items: center;
1563 }
1564 .search-input-icon {
1565 position: absolute;
1566 left: 12px;
1567 color: var(--text-muted);
1568 font-size: 15px;
1569 pointer-events: none;
1570 }
1571 .search-input {
1572 appearance: none;
1573 width: 100%;
1574 padding: 10px 12px 10px 34px;
1575 background: var(--bg);
1576 border: 1px solid var(--border);
1577 border-radius: 10px;
1578 color: var(--text-strong);
1579 font-size: 14px;
1580 font-family: inherit;
1581 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1582 }
1583 .search-input:focus {
1584 outline: none;
1585 border-color: var(--accent);
1586 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1587 }
1588 .search-submit { min-width: 96px; }
1589 .search-results-head {
1590 display: flex;
1591 align-items: baseline;
1592 gap: var(--space-2);
1593 margin-bottom: var(--space-3);
1594 font-size: 13px;
1595 color: var(--text-muted);
1596 }
1597 .search-results-count strong {
1598 color: var(--text-strong);
1599 font-weight: 600;
1600 font-variant-numeric: tabular-nums;
1601 }
1602 .search-results-q { color: var(--text-strong); font-weight: 600; }
1603 .search-results-head code {
1604 font-family: var(--font-mono);
1605 font-size: 12px;
1606 background: var(--bg-secondary);
1607 border: 1px solid var(--border);
1608 border-radius: 5px;
1609 padding: 1px 6px;
1610 color: var(--text);
1611 }
1612 .search-empty {
1613 padding: var(--space-5) var(--space-6);
1614 background: var(--bg-elevated);
1615 border: 1px dashed var(--border);
1616 border-radius: 12px;
1617 color: var(--text-muted);
1618 font-size: 14px;
1619 }
1620 .search-empty strong { color: var(--text-strong); }
1621 .search-results {
1622 display: flex;
1623 flex-direction: column;
1624 gap: var(--space-3);
1625 }
1626 .search-file-head {
1627 display: flex;
1628 align-items: center;
1629 justify-content: space-between;
1630 gap: var(--space-2);
1631 }
1632 .search-file-link {
1633 font-family: var(--font-mono);
1634 font-size: 13px;
1635 color: var(--text-strong);
1636 font-weight: 600;
1637 }
1638 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1639 .search-file-count {
1640 font-family: var(--font-mono);
1641 font-size: 11.5px;
1642 color: var(--text-muted);
1643 font-variant-numeric: tabular-nums;
1644 }
1645`;
1646
fc1817aClaude1647// Home page
06d5ffeClaude1648web.get("/", async (c) => {
1649 const user = c.get("user");
1650
1651 if (user) {
0316dbbClaude1652 return c.redirect("/dashboard");
06d5ffeClaude1653 }
1654
8e9f1d9Claude1655 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1656 let publicStats: PublicStats | null = null;
8e9f1d9Claude1657 try {
1658 const [repoRow] = await db
1659 .select({ n: sql<number>`count(*)::int` })
1660 .from(repositories)
1661 .where(eq(repositories.isPrivate, false));
1662 const [userRow] = await db
1663 .select({ n: sql<number>`count(*)::int` })
1664 .from(users);
1665 stats = {
1666 publicRepos: Number(repoRow?.n ?? 0),
1667 users: Number(userRow?.n ?? 0),
1668 };
1669 } catch {
1670 stats = undefined;
1671 }
1672
52ad8b1Claude1673 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1674 try {
1675 publicStats = await computePublicStats();
1676 } catch {
1677 publicStats = null;
1678 }
1679
534f04aClaude1680 // Block M1 — initial SSR snapshot for the live-now demo feed.
1681 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1682 // in try/catch so a freak module-level explosion can't take down /.
1683 let liveFeed: LandingLiveFeed | null = null;
1684 try {
1685 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1686 listQueuedAiBuildIssues(3),
1687 listRecentAutoMerges(3, 24),
1688 listRecentAiReviews(3, 24),
1689 countAiReviewsSince(24),
1690 listDemoActivityFeed(10),
1691 ]);
1692 liveFeed = {
1693 queued: queued.map((i) => ({
1694 repo: i.repo,
1695 number: i.number,
1696 title: i.title,
1697 createdAt: i.createdAt,
1698 })),
1699 merges: merges.map((m) => ({
1700 repo: m.repo,
1701 number: m.number,
1702 title: m.title,
1703 mergedAt: m.mergedAt,
1704 })),
1705 reviews: reviewList.map((r) => ({
1706 repo: r.repo,
1707 prNumber: r.prNumber,
1708 commentSnippet: r.commentSnippet,
1709 createdAt: r.createdAt,
1710 })),
1711 reviewCount,
1712 feed: feed.map((e) => ({
1713 kind: e.kind,
1714 repo: e.repo,
1715 ref: e.ref,
1716 at: e.at,
1717 })),
1718 };
1719 } catch {
1720 liveFeed = null;
1721 }
1722
29924bcClaude1723 // 2030 reboot — the public landing is a self-contained light marketing
1724 // document (its own shell + design system), rendered directly so it never
1725 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1726 // above for the legacy LandingPage and other surfaces; the new page uses the
1727 // headline counters from `stats`.
1728 void publicStats;
1729 void liveFeed;
1730 void LandingPage;
1731 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1732});
1733
06d5ffeClaude1734// New repository form
1735web.get("/new", requireAuth, (c) => {
1736 const user = c.get("user")!;
1737 const error = c.req.query("error");
1738
1739 return c.html(
1740 <Layout title="New repository" user={user}>
efb11c5Claude1741 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1742 <div class="new-repo-hero">
1743 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1744 <div class="new-repo-hero-orb" />
1745 </div>
1746 <div class="new-repo-hero-inner">
1747 <div class="new-repo-eyebrow">
1748 <strong>Create</strong> · {user.username}
1749 </div>
1750 <h1 class="new-repo-title">
1751 Spin up a <span class="gradient-text">repository</span>.
1752 </h1>
1753 <p class="new-repo-sub">
1754 Push your first commit, and Gluecron wires up gate checks, AI review,
1755 and auto-merge from the moment your branch lands.
1756 </p>
1757 </div>
1758 </div>
06d5ffeClaude1759 <div class="new-repo-form">
efb11c5Claude1760 {error && (
1761 <div class="new-repo-error" role="alert">
1762 {decodeURIComponent(error)}
1763 </div>
1764 )}
1765 <form method="post" action="/new" class="new-repo-form-grid">
1766 <div class="new-repo-row">
1767 <label class="new-repo-label">Owner</label>
1768 <input
1769 type="text"
1770 value={user.username}
1771 disabled
1772 aria-label="Owner"
1773 class="new-repo-input new-repo-input-disabled"
1774 />
06d5ffeClaude1775 </div>
efb11c5Claude1776 <div class="new-repo-row">
1777 <label class="new-repo-label" for="name">
1778 Repository name
1779 </label>
06d5ffeClaude1780 <input
1781 type="text"
1782 id="name"
1783 name="name"
1784 required
1785 pattern="^[a-zA-Z0-9._-]+$"
1786 placeholder="my-project"
1787 autocomplete="off"
efb11c5Claude1788 class="new-repo-input"
06d5ffeClaude1789 />
efb11c5Claude1790 <p class="new-repo-hint">
1791 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1792 <code>{user.username}/&lt;name&gt;</code>.
1793 </p>
06d5ffeClaude1794 </div>
efb11c5Claude1795 <div class="new-repo-row">
1796 <label class="new-repo-label" for="description">
1797 Description{" "}
1798 <span class="new-repo-label-optional">(optional)</span>
1799 </label>
06d5ffeClaude1800 <input
1801 type="text"
1802 id="description"
1803 name="description"
1804 placeholder="A short description of your repository"
efb11c5Claude1805 class="new-repo-input"
06d5ffeClaude1806 />
1807 </div>
efb11c5Claude1808 <div class="new-repo-row">
1809 <span class="new-repo-label">Visibility</span>
1810 <div class="new-repo-visibility">
1811 <label class="new-repo-vis-card">
1812 <input
1813 type="radio"
1814 name="visibility"
1815 value="public"
1816 checked
1817 class="new-repo-vis-radio"
1818 />
1819 <span class="new-repo-vis-body">
1820 <span class="new-repo-vis-label">Public</span>
1821 <span class="new-repo-vis-desc">
1822 Anyone can see this repository. You choose who can commit.
1823 </span>
1824 </span>
1825 </label>
1826 <label class="new-repo-vis-card">
1827 <input
1828 type="radio"
1829 name="visibility"
1830 value="private"
1831 class="new-repo-vis-radio"
1832 />
1833 <span class="new-repo-vis-body">
1834 <span class="new-repo-vis-label">Private</span>
1835 <span class="new-repo-vis-desc">
1836 Only you (and collaborators you invite) can see this
1837 repository.
1838 </span>
1839 </span>
1840 </label>
1841 </div>
1842 </div>
398a10cClaude1843 <div class="new-repo-row">
1844 <span class="new-repo-label">
1845 Starter content{" "}
1846 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1847 </span>
1848 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1849 <label class="new-repo-template-chip">
1850 <input type="radio" name="starter" value="empty" checked />
1851 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1852 Empty
1853 </label>
1854 <label class="new-repo-template-chip">
1855 <input type="radio" name="starter" value="readme" />
1856 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1857 README
1858 </label>
1859 <label class="new-repo-template-chip">
1860 <input type="radio" name="starter" value="readme-mit" />
1861 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1862 README + MIT
1863 </label>
1864 <label class="new-repo-template-chip">
1865 <input type="radio" name="starter" value="node" />
1866 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1867 Node + .gitignore
1868 </label>
1869 </div>
1870 <p class="new-repo-hint">
1871 Just a UI hint — push your own commits to fill the repo.
1872 </p>
1873 </div>
efb11c5Claude1874 <div class="new-repo-callout">
1875 <div class="new-repo-callout-eyebrow">AI-native by default</div>
1876 <p class="new-repo-callout-body">
1877 Every push is gate-checked and reviewed by Claude automatically.
1878 Label an issue <code>ai-build</code> and Gluecron will open the PR
1879 for you.
1880 </p>
1881 </div>
1882 <div class="new-repo-actions">
398a10cClaude1883 <button type="submit" class="btn new-repo-submit">
efb11c5Claude1884 Create repository
1885 </button>
1886 <a href="/dashboard" class="btn new-repo-cancel">
1887 Cancel
1888 </a>
06d5ffeClaude1889 </div>
1890 </form>
1891 </div>
1892 </Layout>
1893 );
1894});
1895
1896web.post("/new", requireAuth, async (c) => {
1897 const user = c.get("user")!;
1898 const body = await c.req.parseBody();
1899 const name = String(body.name || "").trim();
1900 const description = String(body.description || "").trim();
1901 const isPrivate = body.visibility === "private";
1902
1903 if (!name) {
1904 return c.redirect("/new?error=Repository+name+is+required");
1905 }
1906
c63b860Claude1907 // P4 — plan-quota gate. Fail-open inside the helper so a billing
1908 // outage never blocks repo creation.
1909 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
1910 const gate = await checkRepoCreateAllowed(user.id);
1911 if (!gate.ok) {
1912 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
1913 }
1914
06d5ffeClaude1915 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
1916 return c.redirect("/new?error=Invalid+repository+name");
1917 }
1918
1919 if (await repoExists(user.username, name)) {
1920 return c.redirect("/new?error=Repository+already+exists");
1921 }
1922
1923 const diskPath = await initBareRepo(user.username, name);
1924
3ef4c9dClaude1925 const [newRepo] = await db
1926 .insert(repositories)
1927 .values({
1928 name,
1929 ownerId: user.id,
1930 description: description || null,
1931 isPrivate,
1932 diskPath,
1933 })
1934 .returning();
1935
1936 if (newRepo) {
1937 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
1938 await bootstrapRepository({
1939 repositoryId: newRepo.id,
1940 ownerUserId: user.id,
1941 defaultBranch: "main",
1942 });
1943 }
06d5ffeClaude1944
1945 return c.redirect(`/${user.username}/${name}`);
1946});
1947
1948// User profile
fc1817aClaude1949web.get("/:owner", async (c) => {
06d5ffeClaude1950 const { owner: ownerName } = c.req.param();
1951 const user = c.get("user");
1952
1953 // Avoid clashing with fixed routes
1954 if (
1955 ["login", "register", "logout", "new", "settings", "api"].includes(
1956 ownerName
1957 )
1958 ) {
1959 return c.notFound();
1960 }
1961
1962 let ownerUser;
1963 try {
1964 const [found] = await db
1965 .select()
1966 .from(users)
1967 .where(eq(users.username, ownerName))
1968 .limit(1);
1969 ownerUser = found;
1970 } catch {
1971 // DB not available — check if repos exist on disk
1972 ownerUser = null;
1973 }
1974
1975 // Even without DB, show repos if they exist on disk
1976 let repos: any[] = [];
1977 if (ownerUser) {
1978 const allRepos = await db
1979 .select()
1980 .from(repositories)
1981 .where(eq(repositories.ownerId, ownerUser.id))
1982 .orderBy(desc(repositories.updatedAt));
1983
1984 // Show public repos to everyone, private only to owner
1985 repos =
1986 user?.id === ownerUser.id
1987 ? allRepos
1988 : allRepos.filter((r) => !r.isPrivate);
1989 }
1990
7aa8b99Claude1991 // Block J4 — follow counts + viewer's follow state
1992 let followState = {
1993 followers: 0,
1994 following: 0,
1995 viewerFollows: false,
1996 };
1997 if (ownerUser) {
1998 try {
1999 const { followCounts, isFollowing } = await import("../lib/follows");
2000 const counts = await followCounts(ownerUser.id);
2001 followState.followers = counts.followers;
2002 followState.following = counts.following;
2003 if (user && user.id !== ownerUser.id) {
2004 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2005 }
2006 } catch {
2007 // DB hiccup — fall back to zeros.
2008 }
2009 }
2010 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2011
d412586Claude2012 // Block J5 — profile README. Render owner/owner repo's README on the
2013 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2014 // back to "<user>/.github" for org-style profile repos.
2015 let profileReadmeHtml: string | null = null;
2016 try {
2017 const candidates = [ownerName, ".github"];
2018 for (const rname of candidates) {
2019 if (await repoExists(ownerName, rname)) {
2020 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2021 const md = await getReadme(ownerName, rname, ref);
2022 if (md) {
2023 profileReadmeHtml = renderMarkdown(md);
2024 break;
2025 }
2026 }
2027 }
2028 } catch {
2029 profileReadmeHtml = null;
2030 }
2031
efb11c5Claude2032 const displayName = ownerUser?.displayName || ownerName;
2033 const memberSince = ownerUser?.createdAt
2034 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2035 : null;
2036
fc1817aClaude2037 return c.html(
06d5ffeClaude2038 <Layout title={ownerName} user={user}>
efb11c5Claude2039 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2040 <div class="profile-hero">
2041 <div class="profile-hero-orb-wrap" aria-hidden="true">
2042 <div class="profile-hero-orb" />
06d5ffeClaude2043 </div>
efb11c5Claude2044 <div class="profile-hero-inner">
2045 <div class="profile-hero-avatar" aria-hidden="true">
2046 {displayName[0].toUpperCase()}
2047 </div>
2048 <div class="profile-hero-text">
2049 <div class="profile-eyebrow">
2050 <strong>Developer</strong>
2051 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2052 <>
2053 {" "}· Joined{" "}
2054 {memberSince.toLocaleDateString("en-US", {
2055 month: "short",
2056 year: "numeric",
2057 })}
2058 </>
2059 )}
2060 </div>
2061 <h1 class="profile-name">
2062 <span class="gradient-text">{displayName}</span>
2063 </h1>
2064 <div class="profile-handle">@{ownerName}</div>
2065 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2066 <div class="profile-meta">
2067 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2068 <strong>{followState.followers}</strong> follower
2069 {followState.followers === 1 ? "" : "s"}
2070 </a>
2071 <a href={`/${ownerName}/following`} class="profile-meta-link">
2072 <strong>{followState.following}</strong> following
2073 </a>
2074 <a href={`/${ownerName}`} class="profile-meta-link">
2075 <strong>{repos.length}</strong> repo
2076 {repos.length === 1 ? "" : "s"}
2077 </a>
2078 {canFollow && (
2079 <form
2080 method="post"
2081 action={`/${ownerName}/${
2082 followState.viewerFollows ? "unfollow" : "follow"
2083 }`}
2084 class="profile-follow-form"
7aa8b99Claude2085 >
efb11c5Claude2086 <button
2087 type="submit"
2088 class={`btn ${
2089 followState.viewerFollows ? "" : "btn-primary"
2090 } btn-sm`}
2091 >
2092 {followState.viewerFollows ? "Unfollow" : "Follow"}
2093 </button>
2094 </form>
2095 )}
2096 </div>
7aa8b99Claude2097 </div>
06d5ffeClaude2098 </div>
2099 </div>
d412586Claude2100 {profileReadmeHtml && (
efb11c5Claude2101 <div class="profile-readme">
2102 <div class="profile-readme-head">
2103 <span class="profile-readme-icon">{"☰"}</span>
2104 <span>{ownerName}/{ownerName} README.md</span>
2105 </div>
2106 <div
2107 class="markdown-body profile-readme-body"
2108 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2109 />
2110 </div>
d412586Claude2111 )}
efb11c5Claude2112 <div class="profile-section-head">
2113 <h2 class="profile-section-title">Repositories</h2>
2114 <span class="profile-section-count">{repos.length}</span>
2115 </div>
06d5ffeClaude2116 {repos.length === 0 ? (
efb11c5Claude2117 <div class="profile-empty">
2118 <p class="profile-empty-text">
2119 No repositories yet
2120 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2121 </p>
2122 {user?.id === ownerUser?.id && (
2123 <a href="/new" class="btn btn-primary btn-sm">
2124 + Create your first
2125 </a>
2126 )}
2127 </div>
06d5ffeClaude2128 ) : (
2129 <div class="card-grid">
2130 {repos.map((repo) => (
2131 <RepoCard repo={repo} ownerName={ownerName} />
2132 ))}
2133 </div>
2134 )}
fc1817aClaude2135 </Layout>
2136 );
2137});
2138
06d5ffeClaude2139// Star/unstar a repo
2140web.post("/:owner/:repo/star", requireAuth, async (c) => {
2141 const { owner: ownerName, repo: repoName } = c.req.param();
2142 const user = c.get("user")!;
2143
2144 try {
2145 const [ownerUser] = await db
2146 .select()
2147 .from(users)
2148 .where(eq(users.username, ownerName))
2149 .limit(1);
2150 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2151
2152 const [repo] = await db
2153 .select()
2154 .from(repositories)
2155 .where(
2156 and(
2157 eq(repositories.ownerId, ownerUser.id),
2158 eq(repositories.name, repoName)
2159 )
2160 )
2161 .limit(1);
2162 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2163
2164 // Toggle star
2165 const [existing] = await db
2166 .select()
2167 .from(stars)
2168 .where(
2169 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2170 )
2171 .limit(1);
2172
2173 if (existing) {
2174 await db.delete(stars).where(eq(stars.id, existing.id));
2175 await db
2176 .update(repositories)
2177 .set({ starCount: Math.max(0, repo.starCount - 1) })
2178 .where(eq(repositories.id, repo.id));
2179 } else {
2180 await db.insert(stars).values({
2181 userId: user.id,
2182 repositoryId: repo.id,
2183 });
2184 await db
2185 .update(repositories)
2186 .set({ starCount: repo.starCount + 1 })
2187 .where(eq(repositories.id, repo.id));
2188 }
2189 } catch {
2190 // DB error — ignore
2191 }
2192
2193 return c.redirect(`/${ownerName}/${repoName}`);
2194});
2195
fc1817aClaude2196// Repository overview — file tree at HEAD
2197web.get("/:owner/:repo", async (c) => {
2198 const { owner, repo } = c.req.param();
06d5ffeClaude2199 const user = c.get("user");
fc1817aClaude2200
f1dc7c7Claude2201 // ── Loading skeleton (flag-gated) ──
2202 // Renders an SSR'd shell with file-tree + README placeholders when
2203 // `?skeleton=1` is set. Lets the user see the page structure before
2204 // git ops finish. Behind a flag for now so we never flash before the
2205 // real content lands.
2206 if (c.req.query("skeleton") === "1") {
2207 return c.html(
2208 <Layout title={`${owner}/${repo}`} user={user}>
2209 <style
2210 dangerouslySetInnerHTML={{
2211 __html: `
2212 .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; }
2213 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2214 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2215 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2216 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2217 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2218 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2219 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2220 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2221 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2222 .repo-skel-readme { height: 320px; border-radius: 12px; }
2223 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2224 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2225 `,
2226 }}
2227 />
2228 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2229 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2230 <div class="repo-skel-grid" aria-hidden="true">
2231 <div>
2232 <div class="repo-skel repo-skel-branch" />
2233 <div class="repo-skel-tree">
2234 {Array.from({ length: 8 }).map(() => (
2235 <div class="repo-skel repo-skel-tree-row" />
2236 ))}
2237 </div>
2238 <div class="repo-skel repo-skel-readme" />
2239 </div>
2240 <aside class="repo-skel-side">
2241 <div class="repo-skel repo-skel-side-card" />
2242 <div class="repo-skel repo-skel-side-card" />
2243 </aside>
2244 </div>
2245 <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">
2246 Loading {owner}/{repo}…
2247 </span>
2248 </Layout>
2249 );
2250 }
2251
8f50ed0Claude2252 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2253 trackByName(owner, repo, "view", {
2254 userId: user?.id || null,
2255 path: `/${owner}/${repo}`,
2256 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2257 userAgent: c.req.header("user-agent") || null,
2258 referer: c.req.header("referer") || null,
a28cedeClaude2259 }).catch((err) => {
2260 console.warn(
2261 `[web] view tracking failed for ${owner}/${repo}:`,
2262 err instanceof Error ? err.message : err
2263 );
2264 });
8f50ed0Claude2265
fc1817aClaude2266 if (!(await repoExists(owner, repo))) {
2267 return c.html(
06d5ffeClaude2268 <Layout title="Not Found" user={user}>
fc1817aClaude2269 <div class="empty-state">
2270 <h2>Repository not found</h2>
2271 <p>
2272 {owner}/{repo} does not exist.
2273 </p>
2274 </div>
2275 </Layout>,
2276 404
2277 );
2278 }
2279
05b973eClaude2280 // Parallelize all independent operations
2281 const [defaultBranch, branches] = await Promise.all([
2282 getDefaultBranch(owner, repo).then((b) => b || "main"),
2283 listBranches(owner, repo),
2284 ]);
2285 const [tree, starInfo] = await Promise.all([
2286 getTree(owner, repo, defaultBranch),
2287 // Star info fetched in parallel with tree
2288 (async () => {
2289 try {
2290 const [ownerUser] = await db
2291 .select()
2292 .from(users)
2293 .where(eq(users.username, owner))
2294 .limit(1);
71cd5ecClaude2295 if (!ownerUser)
2296 return {
2297 starCount: 0,
2298 starred: false,
2299 archived: false,
2300 isTemplate: false,
544d842Claude2301 forkCount: 0,
2302 description: null as string | null,
2303 pushedAt: null as Date | null,
2304 createdAt: null as Date | null,
cb5a796Claude2305 repoId: null as string | null,
2306 repoOwnerId: null as string | null,
71cd5ecClaude2307 };
05b973eClaude2308 const [repoRow] = await db
2309 .select()
2310 .from(repositories)
2311 .where(
2312 and(
2313 eq(repositories.ownerId, ownerUser.id),
2314 eq(repositories.name, repo)
2315 )
06d5ffeClaude2316 )
05b973eClaude2317 .limit(1);
71cd5ecClaude2318 if (!repoRow)
2319 return {
2320 starCount: 0,
2321 starred: false,
2322 archived: false,
2323 isTemplate: false,
544d842Claude2324 forkCount: 0,
2325 description: null as string | null,
2326 pushedAt: null as Date | null,
2327 createdAt: null as Date | null,
cb5a796Claude2328 repoId: null as string | null,
2329 repoOwnerId: null as string | null,
71cd5ecClaude2330 };
05b973eClaude2331 let starred = false;
06d5ffeClaude2332 if (user) {
2333 const [star] = await db
2334 .select()
2335 .from(stars)
2336 .where(
2337 and(
2338 eq(stars.userId, user.id),
2339 eq(stars.repositoryId, repoRow.id)
2340 )
2341 )
2342 .limit(1);
2343 starred = !!star;
2344 }
71cd5ecClaude2345 return {
2346 starCount: repoRow.starCount,
2347 starred,
2348 archived: repoRow.isArchived,
2349 isTemplate: repoRow.isTemplate,
544d842Claude2350 forkCount: repoRow.forkCount,
2351 description: repoRow.description as string | null,
2352 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2353 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2354 repoId: repoRow.id as string,
2355 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2356 };
05b973eClaude2357 } catch {
71cd5ecClaude2358 return {
2359 starCount: 0,
2360 starred: false,
2361 archived: false,
2362 isTemplate: false,
544d842Claude2363 forkCount: 0,
2364 description: null as string | null,
2365 pushedAt: null as Date | null,
2366 createdAt: null as Date | null,
cb5a796Claude2367 repoId: null as string | null,
2368 repoOwnerId: null as string | null,
71cd5ecClaude2369 };
06d5ffeClaude2370 }
05b973eClaude2371 })(),
2372 ]);
544d842Claude2373 const {
2374 starCount,
2375 starred,
2376 archived,
2377 isTemplate,
2378 forkCount,
2379 description,
2380 pushedAt,
2381 createdAt,
cb5a796Claude2382 repoId,
2383 repoOwnerId,
544d842Claude2384 } = starInfo;
2385
91a0204Claude2386 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2387 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2388 // and the badge simply doesn't render.
2389 let healthScore: HealthScore | null = null;
2390 if (repoId) {
2391 try {
2392 healthScore = await computeHealthScore(repoId);
2393 } catch {
2394 // swallow — badge is optional
2395 }
2396 }
2397
cb5a796Claude2398 // Pending-comments banner data (lazy + best-effort). Only the repo
2399 // owner sees the banner, so non-owner views skip the DB hit entirely.
2400 let repoHomePendingCount = 0;
2401 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2402 try {
2403 const { countPendingForRepo } = await import(
2404 "../lib/comment-moderation"
2405 );
2406 repoHomePendingCount = await countPendingForRepo(repoId);
2407 } catch {
2408 /* swallow */
2409 }
2410 }
2411
544d842Claude2412 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2413 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2414 const repoHomeCss = `
2415 .repo-home-hero {
2416 position: relative;
2417 margin-bottom: var(--space-5);
2418 padding: var(--space-5) var(--space-6);
2419 background: var(--bg-elevated);
2420 border: 1px solid var(--border);
2421 border-radius: 16px;
2422 overflow: hidden;
2423 }
2424 .repo-home-hero::before {
2425 content: '';
2426 position: absolute;
2427 top: 0; left: 0; right: 0;
2428 height: 2px;
2429 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2430 opacity: 0.7;
2431 pointer-events: none;
2432 }
2433 .repo-home-hero-orb-wrap {
2434 position: absolute;
2435 inset: -25% -10% auto auto;
2436 width: 360px;
2437 height: 360px;
2438 pointer-events: none;
2439 z-index: 0;
2440 }
2441 .repo-home-hero-orb {
2442 position: absolute;
2443 inset: 0;
2444 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
2445 filter: blur(80px);
2446 opacity: 0.7;
2447 animation: repoHomeOrb 14s ease-in-out infinite;
2448 }
2449 @keyframes repoHomeOrb {
2450 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
2451 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
2452 }
2453 @media (prefers-reduced-motion: reduce) {
2454 .repo-home-hero-orb { animation: none; }
2455 }
2456 .repo-home-hero-inner {
2457 position: relative;
2458 z-index: 1;
2459 }
2460 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
2461 .repo-home-eyebrow {
2462 font-size: 12px;
2463 font-family: var(--font-mono);
2464 color: var(--text-muted);
2465 letter-spacing: 0.1em;
2466 text-transform: uppercase;
2467 margin-bottom: var(--space-2);
2468 }
2469 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
2470 .repo-home-description {
2471 font-size: 15px;
2472 line-height: 1.55;
2473 color: var(--text);
2474 margin: 0;
2475 max-width: 720px;
2476 }
2477 .repo-home-description-empty {
2478 font-size: 14px;
2479 color: var(--text-muted);
2480 font-style: italic;
2481 margin: 0;
2482 }
2483 .repo-home-stat-row {
2484 display: flex;
2485 flex-wrap: wrap;
2486 gap: var(--space-4);
2487 margin-top: var(--space-3);
2488 font-size: 13px;
2489 color: var(--text-muted);
2490 }
2491 .repo-home-stat {
2492 display: inline-flex;
2493 align-items: center;
2494 gap: 6px;
2495 }
2496 .repo-home-stat strong {
2497 color: var(--text-strong);
2498 font-weight: 600;
2499 font-variant-numeric: tabular-nums;
2500 }
2501 .repo-home-stat .repo-home-stat-icon {
2502 color: var(--text-faint);
2503 font-size: 14px;
2504 line-height: 1;
2505 }
2506 .repo-home-stat a {
2507 color: var(--text-muted);
2508 transition: color var(--t-fast) var(--ease);
2509 }
2510 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
2511
2512 /* Two-column layout: file tree + sidebar */
2513 .repo-home-grid {
2514 display: grid;
2515 grid-template-columns: minmax(0, 1fr) 280px;
2516 gap: var(--space-5);
2517 align-items: start;
2518 }
2519 @media (max-width: 960px) {
2520 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
2521 }
2522 .repo-home-main { min-width: 0; }
2523
2524 /* Sidebar card */
2525 .repo-home-side {
2526 display: flex;
2527 flex-direction: column;
2528 gap: var(--space-4);
2529 }
2530 .repo-home-side-card {
2531 background: var(--bg-elevated);
2532 border: 1px solid var(--border);
2533 border-radius: 12px;
2534 padding: var(--space-4);
2535 }
2536 .repo-home-side-title {
2537 font-size: 11px;
2538 font-family: var(--font-mono);
2539 letter-spacing: 0.12em;
2540 text-transform: uppercase;
2541 color: var(--text-muted);
2542 margin: 0 0 var(--space-3);
2543 font-weight: 600;
2544 }
2545 .repo-home-side-row {
2546 display: flex;
2547 justify-content: space-between;
2548 align-items: center;
2549 gap: var(--space-2);
2550 font-size: 13px;
2551 padding: 6px 0;
2552 border-top: 1px solid var(--border);
2553 }
2554 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
2555 .repo-home-side-key {
2556 color: var(--text-muted);
2557 display: inline-flex;
2558 align-items: center;
2559 gap: 6px;
2560 }
2561 .repo-home-side-val {
2562 color: var(--text-strong);
2563 font-weight: 500;
2564 font-variant-numeric: tabular-nums;
2565 max-width: 60%;
2566 text-align: right;
2567 overflow: hidden;
2568 text-overflow: ellipsis;
2569 white-space: nowrap;
2570 }
2571 .repo-home-side-val a { color: var(--text-strong); }
2572 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
2573
2574 /* Clone / Code tabs */
2575 .repo-home-clone {
2576 background: var(--bg-elevated);
2577 border: 1px solid var(--border);
2578 border-radius: 12px;
2579 overflow: hidden;
2580 }
2581 .repo-home-clone-tabs {
2582 display: flex;
2583 gap: 0;
2584 background: var(--bg-secondary);
2585 border-bottom: 1px solid var(--border);
2586 padding: 0 var(--space-2);
2587 }
2588 .repo-home-clone-tab {
2589 appearance: none;
2590 background: transparent;
2591 border: 0;
2592 border-bottom: 2px solid transparent;
2593 padding: 9px 12px;
2594 font-size: 12px;
2595 font-weight: 500;
2596 color: var(--text-muted);
2597 cursor: pointer;
2598 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2599 font-family: inherit;
2600 margin-bottom: -1px;
2601 }
2602 .repo-home-clone-tab:hover { color: var(--text-strong); }
2603 .repo-home-clone-tab[aria-selected="true"] {
2604 color: var(--text-strong);
2605 border-bottom-color: var(--accent);
2606 }
2607 .repo-home-clone-body {
2608 padding: var(--space-3);
2609 display: flex;
2610 align-items: center;
2611 gap: var(--space-2);
2612 }
2613 .repo-home-clone-input {
2614 flex: 1;
2615 min-width: 0;
2616 font-family: var(--font-mono);
2617 font-size: 12px;
2618 background: var(--bg);
2619 border: 1px solid var(--border);
2620 border-radius: 8px;
2621 padding: 8px 10px;
2622 color: var(--text-strong);
2623 overflow: hidden;
2624 text-overflow: ellipsis;
2625 white-space: nowrap;
2626 }
2627 .repo-home-clone-copy {
2628 appearance: none;
2629 background: var(--bg-secondary);
2630 border: 1px solid var(--border);
2631 color: var(--text-strong);
2632 border-radius: 8px;
2633 padding: 8px 12px;
2634 font-size: 12px;
2635 font-weight: 600;
2636 cursor: pointer;
2637 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2638 font-family: inherit;
2639 }
2640 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
2641 .repo-home-clone-pane { display: none; }
2642 .repo-home-clone-pane[data-active="true"] { display: flex; }
2643
2644 /* README card */
2645 .repo-home-readme {
2646 margin-top: var(--space-5);
2647 background: var(--bg-elevated);
2648 border: 1px solid var(--border);
2649 border-radius: 12px;
2650 overflow: hidden;
2651 }
2652 .repo-home-readme-head {
2653 display: flex;
2654 align-items: center;
2655 gap: 8px;
2656 padding: 10px 16px;
2657 background: var(--bg-secondary);
2658 border-bottom: 1px solid var(--border);
2659 font-size: 13px;
2660 color: var(--text-muted);
2661 }
2662 .repo-home-readme-head .repo-home-readme-icon {
2663 color: var(--accent);
2664 font-size: 14px;
2665 }
2666 .repo-home-readme-body {
2667 padding: var(--space-5) var(--space-6);
2668 }
2669
2670 /* Empty-state CTA */
2671 .repo-home-empty {
2672 position: relative;
2673 margin-top: var(--space-4);
2674 background: var(--bg-elevated);
2675 border: 1px solid var(--border);
2676 border-radius: 16px;
2677 padding: var(--space-6);
2678 overflow: hidden;
2679 }
2680 .repo-home-empty::before {
2681 content: '';
2682 position: absolute;
2683 top: 0; left: 0; right: 0;
2684 height: 2px;
2685 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2686 opacity: 0.7;
2687 pointer-events: none;
2688 }
2689 .repo-home-empty-eyebrow {
2690 font-size: 12px;
2691 font-family: var(--font-mono);
2692 color: var(--accent);
2693 letter-spacing: 0.12em;
2694 text-transform: uppercase;
2695 margin-bottom: var(--space-2);
2696 font-weight: 600;
2697 }
2698 .repo-home-empty-title {
2699 font-family: var(--font-display);
2700 font-weight: 800;
2701 letter-spacing: -0.025em;
2702 font-size: clamp(22px, 3vw, 30px);
2703 line-height: 1.1;
2704 margin: 0 0 var(--space-2);
2705 color: var(--text-strong);
2706 }
2707 .repo-home-empty-sub {
2708 color: var(--text-muted);
2709 font-size: 14px;
2710 line-height: 1.55;
2711 max-width: 640px;
2712 margin: 0 0 var(--space-4);
2713 }
2714 .repo-home-empty-snippet {
2715 background: var(--bg);
2716 border: 1px solid var(--border);
2717 border-radius: 10px;
2718 padding: var(--space-3) var(--space-4);
2719 font-family: var(--font-mono);
2720 font-size: 12.5px;
2721 line-height: 1.7;
2722 color: var(--text-strong);
2723 overflow-x: auto;
2724 white-space: pre;
2725 margin: 0;
2726 }
2727 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2728 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2729
91a0204Claude2730 /* Health score badge */
2731 .repo-health-badge {
2732 display: inline-flex;
2733 align-items: center;
2734 gap: 5px;
2735 padding: 3px 10px;
2736 border-radius: 999px;
2737 font-size: 11.5px;
2738 font-weight: 700;
2739 font-family: var(--font-mono);
2740 text-decoration: none;
2741 border: 1px solid transparent;
2742 transition: filter 120ms ease, opacity 120ms ease;
2743 vertical-align: middle;
2744 }
2745 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
2746 .repo-health-badge-elite {
2747 background: rgba(52,211,153,0.15);
2748 color: #6ee7b7;
2749 border-color: rgba(52,211,153,0.30);
2750 }
2751 .repo-health-badge-strong {
2752 background: rgba(96,165,250,0.15);
2753 color: #93c5fd;
2754 border-color: rgba(96,165,250,0.30);
2755 }
2756 .repo-health-badge-improving {
2757 background: rgba(251,191,36,0.15);
2758 color: #fde68a;
2759 border-color: rgba(251,191,36,0.30);
2760 }
2761 .repo-health-badge-needs-attention {
2762 background: rgba(248,113,113,0.15);
2763 color: #fca5a5;
2764 border-color: rgba(248,113,113,0.30);
2765 }
2766
2767 /* Three-option empty-state panel */
2768 .repo-empty-options {
2769 display: grid;
2770 grid-template-columns: repeat(3, 1fr);
2771 gap: var(--space-3);
2772 margin-top: var(--space-5);
2773 }
2774 @media (max-width: 760px) {
2775 .repo-empty-options { grid-template-columns: 1fr; }
2776 }
2777 .repo-empty-option {
2778 position: relative;
2779 background: var(--bg-elevated);
2780 border: 1px solid var(--border);
2781 border-radius: 14px;
2782 padding: var(--space-4) var(--space-4);
2783 display: flex;
2784 flex-direction: column;
2785 gap: var(--space-2);
2786 overflow: hidden;
2787 transition: border-color 140ms ease, background 140ms ease;
2788 }
2789 .repo-empty-option:hover {
2790 border-color: rgba(140,109,255,0.45);
2791 background: rgba(140,109,255,0.04);
2792 }
2793 .repo-empty-option::before {
2794 content: '';
2795 position: absolute;
2796 top: 0; left: 0; right: 0;
2797 height: 2px;
2798 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2799 opacity: 0.55;
2800 pointer-events: none;
2801 }
2802 .repo-empty-option-label {
2803 font-size: 10px;
2804 font-family: var(--font-mono);
2805 font-weight: 700;
2806 letter-spacing: 0.12em;
2807 text-transform: uppercase;
2808 color: var(--accent);
2809 margin-bottom: 2px;
2810 }
2811 .repo-empty-option-title {
2812 font-family: var(--font-display);
2813 font-weight: 700;
2814 font-size: 16px;
2815 letter-spacing: -0.01em;
2816 color: var(--text-strong);
2817 margin: 0;
2818 }
2819 .repo-empty-option-sub {
2820 font-size: 13px;
2821 color: var(--text-muted);
2822 line-height: 1.5;
2823 margin: 0;
2824 flex: 1;
2825 }
2826 .repo-empty-option-snippet {
2827 background: var(--bg);
2828 border: 1px solid var(--border);
2829 border-radius: 8px;
2830 padding: var(--space-2) var(--space-3);
2831 font-family: var(--font-mono);
2832 font-size: 11.5px;
2833 line-height: 1.75;
2834 color: var(--text-strong);
2835 overflow-x: auto;
2836 white-space: pre;
2837 margin: var(--space-1) 0 0;
2838 }
2839 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
2840 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
2841 .repo-empty-option-cta {
2842 display: inline-flex;
2843 align-items: center;
2844 gap: 6px;
2845 margin-top: auto;
2846 padding-top: var(--space-2);
2847 font-size: 13px;
2848 font-weight: 600;
2849 color: var(--accent);
2850 text-decoration: none;
2851 transition: color 120ms ease;
2852 }
2853 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
2854
544d842Claude2855 @media (max-width: 720px) {
2856 .repo-home-hero { padding: var(--space-4) var(--space-4); }
2857 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
2858 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude2859 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
2860 .repo-home-side-val { max-width: 55%; }
2861 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
2862 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
2863 .repo-home-side-card { padding: var(--space-3); }
544d842Claude2864 }
2865 `;
2866 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude2867 // SSH URL — port-aware:
2868 // Standard port 22 → git@host:owner/repo.git
2869 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude2870 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
2871 try {
2872 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude2873 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
2874 ? host
2875 : "localhost";
2876 const sshPort = config.sshPort;
2877 if (sshPort === 22) {
2878 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
2879 } else {
2880 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude2881 }
2882 } catch {
2883 // Fall through to default.
2884 }
2885 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
2886 const formatRelative = (date: Date | null): string => {
2887 if (!date) return "never";
2888 const ms = Date.now() - date.getTime();
2889 const s = Math.max(0, Math.round(ms / 1000));
2890 if (s < 60) return "just now";
2891 const m = Math.round(s / 60);
2892 if (m < 60) return `${m} min ago`;
2893 const h = Math.round(m / 60);
2894 if (h < 24) return `${h}h ago`;
2895 const d = Math.round(h / 24);
2896 if (d < 30) return `${d}d ago`;
2897 const mo = Math.round(d / 30);
2898 if (mo < 12) return `${mo}mo ago`;
2899 const y = Math.round(d / 365);
2900 return `${y}y ago`;
2901 };
06d5ffeClaude2902
fc1817aClaude2903 if (tree.length === 0) {
5acce80Claude2904 const repoOgDesc = description
2905 ? `${owner}/${repo} on Gluecron — ${description}`
2906 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude2907 return c.html(
5acce80Claude2908 <Layout
2909 title={`${owner}/${repo}`}
2910 user={user}
2911 description={repoOgDesc}
2912 ogTitle={`${owner}/${repo} — Gluecron`}
2913 ogDescription={repoOgDesc}
2914 twitterCard="summary"
2915 >
544d842Claude2916 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude2917 <style dangerouslySetInnerHTML={{ __html: `
2918 .empty-options-grid {
2919 display: grid;
2920 grid-template-columns: repeat(3, 1fr);
2921 gap: var(--space-4);
2922 margin-top: var(--space-4);
2923 }
2924 @media (max-width: 800px) {
2925 .empty-options-grid { grid-template-columns: 1fr; }
2926 }
2927 .empty-option-card {
2928 position: relative;
2929 background: var(--bg-elevated);
2930 border: 1px solid var(--border);
2931 border-radius: 14px;
2932 padding: var(--space-5);
2933 display: flex;
2934 flex-direction: column;
2935 gap: var(--space-3);
2936 overflow: hidden;
2937 transition: border-color 140ms ease, box-shadow 140ms ease;
2938 }
2939 .empty-option-card:hover {
2940 border-color: rgba(140,109,255,0.45);
2941 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
2942 }
2943 .empty-option-card::before {
2944 content: '';
2945 position: absolute;
2946 top: 0; left: 0; right: 0;
2947 height: 2px;
2948 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2949 opacity: 0.55;
2950 pointer-events: none;
2951 }
2952 .empty-option-label {
2953 font-size: 10.5px;
2954 font-family: var(--font-mono);
2955 text-transform: uppercase;
2956 letter-spacing: 0.14em;
2957 color: var(--accent);
2958 font-weight: 700;
2959 }
2960 .empty-option-title {
2961 font-family: var(--font-display);
2962 font-weight: 700;
2963 font-size: 17px;
2964 letter-spacing: -0.01em;
2965 color: var(--text-strong);
2966 margin: 0;
2967 line-height: 1.25;
2968 }
2969 .empty-option-body {
2970 font-size: 13px;
2971 color: var(--text-muted);
2972 line-height: 1.55;
2973 margin: 0;
2974 flex: 1;
2975 }
2976 .empty-option-snippet {
2977 background: var(--bg);
2978 border: 1px solid var(--border);
2979 border-radius: 8px;
2980 padding: var(--space-3) var(--space-4);
2981 font-family: var(--font-mono);
2982 font-size: 11.5px;
2983 line-height: 1.75;
2984 color: var(--text-strong);
2985 overflow-x: auto;
2986 white-space: pre;
2987 margin: 0;
2988 }
2989 .empty-option-snippet .ec { color: var(--text-faint); }
2990 .empty-option-snippet .em { color: var(--accent); }
2991 .empty-option-cta {
2992 display: inline-flex;
2993 align-items: center;
2994 gap: 6px;
2995 padding: 8px 16px;
2996 font-size: 13px;
2997 font-weight: 600;
2998 color: var(--text-strong);
2999 background: var(--bg-secondary);
3000 border: 1px solid var(--border);
3001 border-radius: 9999px;
3002 text-decoration: none;
3003 align-self: flex-start;
3004 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3005 }
3006 .empty-option-cta:hover {
3007 border-color: rgba(140,109,255,0.55);
3008 background: rgba(140,109,255,0.08);
3009 color: var(--accent);
3010 text-decoration: none;
3011 }
3012 .empty-option-cta-accent {
3013 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3014 border-color: transparent;
3015 color: #fff;
3016 }
3017 .empty-option-cta-accent:hover {
3018 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3019 border-color: transparent;
3020 color: #fff;
3021 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3022 }
3023 ` }} />
544d842Claude3024 <div class="repo-home-hero">
3025 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3026 <div class="repo-home-hero-orb" />
3027 </div>
3028 <div class="repo-home-hero-inner">
3029 <div class="repo-home-eyebrow">
3030 <strong>Repository</strong> · {owner}
3031 </div>
91a0204Claude3032 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3033 <RepoHeader
3034 owner={owner}
3035 repo={repo}
3036 starCount={starCount}
3037 starred={starred}
3038 forkCount={forkCount}
3039 currentUser={user?.username}
3040 archived={archived}
3041 isTemplate={isTemplate}
3042 />
3043 {healthScore && (() => {
3044 const gradeLabel: Record<string, string> = {
3045 elite: "Elite", strong: "Strong",
3046 improving: "Improving", "needs-attention": "Needs Attention",
3047 };
3048 return (
3049 <a
3050 href={`/${owner}/${repo}/insights/health`}
3051 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3052 title={`Health score: ${healthScore!.total}/100`}
3053 >
3054 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3055 </a>
3056 );
3057 })()}
3058 </div>
544d842Claude3059 {description ? (
3060 <p class="repo-home-description">{description}</p>
3061 ) : (
3062 <p class="repo-home-description-empty">
3063 No description yet — push a README to tell the world what this
3064 ships.
3065 </p>
3066 )}
3067 </div>
3068 </div>
fc1817aClaude3069 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3070 <div style="margin-top:var(--space-4)">
3071 <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)">
3072 Getting started
3073 </div>
544d842Claude3074 <h2 class="repo-home-empty-title">
91a0204Claude3075 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3076 </h2>
3077 <p class="repo-home-empty-sub">
91a0204Claude3078 Choose how you want to kick things off. Every push wires up gate
3079 checks and AI review automatically.
544d842Claude3080 </p>
91a0204Claude3081 <div class="empty-options-grid">
3082 <div class="empty-option-card">
3083 <div class="empty-option-label">Option A</div>
3084 <h3 class="empty-option-title">Push your first commit</h3>
3085 <p class="empty-option-body">
3086 Wire up an existing project in seconds. Run these commands from
3087 your project directory.
3088 </p>
3089 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3090<span class="em">git remote add</span> origin {cloneHttpsUrl}
3091<span class="em">git branch</span> -M main
3092<span class="em">git push</span> -u origin main</pre>
3093 </div>
3094 <div class="empty-option-card">
3095 <div class="empty-option-label">Option B</div>
3096 <h3 class="empty-option-title">Import from GitHub</h3>
3097 <p class="empty-option-body">
3098 Mirror an existing GitHub repository here in one click. Gluecron
3099 syncs the full history and branches.
3100 </p>
3101 <a href="/import" class="empty-option-cta">
3102 Import repository
3103 </a>
3104 </div>
3105 <div class="empty-option-card">
3106 <div class="empty-option-label">Option C</div>
3107 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3108 <p class="empty-option-body">
3109 Let AI write your first feature. Describe what you want to build
3110 and Gluecron opens a pull request with the code.
3111 </p>
3112 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3113 Let AI write your first feature
3114 </a>
3115 </div>
3116 </div>
fc1817aClaude3117 </div>
3118 </Layout>
3119 );
3120 }
3121
3122 const readme = await getReadme(owner, repo, defaultBranch);
3123
544d842Claude3124 // Sidebar facts — derived from data we already have.
3125 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3126 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3127
5acce80Claude3128 const repoOgDesc = description
3129 ? `${owner}/${repo} on Gluecron — ${description}`
3130 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3131
fc1817aClaude3132 return c.html(
5acce80Claude3133 <Layout
3134 title={`${owner}/${repo}`}
3135 user={user}
3136 description={repoOgDesc}
3137 ogTitle={`${owner}/${repo} — Gluecron`}
3138 ogDescription={repoOgDesc}
3139 twitterCard="summary"
3140 >
544d842Claude3141 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3142 <div class="repo-home-hero">
3143 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3144 <div class="repo-home-hero-orb" />
3145 </div>
3146 <div class="repo-home-hero-inner">
3147 <div class="repo-home-eyebrow">
3148 <strong>Repository</strong> · {owner}
3149 </div>
91a0204Claude3150 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3151 <RepoHeader
3152 owner={owner}
3153 repo={repo}
3154 starCount={starCount}
3155 starred={starred}
3156 forkCount={forkCount}
3157 currentUser={user?.username}
3158 archived={archived}
3159 isTemplate={isTemplate}
3160 />
3161 {healthScore && (() => {
3162 const gradeLabel: Record<string, string> = {
3163 elite: "Elite", strong: "Strong",
3164 improving: "Improving", "needs-attention": "Needs Attention",
3165 };
3166 return (
3167 <a
3168 href={`/${owner}/${repo}/insights/health`}
3169 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3170 title={`Health score: ${healthScore!.total}/100`}
3171 >
3172 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3173 </a>
3174 );
3175 })()}
3176 </div>
544d842Claude3177 {description ? (
3178 <p class="repo-home-description">{description}</p>
3179 ) : (
3180 <p class="repo-home-description-empty">
3181 No description yet.
3182 </p>
3183 )}
3184 <div class="repo-home-stat-row" aria-label="Repository stats">
3185 <span class="repo-home-stat" title="Default branch">
3186 <span class="repo-home-stat-icon">{"⎇"}</span>
3187 <strong>{defaultBranch}</strong>
3188 </span>
3189 <a
3190 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3191 class="repo-home-stat"
3192 title="Browse all branches"
3193 >
3194 <span class="repo-home-stat-icon">{"⊢"}</span>
3195 <strong>{branches.length}</strong>{" "}
3196 branch{branches.length === 1 ? "" : "es"}
3197 </a>
3198 <span class="repo-home-stat" title="Top-level entries">
3199 <span class="repo-home-stat-icon">{"■"}</span>
3200 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3201 {dirCount > 0 && (
3202 <>
3203 {" · "}
3204 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3205 </>
3206 )}
3207 </span>
3208 {pushedAt && (
3209 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3210 <span class="repo-home-stat-icon">{"↻"}</span>
3211 Updated <strong>{formatRelative(pushedAt)}</strong>
3212 </span>
3213 )}
3214 </div>
3215 </div>
3216 </div>
71cd5ecClaude3217 {isTemplate && user && user.username !== owner && (
3218 <div
3219 class="panel"
dc26881CC LABS App3220 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3221 >
3222 <div style="font-size:13px">
3223 <strong>Template repository.</strong> Create a new repository from
3224 this template's files.
3225 </div>
3226 <form
001af43Claude3227 method="post"
71cd5ecClaude3228 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3229 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3230 >
3231 <input
3232 type="text"
3233 name="name"
3234 placeholder="new-repo-name"
3235 required
2c3ba6ecopilot-swe-agent[bot]3236 aria-label="New repository name"
71cd5ecClaude3237 style="width:200px"
3238 />
3239 <button type="submit" class="btn btn-primary">
3240 Use this template
3241 </button>
3242 </form>
3243 </div>
3244 )}
fc1817aClaude3245 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3246 <RepoHomePendingBanner
3247 owner={owner}
3248 repo={repo}
3249 count={repoHomePendingCount}
3250 />
c6018a5Claude3251 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3252 row sits just below the nav as a slim CTA strip. Scoped under
3253 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3254 <style
3255 dangerouslySetInnerHTML={{
3256 __html: `
3257 .repo-ai-cta-row {
3258 display: flex;
3259 flex-wrap: wrap;
3260 gap: 8px;
3261 margin: 12px 0 18px;
3262 padding: 10px 14px;
3263 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3264 border: 1px solid var(--border);
3265 border-radius: 10px;
3266 position: relative;
3267 overflow: hidden;
3268 }
3269 .repo-ai-cta-row::before {
3270 content: '';
3271 position: absolute;
3272 top: 0; left: 0; right: 0;
3273 height: 1px;
3274 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3275 opacity: 0.7;
3276 pointer-events: none;
3277 }
3278 .repo-ai-cta-label {
3279 font-size: 11px;
3280 font-weight: 600;
3281 letter-spacing: 0.06em;
3282 text-transform: uppercase;
3283 color: var(--accent);
3284 align-self: center;
3285 padding-right: 8px;
3286 border-right: 1px solid var(--border);
3287 margin-right: 4px;
3288 }
3289 .repo-ai-cta {
3290 display: inline-flex;
3291 align-items: center;
3292 gap: 6px;
3293 padding: 5px 10px;
3294 font-size: 12.5px;
3295 font-weight: 500;
3296 color: var(--text);
3297 background: var(--bg);
3298 border: 1px solid var(--border);
3299 border-radius: 7px;
3300 text-decoration: none;
3301 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3302 }
3303 .repo-ai-cta:hover {
3304 border-color: rgba(140,109,255,0.45);
3305 color: var(--text-strong);
3306 background: rgba(140,109,255,0.06);
3307 text-decoration: none;
3308 }
3309 .repo-ai-cta-icon {
3310 opacity: 0.75;
3311 font-size: 12px;
3312 }
3313 @media (max-width: 640px) {
3314 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3315 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3316 }
3317 `,
3318 }}
3319 />
3320 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3321 <span class="repo-ai-cta-label">AI surfaces</span>
3322 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3323 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3324 Chat
3325 </a>
3326 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3327 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3328 Previews
3329 </a>
3330 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3331 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3332 Migrations
3333 </a>
3334 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
3335 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
3336 Semantic search
3337 </a>
3338 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3339 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3340 AI release notes
3341 </a>
3646bfeClaude3342 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3343 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3344 Dev environment
3345 </a>
c6018a5Claude3346 </nav>
544d842Claude3347 <div class="repo-home-grid">
3348 <div class="repo-home-main">
3349 <BranchSwitcher
3350 owner={owner}
3351 repo={repo}
3352 currentRef={defaultBranch}
3353 branches={branches}
3354 pathType="tree"
3355 />
3356 <FileTable
3357 entries={tree}
3358 owner={owner}
3359 repo={repo}
3360 ref={defaultBranch}
3361 path=""
3362 />
3363 {readme && (() => {
3364 const readmeHtml = renderMarkdown(readme);
3365 return (
3366 <div class="repo-home-readme">
3367 <div class="repo-home-readme-head">
3368 <span class="repo-home-readme-icon">{"☰"}</span>
3369 <span>README.md</span>
3370 </div>
3371 <style>{markdownCss}</style>
3372 <div class="markdown-body repo-home-readme-body">
3373 {html([readmeHtml] as unknown as TemplateStringsArray)}
3374 </div>
3375 </div>
3376 );
3377 })()}
3378 </div>
3379 <aside class="repo-home-side" aria-label="Repository details">
3380 <div class="repo-home-clone">
3381 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
3382 <button
3383 type="button"
3384 class="repo-home-clone-tab"
3385 role="tab"
3386 aria-selected="true"
3387 data-pane="https"
3388 data-repo-home-clone-tab
3389 >
3390 HTTPS
3391 </button>
3392 <button
3393 type="button"
3394 class="repo-home-clone-tab"
3395 role="tab"
3396 aria-selected="false"
3397 data-pane="ssh"
3398 data-repo-home-clone-tab
3399 >
3400 SSH
3401 </button>
3402 <button
3403 type="button"
3404 class="repo-home-clone-tab"
3405 role="tab"
3406 aria-selected="false"
3407 data-pane="cli"
3408 data-repo-home-clone-tab
3409 >
3410 CLI
3411 </button>
3412 </div>
3413 <div
3414 class="repo-home-clone-pane"
3415 data-pane="https"
3416 data-active="true"
3417 role="tabpanel"
3418 >
3419 <div class="repo-home-clone-body">
3420 <input
3421 class="repo-home-clone-input"
3422 type="text"
3423 value={cloneHttpsUrl}
3424 readonly
3425 aria-label="HTTPS clone URL"
3426 data-repo-home-clone-input
3427 />
3428 <button
3429 type="button"
3430 class="repo-home-clone-copy"
3431 data-repo-home-copy={cloneHttpsUrl}
3432 >
3433 Copy
3434 </button>
3435 </div>
3436 </div>
3437 <div
3438 class="repo-home-clone-pane"
3439 data-pane="ssh"
3440 data-active="false"
3441 role="tabpanel"
3442 >
3443 <div class="repo-home-clone-body">
3444 <input
3445 class="repo-home-clone-input"
3446 type="text"
3447 value={cloneSshUrl}
3448 readonly
3449 aria-label="SSH clone URL"
3450 data-repo-home-clone-input
3451 />
3452 <button
3453 type="button"
3454 class="repo-home-clone-copy"
3455 data-repo-home-copy={cloneSshUrl}
3456 >
3457 Copy
3458 </button>
3459 </div>
3460 </div>
3461 <div
3462 class="repo-home-clone-pane"
3463 data-pane="cli"
3464 data-active="false"
3465 role="tabpanel"
3466 >
3467 <div class="repo-home-clone-body">
3468 <input
3469 class="repo-home-clone-input"
3470 type="text"
3471 value={cloneCliCmd}
3472 readonly
3473 aria-label="Gluecron CLI clone command"
3474 data-repo-home-clone-input
3475 />
3476 <button
3477 type="button"
3478 class="repo-home-clone-copy"
3479 data-repo-home-copy={cloneCliCmd}
3480 >
3481 Copy
3482 </button>
3483 </div>
79136bbClaude3484 </div>
fc1817aClaude3485 </div>
544d842Claude3486 <div class="repo-home-side-card">
3487 <h3 class="repo-home-side-title">About</h3>
3488 <div class="repo-home-side-row">
3489 <span class="repo-home-side-key">Default branch</span>
3490 <span class="repo-home-side-val">{defaultBranch}</span>
3491 </div>
3492 <div class="repo-home-side-row">
3493 <span class="repo-home-side-key">Branches</span>
3494 <span class="repo-home-side-val">
3495 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
3496 {branches.length}
3497 </a>
3498 </span>
3499 </div>
3500 <div class="repo-home-side-row">
3501 <span class="repo-home-side-key">Stars</span>
3502 <span class="repo-home-side-val">{starCount}</span>
3503 </div>
3504 <div class="repo-home-side-row">
3505 <span class="repo-home-side-key">Forks</span>
3506 <span class="repo-home-side-val">{forkCount}</span>
3507 </div>
3508 {pushedAt && (
3509 <div class="repo-home-side-row">
3510 <span class="repo-home-side-key">Last push</span>
3511 <span class="repo-home-side-val">
3512 {formatRelative(pushedAt)}
3513 </span>
3514 </div>
3515 )}
3516 {createdAt && (
3517 <div class="repo-home-side-row">
3518 <span class="repo-home-side-key">Created</span>
3519 <span class="repo-home-side-val">
3520 {formatRelative(createdAt)}
3521 </span>
3522 </div>
3523 )}
3524 {(archived || isTemplate) && (
3525 <div class="repo-home-side-row">
3526 <span class="repo-home-side-key">State</span>
3527 <span class="repo-home-side-val">
3528 {archived ? "Archived" : "Template"}
3529 </span>
3530 </div>
3531 )}
3532 </div>
3533 </aside>
3534 </div>
3535 <script
3536 dangerouslySetInnerHTML={{
3537 __html: `
3538 (function(){
3539 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
3540 tabs.forEach(function(tab){
3541 tab.addEventListener('click', function(){
3542 var target = tab.getAttribute('data-pane');
3543 tabs.forEach(function(t){
3544 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
3545 });
3546 var panes = document.querySelectorAll('.repo-home-clone-pane');
3547 panes.forEach(function(p){
3548 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
3549 });
3550 });
3551 });
3552 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
3553 copyBtns.forEach(function(btn){
3554 btn.addEventListener('click', function(){
3555 var text = btn.getAttribute('data-repo-home-copy') || '';
3556 var done = function(){
3557 var prev = btn.textContent;
3558 btn.textContent = 'Copied';
3559 setTimeout(function(){ btn.textContent = prev; }, 1200);
3560 };
3561 if (navigator.clipboard && navigator.clipboard.writeText) {
3562 navigator.clipboard.writeText(text).then(done, done);
3563 } else {
3564 var ta = document.createElement('textarea');
3565 ta.value = text;
3566 document.body.appendChild(ta);
3567 ta.select();
3568 try { document.execCommand('copy'); } catch (e) {}
3569 document.body.removeChild(ta);
3570 done();
3571 }
3572 });
3573 });
3574 })();
3575 `,
3576 }}
3577 />
fc1817aClaude3578 </Layout>
3579 );
3580});
3581
3582// Browse tree at ref/path
3583web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
3584 const { owner, repo } = c.req.param();
06d5ffeClaude3585 const user = c.get("user");
fc1817aClaude3586 const refAndPath = c.req.param("ref");
3587
3588 const branches = await listBranches(owner, repo);
3589 let ref = "";
3590 let treePath = "";
3591
3592 for (const branch of branches) {
3593 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
3594 ref = branch;
3595 treePath = refAndPath.slice(branch.length + 1);
3596 break;
3597 }
3598 }
3599
3600 if (!ref) {
3601 const slashIdx = refAndPath.indexOf("/");
3602 if (slashIdx === -1) {
3603 ref = refAndPath;
3604 } else {
3605 ref = refAndPath.slice(0, slashIdx);
3606 treePath = refAndPath.slice(slashIdx + 1);
3607 }
3608 }
3609
3610 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude3611 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3612 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude3613
3614 return c.html(
06d5ffeClaude3615 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude3616 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3617 <RepoHeader owner={owner} repo={repo} />
3618 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3619 <div class="tree-header">
3620 <div class="tree-header-row">
3621 <BranchSwitcher
3622 owner={owner}
3623 repo={repo}
3624 currentRef={ref}
3625 branches={branches}
3626 pathType="tree"
3627 subPath={treePath}
3628 />
3629 <div class="tree-header-stats">
3630 <span class="tree-stat" title="Entries in this directory">
3631 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3632 {dirCount > 0 && (
3633 <>
3634 {" · "}
3635 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3636 </>
3637 )}
3638 </span>
3639 <a
3640 href={`/${owner}/${repo}/search`}
3641 class="tree-stat tree-stat-link"
3642 title="Search code in this repository"
3643 >
3644 {"⌕"} Search
3645 </a>
3646 </div>
3647 </div>
3648 <div class="tree-breadcrumb-row">
3649 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
3650 </div>
3651 </div>
fc1817aClaude3652 <FileTable
3653 entries={tree}
3654 owner={owner}
3655 repo={repo}
3656 ref={ref}
3657 path={treePath}
3658 />
3659 </Layout>
3660 );
3661});
3662
06d5ffeClaude3663// View file blob with syntax highlighting
fc1817aClaude3664web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
3665 const { owner, repo } = c.req.param();
06d5ffeClaude3666 const user = c.get("user");
fc1817aClaude3667 const refAndPath = c.req.param("ref");
3668
3669 const branches = await listBranches(owner, repo);
3670 let ref = "";
3671 let filePath = "";
3672
3673 for (const branch of branches) {
3674 if (refAndPath.startsWith(branch + "/")) {
3675 ref = branch;
3676 filePath = refAndPath.slice(branch.length + 1);
3677 break;
3678 }
3679 }
3680
3681 if (!ref) {
3682 const slashIdx = refAndPath.indexOf("/");
3683 if (slashIdx === -1) return c.text("Not found", 404);
3684 ref = refAndPath.slice(0, slashIdx);
3685 filePath = refAndPath.slice(slashIdx + 1);
3686 }
3687
3688 const blob = await getBlob(owner, repo, ref, filePath);
3689 if (!blob) {
3690 return c.html(
06d5ffeClaude3691 <Layout title="Not Found" user={user}>
fc1817aClaude3692 <div class="empty-state">
3693 <h2>File not found</h2>
3694 </div>
3695 </Layout>,
3696 404
3697 );
3698 }
3699
06d5ffeClaude3700 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3701 const lineCount = blob.isBinary
3702 ? 0
3703 : (blob.content.endsWith("\n")
3704 ? blob.content.split("\n").length - 1
3705 : blob.content.split("\n").length);
3706 const formatBytes = (n: number): string => {
3707 if (n < 1024) return `${n} B`;
3708 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3709 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3710 };
fc1817aClaude3711
3712 return c.html(
06d5ffeClaude3713 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3714 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3715 <RepoHeader owner={owner} repo={repo} />
3716 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3717 <div class="blob-toolbar">
3718 <BranchSwitcher
3719 owner={owner}
3720 repo={repo}
3721 currentRef={ref}
3722 branches={branches}
3723 pathType="blob"
3724 subPath={filePath}
3725 />
3726 <div class="blob-breadcrumb">
3727 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3728 </div>
3729 </div>
3730 <div class="blob-view blob-card">
3731 <div class="blob-header blob-header-polished">
3732 <div class="blob-header-meta">
3733 <span class="blob-header-icon" aria-hidden="true">
3734 {"📄"}
3735 </span>
3736 <span class="blob-header-name">{fileName}</span>
3737 <span class="blob-header-size">
3738 {formatBytes(blob.size)}
3739 {!blob.isBinary && (
3740 <>
3741 {" · "}
3742 {lineCount} line{lineCount === 1 ? "" : "s"}
3743 </>
3744 )}
3745 </span>
3746 </div>
3747 <div class="blob-header-actions">
3748 <a
3749 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
3750 class="blob-pill"
3751 >
79136bbClaude3752 Raw
3753 </a>
efb11c5Claude3754 <a
3755 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
3756 class="blob-pill"
3757 >
79136bbClaude3758 Blame
3759 </a>
efb11c5Claude3760 <a
3761 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
3762 class="blob-pill"
3763 >
16b325cClaude3764 History
3765 </a>
0074234Claude3766 {user && (
efb11c5Claude3767 <a
3768 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
3769 class="blob-pill blob-pill-accent"
3770 >
0074234Claude3771 Edit
3772 </a>
3773 )}
efb11c5Claude3774 </div>
fc1817aClaude3775 </div>
3776 {blob.isBinary ? (
efb11c5Claude3777 <div class="blob-binary">
fc1817aClaude3778 Binary file not shown.
3779 </div>
06d5ffeClaude3780 ) : (() => {
3781 const { html: highlighted, language } = highlightCode(
3782 blob.content,
3783 fileName
3784 );
3785 if (language) {
3786 return (
3787 <HighlightedCode
3788 highlightedHtml={highlighted}
efb11c5Claude3789 lineCount={lineCount}
06d5ffeClaude3790 />
3791 );
3792 }
3793 const lines = blob.content.split("\n");
3794 if (lines[lines.length - 1] === "") lines.pop();
3795 return <PlainCode lines={lines} />;
3796 })()}
fc1817aClaude3797 </div>
3798 </Layout>
3799 );
3800});
3801
398a10cClaude3802// ─── Branches list ────────────────────────────────────────────────────────
3803// Lightweight `git for-each-ref` enrichment so each row shows last-commit
3804// author + relative time + ahead/behind vs the default branch. No DB. All
3805// data comes from git plumbing; failures degrade gracefully (counts omitted).
3806web.get("/:owner/:repo/branches", async (c) => {
3807 const { owner, repo } = c.req.param();
3808 const user = c.get("user");
3809
3810 if (!(await repoExists(owner, repo))) return c.notFound();
3811
3812 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3813 const branches = await listBranches(owner, repo);
3814 const repoDir = getRepoPath(owner, repo);
3815
3816 type BranchRow = {
3817 name: string;
3818 isDefault: boolean;
3819 sha: string;
3820 subject: string;
3821 author: string;
3822 date: string;
3823 ahead: number;
3824 behind: number;
3825 };
3826
3827 const runGit = async (args: string[]): Promise<string> => {
3828 try {
3829 const proc = Bun.spawn(["git", ...args], {
3830 cwd: repoDir,
3831 stdout: "pipe",
3832 stderr: "pipe",
3833 });
3834 const out = await new Response(proc.stdout).text();
3835 await proc.exited;
3836 return out;
3837 } catch {
3838 return "";
3839 }
3840 };
3841
3842 const meta = await runGit([
3843 "for-each-ref",
3844 "--sort=-committerdate",
3845 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
3846 "refs/heads/",
3847 ]);
3848 const metaByName: Record<
3849 string,
3850 { sha: string; subject: string; author: string; date: string }
3851 > = {};
3852 for (const line of meta.split("\n").filter(Boolean)) {
3853 const [name, sha, subject, author, date] = line.split("\0");
3854 metaByName[name] = { sha, subject, author, date };
3855 }
3856
3857 const branchOrder = [...branches].sort((a, b) => {
3858 if (a === defaultBranch) return -1;
3859 if (b === defaultBranch) return 1;
3860 const aDate = metaByName[a]?.date || "";
3861 const bDate = metaByName[b]?.date || "";
3862 return bDate.localeCompare(aDate);
3863 });
3864
3865 const rows: BranchRow[] = [];
3866 for (const name of branchOrder) {
3867 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
3868 let ahead = 0;
3869 let behind = 0;
3870 if (name !== defaultBranch && metaByName[defaultBranch]) {
3871 const out = await runGit([
3872 "rev-list",
3873 "--left-right",
3874 "--count",
3875 `${defaultBranch}...${name}`,
3876 ]);
3877 const parts = out.trim().split(/\s+/);
3878 if (parts.length === 2) {
3879 behind = parseInt(parts[0], 10) || 0;
3880 ahead = parseInt(parts[1], 10) || 0;
3881 }
3882 }
3883 rows.push({
3884 name,
3885 isDefault: name === defaultBranch,
3886 sha: m.sha,
3887 subject: m.subject,
3888 author: m.author,
3889 date: m.date,
3890 ahead,
3891 behind,
3892 });
3893 }
3894
3895 const relative = (iso: string): string => {
3896 if (!iso) return "—";
3897 const d = new Date(iso);
3898 if (Number.isNaN(d.getTime())) return "—";
3899 const diff = Date.now() - d.getTime();
3900 const m = Math.floor(diff / 60000);
3901 if (m < 1) return "just now";
3902 if (m < 60) return `${m} min ago`;
3903 const h = Math.floor(m / 60);
3904 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
3905 const dd = Math.floor(h / 24);
3906 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
3907 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3908 };
3909
3910 const success = c.req.query("success");
3911 const error = c.req.query("error");
3912
3913 return c.html(
3914 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
3915 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
3916 <RepoHeader owner={owner} repo={repo} />
3917 <RepoNav owner={owner} repo={repo} active="code" />
3918 <div
3919 class="branches-wrap"
eed4684Claude3920 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude3921 >
3922 <header class="branches-head" style="margin-bottom:var(--space-5)">
3923 <div
3924 class="branches-eyebrow"
3925 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"
3926 >
3927 <span
3928 class="branches-eyebrow-dot"
3929 aria-hidden="true"
3930 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)"
3931 />
3932 Repository · Branches
3933 </div>
3934 <h1
3935 class="branches-title"
3936 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)"
3937 >
3938 <span class="gradient-text">{rows.length}</span>{" "}
3939 branch{rows.length === 1 ? "" : "es"}
3940 </h1>
3941 <p
3942 class="branches-sub"
3943 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
3944 >
3945 All work-in-progress lines for{" "}
3946 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
3947 counts are relative to{" "}
3948 <code style="font-size:12.5px">{defaultBranch}</code>.
3949 </p>
3950 </header>
3951
3952 {success && (
3953 <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">
3954 {decodeURIComponent(success)}
3955 </div>
3956 )}
3957 {error && (
3958 <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">
3959 {decodeURIComponent(error)}
3960 </div>
3961 )}
3962
3963 {rows.length === 0 ? (
3964 <div class="commits-empty">
3965 <div class="commits-empty-orb" aria-hidden="true" />
3966 <div class="commits-empty-inner">
3967 <div class="commits-empty-icon" aria-hidden="true">
3968 <svg
3969 width="22"
3970 height="22"
3971 viewBox="0 0 24 24"
3972 fill="none"
3973 stroke="currentColor"
3974 stroke-width="2"
3975 stroke-linecap="round"
3976 stroke-linejoin="round"
3977 >
3978 <line x1="6" y1="3" x2="6" y2="15" />
3979 <circle cx="18" cy="6" r="3" />
3980 <circle cx="6" cy="18" r="3" />
3981 <path d="M18 9a9 9 0 0 1-9 9" />
3982 </svg>
3983 </div>
3984 <h3 class="commits-empty-title">No branches yet</h3>
3985 <p class="commits-empty-sub">
3986 Push your first commit to create the default branch.
3987 </p>
3988 </div>
3989 </div>
3990 ) : (
3991 <div class="branches-list">
3992 {rows.map((r) => (
3993 <div class="branches-row">
3994 <div class="branches-row-icon" aria-hidden="true">
3995 <svg
3996 width="14"
3997 height="14"
3998 viewBox="0 0 24 24"
3999 fill="none"
4000 stroke="currentColor"
4001 stroke-width="2"
4002 stroke-linecap="round"
4003 stroke-linejoin="round"
4004 >
4005 <line x1="6" y1="3" x2="6" y2="15" />
4006 <circle cx="18" cy="6" r="3" />
4007 <circle cx="6" cy="18" r="3" />
4008 <path d="M18 9a9 9 0 0 1-9 9" />
4009 </svg>
4010 </div>
4011 <div class="branches-row-main">
4012 <div class="branches-row-name">
4013 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4014 {r.isDefault && (
4015 <span
4016 class="branches-row-default"
4017 title="Default branch"
4018 >
4019 Default
4020 </span>
4021 )}
4022 </div>
4023 <div class="branches-row-meta">
4024 {r.subject ? (
4025 <>
4026 <strong>{r.author || "—"}</strong>
4027 <span class="sep">·</span>
4028 <span
4029 title={r.date ? new Date(r.date).toISOString() : ""}
4030 >
4031 updated {relative(r.date)}
4032 </span>
4033 {r.sha && (
4034 <>
4035 <span class="sep">·</span>
4036 <a
4037 href={`/${owner}/${repo}/commit/${r.sha}`}
4038 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4039 >
4040 {r.sha.slice(0, 7)}
4041 </a>
4042 </>
4043 )}
4044 </>
4045 ) : (
4046 <span>No commit metadata</span>
4047 )}
4048 </div>
4049 </div>
4050 <div class="branches-row-side">
4051 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4052 <span
4053 class="branches-row-divergence"
4054 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4055 >
4056 <span class="ahead">{r.ahead} ahead</span>
4057 <span style="opacity:0.4">|</span>
4058 <span class="behind">{r.behind} behind</span>
4059 </span>
4060 )}
4061 <div class="branches-row-actions">
4062 <a
4063 href={`/${owner}/${repo}/commits/${r.name}`}
4064 class="branches-btn"
4065 title="View commits on this branch"
4066 >
4067 Commits
4068 </a>
4069 {!r.isDefault &&
4070 user &&
4071 user.username === owner && (
4072 <form
4073 method="post"
4074 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4075 style="margin:0"
4076 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4077 >
4078 <button
4079 type="submit"
4080 class="branches-btn branches-btn-danger"
4081 >
4082 Delete
4083 </button>
4084 </form>
4085 )}
4086 </div>
4087 </div>
4088 </div>
4089 ))}
4090 </div>
4091 )}
4092 </div>
4093 </Layout>
4094 );
4095});
4096
4097// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4098// that are not merged into the default branch — matches the explicit
4099// confirmation on the row's delete button.
4100web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4101 const { owner, repo } = c.req.param();
4102 const branchName = decodeURIComponent(c.req.param("name"));
4103 const user = c.get("user")!;
4104
4105 // Owner-only check (mirrors collaborators.tsx pattern).
4106 const [ownerRow] = await db
4107 .select()
4108 .from(users)
4109 .where(eq(users.username, owner))
4110 .limit(1);
4111 if (!ownerRow || ownerRow.id !== user.id) {
4112 return c.redirect(
4113 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4114 );
4115 }
4116
4117 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4118 if (branchName === defaultBranch) {
4119 return c.redirect(
4120 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4121 );
4122 }
4123
4124 const branches = await listBranches(owner, repo);
4125 if (!branches.includes(branchName)) {
4126 return c.redirect(
4127 `/${owner}/${repo}/branches?error=Branch+not+found`
4128 );
4129 }
4130
4131 try {
4132 const repoDir = getRepoPath(owner, repo);
4133 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4134 cwd: repoDir,
4135 stdout: "pipe",
4136 stderr: "pipe",
4137 });
4138 await proc.exited;
4139 if (proc.exitCode !== 0) {
4140 return c.redirect(
4141 `/${owner}/${repo}/branches?error=Delete+failed`
4142 );
4143 }
4144 } catch {
4145 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4146 }
4147
4148 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4149});
4150
4151// ─── Tags list ────────────────────────────────────────────────────────────
4152// Pulls from `listTags` (sorted newest first). For each tag we look up an
4153// associated release row so the "View release" CTA can deep-link directly
4154// without making the user hunt for it.
4155web.get("/:owner/:repo/tags", async (c) => {
4156 const { owner, repo } = c.req.param();
4157 const user = c.get("user");
4158
4159 if (!(await repoExists(owner, repo))) return c.notFound();
4160
4161 const tags = await listTags(owner, repo);
4162
4163 // Map tags -> releases. Best-effort; releases table may not exist in
4164 // every test setup, so any error falls through with an empty set.
4165 const tagsWithReleases = new Set<string>();
4166 try {
4167 const [ownerRow] = await db
4168 .select()
4169 .from(users)
4170 .where(eq(users.username, owner))
4171 .limit(1);
4172 if (ownerRow) {
4173 const [repoRow] = await db
4174 .select()
4175 .from(repositories)
4176 .where(
4177 and(
4178 eq(repositories.ownerId, ownerRow.id),
4179 eq(repositories.name, repo)
4180 )
4181 )
4182 .limit(1);
4183 if (repoRow) {
4184 // Raw SQL so we don't need to import the releases schema here.
4185 const result = await db.execute(
4186 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4187 );
4188 const rows: any[] = (result as any).rows || (result as any) || [];
4189 for (const row of rows) {
4190 const tag = row?.tag;
4191 if (typeof tag === "string") tagsWithReleases.add(tag);
4192 }
4193 }
4194 }
4195 } catch {
4196 // No releases table or DB error — leave set empty.
4197 }
4198
4199 const relative = (iso: string): string => {
4200 if (!iso) return "—";
4201 const d = new Date(iso);
4202 if (Number.isNaN(d.getTime())) return "—";
4203 const diff = Date.now() - d.getTime();
4204 const m = Math.floor(diff / 60000);
4205 if (m < 1) return "just now";
4206 if (m < 60) return `${m} min ago`;
4207 const h = Math.floor(m / 60);
4208 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4209 const dd = Math.floor(h / 24);
4210 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4211 return d.toLocaleDateString("en-US", {
4212 month: "short",
4213 day: "numeric",
4214 year: "numeric",
4215 });
4216 };
4217
4218 return c.html(
4219 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4220 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4221 <RepoHeader owner={owner} repo={repo} />
4222 <RepoNav owner={owner} repo={repo} active="code" />
4223 <div
4224 class="tags-wrap"
eed4684Claude4225 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4226 >
4227 <header class="tags-head" style="margin-bottom:var(--space-5)">
4228 <div
4229 class="tags-eyebrow"
4230 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"
4231 >
4232 <span
4233 class="tags-eyebrow-dot"
4234 aria-hidden="true"
4235 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)"
4236 />
4237 Repository · Tags
4238 </div>
4239 <h1
4240 class="tags-title"
4241 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)"
4242 >
4243 <span class="gradient-text">{tags.length}</span>{" "}
4244 tag{tags.length === 1 ? "" : "s"}
4245 </h1>
4246 <p
4247 class="tags-sub"
4248 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4249 >
4250 Named points in the history — typically releases, milestones,
4251 or shipped versions. Click a tag to browse the tree at that
4252 revision.
4253 </p>
4254 </header>
4255
4256 {tags.length === 0 ? (
4257 <div class="commits-empty">
4258 <div class="commits-empty-orb" aria-hidden="true" />
4259 <div class="commits-empty-inner">
4260 <div class="commits-empty-icon" aria-hidden="true">
4261 <svg
4262 width="22"
4263 height="22"
4264 viewBox="0 0 24 24"
4265 fill="none"
4266 stroke="currentColor"
4267 stroke-width="2"
4268 stroke-linecap="round"
4269 stroke-linejoin="round"
4270 >
4271 <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" />
4272 <line x1="7" y1="7" x2="7.01" y2="7" />
4273 </svg>
4274 </div>
4275 <h3 class="commits-empty-title">No tags yet</h3>
4276 <p class="commits-empty-sub">
4277 Tag a commit to mark a release or milestone. From the CLI:{" "}
4278 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4279 </p>
4280 </div>
4281 </div>
4282 ) : (
4283 <div class="tags-list">
4284 {tags.map((t) => {
4285 const hasRelease = tagsWithReleases.has(t.name);
4286 return (
4287 <div class="tags-row">
4288 <div class="tags-row-icon" aria-hidden="true">
4289 <svg
4290 width="14"
4291 height="14"
4292 viewBox="0 0 24 24"
4293 fill="none"
4294 stroke="currentColor"
4295 stroke-width="2"
4296 stroke-linecap="round"
4297 stroke-linejoin="round"
4298 >
4299 <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" />
4300 <line x1="7" y1="7" x2="7.01" y2="7" />
4301 </svg>
4302 </div>
4303 <div class="tags-row-main">
4304 <div class="tags-row-name">
4305 <a
4306 href={`/${owner}/${repo}/tree/${t.name}`}
4307 style="text-decoration:none"
4308 >
4309 <span class="tags-row-version">{t.name}</span>
4310 </a>
4311 </div>
4312 <div class="tags-row-meta">
4313 <span
4314 title={t.date ? new Date(t.date).toISOString() : ""}
4315 >
4316 Tagged {relative(t.date)}
4317 </span>
4318 <span class="sep">·</span>
4319 <a
4320 href={`/${owner}/${repo}/commit/${t.sha}`}
4321 class="tags-row-sha"
4322 title={t.sha}
4323 >
4324 {t.sha.slice(0, 7)}
4325 </a>
4326 </div>
4327 </div>
4328 <div class="tags-row-side">
4329 <a
4330 href={`/${owner}/${repo}/tree/${t.name}`}
4331 class="tags-row-link"
4332 >
4333 Browse files
4334 </a>
4335 {hasRelease && (
4336 <a
4337 href={`/${owner}/${repo}/releases/tag/${t.name}`}
4338 class="tags-row-link"
4339 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
4340 >
4341 View release
4342 </a>
4343 )}
4344 </div>
4345 </div>
4346 );
4347 })}
4348 </div>
4349 )}
4350 </div>
4351 </Layout>
4352 );
4353});
4354
fc1817aClaude4355// Commit log
4356web.get("/:owner/:repo/commits/:ref?", async (c) => {
4357 const { owner, repo } = c.req.param();
06d5ffeClaude4358 const user = c.get("user");
fc1817aClaude4359 const ref =
4360 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude4361 const branches = await listBranches(owner, repo);
fc1817aClaude4362
4363 const commits = await listCommits(owner, repo, ref, 50);
4364
3951454Claude4365 // Block J3 — batch-fetch cached verification results for the page.
4366 let verifications: Record<string, { verified: boolean; reason: string }> = {};
4367 try {
4368 const [ownerRow] = await db
4369 .select()
4370 .from(users)
4371 .where(eq(users.username, owner))
4372 .limit(1);
4373 if (ownerRow) {
4374 const [repoRow] = await db
4375 .select()
4376 .from(repositories)
4377 .where(
4378 and(
4379 eq(repositories.ownerId, ownerRow.id),
4380 eq(repositories.name, repo)
4381 )
4382 )
4383 .limit(1);
4384 if (repoRow && commits.length > 0) {
4385 const rows = await db
4386 .select()
4387 .from(commitVerifications)
4388 .where(
4389 and(
4390 eq(commitVerifications.repositoryId, repoRow.id),
4391 inArray(
4392 commitVerifications.commitSha,
4393 commits.map((c) => c.sha)
4394 )
4395 )
4396 );
4397 for (const r of rows) {
4398 verifications[r.commitSha] = {
4399 verified: r.verified,
4400 reason: r.reason,
4401 };
4402 }
4403 }
4404 }
4405 } catch {
4406 // DB unavailable — skip the badges gracefully.
4407 }
4408
fc1817aClaude4409 return c.html(
06d5ffeClaude4410 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude4411 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4412 <RepoHeader owner={owner} repo={repo} />
4413 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude4414 <div class="commits-hero">
4415 <div class="commits-hero-orb-wrap" aria-hidden="true">
4416 <div class="commits-hero-orb" />
fc1817aClaude4417 </div>
efb11c5Claude4418 <div class="commits-hero-inner">
4419 <div class="commits-eyebrow">
4420 <strong>History</strong> · {owner}/{repo}
4421 </div>
4422 <h1 class="commits-title">
4423 <span class="gradient-text">{commits.length}</span> recent commit
4424 {commits.length === 1 ? "" : "s"} on{" "}
4425 <span class="commits-branch">{ref}</span>
4426 </h1>
4427 <p class="commits-sub">
4428 Browse the project's history. Click any commit to see the full
4429 diff, AI review notes, and signature status.
4430 </p>
4431 </div>
4432 </div>
4433 <div class="commits-toolbar">
4434 <BranchSwitcher
3951454Claude4435 owner={owner}
4436 repo={repo}
efb11c5Claude4437 currentRef={ref}
4438 branches={branches}
4439 pathType="commits"
3951454Claude4440 />
398a10cClaude4441 <div class="commits-toolbar-actions">
4442 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
4443 {"⊢"} Branches
4444 </a>
4445 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
4446 {"#"} Tags
4447 </a>
4448 </div>
efb11c5Claude4449 </div>
4450 {commits.length === 0 ? (
4451 <div class="commits-empty">
398a10cClaude4452 <div class="commits-empty-orb" aria-hidden="true" />
4453 <div class="commits-empty-inner">
4454 <div class="commits-empty-icon" aria-hidden="true">
4455 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4456 <circle cx="12" cy="12" r="4" />
4457 <line x1="1.05" y1="12" x2="7" y2="12" />
4458 <line x1="17.01" y1="12" x2="22.96" y2="12" />
4459 </svg>
4460 </div>
4461 <h3 class="commits-empty-title">No commits yet</h3>
4462 <p class="commits-empty-sub">
4463 This branch is empty. Push your first commit, or use the
4464 web editor to create a file.
4465 </p>
4466 </div>
efb11c5Claude4467 </div>
4468 ) : (
4469 <div class="commits-list-wrap">
398a10cClaude4470 {(() => {
4471 // Group commits by day for the section headers.
4472 const dayLabel = (iso: string): string => {
4473 const d = new Date(iso);
4474 if (Number.isNaN(d.getTime())) return "Unknown";
4475 const today = new Date();
4476 const yesterday = new Date(today);
4477 yesterday.setDate(today.getDate() - 1);
4478 const sameDay = (a: Date, b: Date) =>
4479 a.getFullYear() === b.getFullYear() &&
4480 a.getMonth() === b.getMonth() &&
4481 a.getDate() === b.getDate();
4482 if (sameDay(d, today)) return "Today";
4483 if (sameDay(d, yesterday)) return "Yesterday";
4484 return d.toLocaleDateString("en-US", {
4485 month: "long",
4486 day: "numeric",
4487 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
4488 });
4489 };
4490 const relative = (iso: string): string => {
4491 const d = new Date(iso);
4492 if (Number.isNaN(d.getTime())) return "";
4493 const diff = Date.now() - d.getTime();
4494 const m = Math.floor(diff / 60000);
4495 if (m < 1) return "just now";
4496 if (m < 60) return `${m} min ago`;
4497 const h = Math.floor(m / 60);
4498 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4499 const dd = Math.floor(h / 24);
4500 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4501 return d.toLocaleDateString("en-US", {
4502 month: "short",
4503 day: "numeric",
4504 });
4505 };
4506 const initial = (name: string): string =>
4507 (name || "?").trim().charAt(0).toUpperCase() || "?";
4508 // Build groups preserving order.
4509 const groups: Array<{ label: string; items: typeof commits }> = [];
4510 let lastLabel = "";
4511 for (const cm of commits) {
4512 const label = dayLabel(cm.date);
4513 if (label !== lastLabel) {
4514 groups.push({ label, items: [] });
4515 lastLabel = label;
4516 }
4517 groups[groups.length - 1].items.push(cm);
4518 }
4519 return groups.map((g) => (
4520 <>
4521 <div class="commits-day-head">
4522 <span class="commits-day-head-dot" aria-hidden="true" />
4523 Commits on {g.label}
4524 </div>
4525 {g.items.map((cm) => {
4526 const v = verifications[cm.sha];
4527 return (
4528 <div class="commits-row">
4529 <div class="commits-avatar" aria-hidden="true">
4530 {initial(cm.author)}
4531 </div>
4532 <div class="commits-row-body">
4533 <div class="commits-row-msg">
4534 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
4535 {cm.message}
4536 </a>
4537 {v?.verified && (
4538 <span
4539 class="commits-row-verified"
4540 title="Signed with a registered key"
4541 >
4542 Verified
4543 </span>
4544 )}
4545 </div>
4546 <div class="commits-row-meta">
4547 <strong>{cm.author}</strong>
4548 <span class="sep">·</span>
4549 <span
4550 class="commits-row-time"
4551 title={new Date(cm.date).toISOString()}
4552 >
4553 committed {relative(cm.date)}
4554 </span>
4555 {cm.parentShas.length > 1 && (
4556 <>
4557 <span class="sep">·</span>
4558 <span>merge of {cm.parentShas.length} parents</span>
4559 </>
4560 )}
4561 </div>
4562 </div>
4563 <div class="commits-row-side">
4564 <a
4565 href={`/${owner}/${repo}/commit/${cm.sha}`}
4566 class="commits-row-sha"
4567 title={cm.sha}
4568 >
4569 {cm.sha.slice(0, 7)}
4570 </a>
4571 <button
4572 type="button"
4573 class="commits-row-copy"
4574 data-copy-sha={cm.sha}
4575 title="Copy full SHA"
4576 aria-label={`Copy SHA ${cm.sha}`}
4577 >
4578 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4579 <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" />
4580 <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" />
4581 </svg>
4582 </button>
4583 </div>
4584 </div>
4585 );
4586 })}
4587 </>
4588 ));
4589 })()}
efb11c5Claude4590 </div>
fc1817aClaude4591 )}
398a10cClaude4592 <script
4593 dangerouslySetInnerHTML={{
4594 __html: `
4595 (function(){
4596 document.addEventListener('click', function(e){
4597 var t = e.target; if (!t) return;
4598 var btn = t.closest && t.closest('[data-copy-sha]');
4599 if (!btn) return;
4600 e.preventDefault();
4601 var sha = btn.getAttribute('data-copy-sha') || '';
4602 if (!navigator.clipboard) return;
4603 navigator.clipboard.writeText(sha).then(function(){
4604 btn.classList.add('is-copied');
4605 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
4606 }).catch(function(){});
4607 });
4608 })();
4609 `,
4610 }}
4611 />
fc1817aClaude4612 </Layout>
4613 );
4614});
4615
4616// Single commit with diff
4617web.get("/:owner/:repo/commit/:sha", async (c) => {
4618 const { owner, repo, sha } = c.req.param();
06d5ffeClaude4619 const user = c.get("user");
fc1817aClaude4620
05b973eClaude4621 // Fetch commit, full message, and diff in parallel
4622 const [commit, fullMessage, diffResult] = await Promise.all([
4623 getCommit(owner, repo, sha),
4624 getCommitFullMessage(owner, repo, sha),
4625 getDiff(owner, repo, sha),
4626 ]);
fc1817aClaude4627 if (!commit) {
4628 return c.html(
06d5ffeClaude4629 <Layout title="Not Found" user={user}>
fc1817aClaude4630 <div class="empty-state">
4631 <h2>Commit not found</h2>
4632 </div>
4633 </Layout>,
4634 404
4635 );
4636 }
4637
3951454Claude4638 // Block J3 — try to verify this commit's signature.
4639 let verification:
4640 | { verified: boolean; reason: string; signatureType: string | null }
4641 | null = null;
0cdfd89Claude4642 // Block J8 — external CI commit statuses rollup.
4643 let statusCombined:
4644 | {
4645 state: "pending" | "success" | "failure";
4646 total: number;
4647 contexts: Array<{
4648 context: string;
4649 state: string;
4650 description: string | null;
4651 targetUrl: string | null;
4652 }>;
4653 }
4654 | null = null;
3951454Claude4655 try {
4656 const [ownerRow] = await db
4657 .select()
4658 .from(users)
4659 .where(eq(users.username, owner))
4660 .limit(1);
4661 if (ownerRow) {
4662 const [repoRow] = await db
4663 .select()
4664 .from(repositories)
4665 .where(
4666 and(
4667 eq(repositories.ownerId, ownerRow.id),
4668 eq(repositories.name, repo)
4669 )
4670 )
4671 .limit(1);
4672 if (repoRow) {
4673 const { verifyCommit } = await import("../lib/signatures");
4674 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
4675 verification = {
4676 verified: v.verified,
4677 reason: v.reason,
4678 signatureType: v.signatureType,
4679 };
0cdfd89Claude4680 try {
4681 const { combinedStatus } = await import("../lib/commit-statuses");
4682 const combined = await combinedStatus(repoRow.id, commit.sha);
4683 if (combined.total > 0) {
4684 statusCombined = {
4685 state: combined.state as any,
4686 total: combined.total,
4687 contexts: combined.contexts.map((c) => ({
4688 context: c.context,
4689 state: c.state,
4690 description: c.description,
4691 targetUrl: c.targetUrl,
4692 })),
4693 };
4694 }
4695 } catch {
4696 statusCombined = null;
4697 }
3951454Claude4698 }
4699 }
4700 } catch {
4701 verification = null;
4702 }
4703
05b973eClaude4704 const { files, raw } = diffResult;
fc1817aClaude4705
efb11c5Claude4706 // Diff stats: count additions / deletions across all files for the
4707 // header summary bar. Computed here from the parsed diff so we don't
4708 // touch the DiffView component.
4709 let additions = 0;
4710 let deletions = 0;
4711 for (const f of files) {
4712 const hunks = (f as any).hunks as Array<any> | undefined;
4713 if (Array.isArray(hunks)) {
4714 for (const h of hunks) {
4715 const lines = (h?.lines || []) as Array<any>;
4716 for (const ln of lines) {
4717 const t = ln?.type || ln?.kind;
4718 if (t === "add" || t === "added" || t === "+") additions += 1;
4719 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
4720 deletions += 1;
4721 }
4722 }
4723 }
4724 }
4725 // Fall back: scan raw if file-level counting yielded zero (it's just a
4726 // header polish — never let a parsing miss break the page).
4727 if (additions === 0 && deletions === 0 && typeof raw === "string") {
4728 for (const line of raw.split("\n")) {
4729 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
4730 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
4731 }
4732 }
4733 const fileCount = files.length;
4734
fc1817aClaude4735 return c.html(
06d5ffeClaude4736 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude4737 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4738 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude4739 <div class="commit-detail-card">
4740 <div class="commit-detail-eyebrow">
4741 <strong>Commit</strong>
4742 <span class="commit-detail-sha-pill" title={commit.sha}>
4743 {commit.sha.slice(0, 7)}
4744 </span>
3951454Claude4745 {verification && verification.reason !== "unsigned" && (
4746 <span
efb11c5Claude4747 class={`commit-detail-verify ${
3951454Claude4748 verification.verified
efb11c5Claude4749 ? "commit-detail-verify-ok"
4750 : "commit-detail-verify-warn"
3951454Claude4751 }`}
4752 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
4753 >
4754 {verification.verified ? "Verified" : verification.reason}
4755 </span>
4756 )}
fc1817aClaude4757 </div>
efb11c5Claude4758 <h1 class="commit-detail-title">{commit.message}</h1>
4759 {fullMessage !== commit.message && (
4760 <pre class="commit-detail-body">{fullMessage}</pre>
4761 )}
4762 <div class="commit-detail-meta">
4763 <span class="commit-detail-author">
4764 <strong>{commit.author}</strong> committed on{" "}
4765 {new Date(commit.date).toLocaleDateString("en-US", {
4766 month: "long",
4767 day: "numeric",
4768 year: "numeric",
4769 })}
4770 </span>
fc1817aClaude4771 {commit.parentShas.length > 0 && (
efb11c5Claude4772 <span class="commit-detail-parents">
4773 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
4774 {commit.parentShas.map((p, idx) => (
4775 <>
4776 {idx > 0 && " "}
4777 <a
4778 href={`/${owner}/${repo}/commit/${p}`}
4779 class="commit-detail-sha-link"
4780 >
4781 {p.slice(0, 7)}
4782 </a>
4783 </>
fc1817aClaude4784 ))}
4785 </span>
4786 )}
4787 </div>
efb11c5Claude4788 <div class="commit-detail-stats">
4789 <span class="commit-detail-stat">
4790 <strong>{fileCount}</strong>{" "}
4791 file{fileCount === 1 ? "" : "s"} changed
4792 </span>
4793 <span class="commit-detail-stat commit-detail-stat-add">
4794 <span class="commit-detail-stat-mark">+</span>
4795 <strong>{additions}</strong>
4796 </span>
4797 <span class="commit-detail-stat commit-detail-stat-del">
4798 <span class="commit-detail-stat-mark">−</span>
4799 <strong>{deletions}</strong>
4800 </span>
4801 <span class="commit-detail-sha-full" title="Full SHA">
4802 {commit.sha}
4803 </span>
4804 </div>
0cdfd89Claude4805 {statusCombined && (
efb11c5Claude4806 <div class="commit-detail-checks">
4807 <div class="commit-detail-checks-head">
4808 <strong>Checks</strong>
4809 <span class="commit-detail-checks-summary">
4810 {statusCombined.total} total ·{" "}
4811 <span
4812 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
4813 >
4814 {statusCombined.state}
4815 </span>
0cdfd89Claude4816 </span>
efb11c5Claude4817 </div>
4818 <div class="commit-detail-check-row">
0cdfd89Claude4819 {statusCombined.contexts.map((cx) => (
4820 <span
efb11c5Claude4821 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude4822 title={cx.description || cx.context}
4823 >
4824 {cx.targetUrl ? (
efb11c5Claude4825 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude4826 {cx.context}: {cx.state}
4827 </a>
4828 ) : (
4829 <>
4830 {cx.context}: {cx.state}
4831 </>
4832 )}
4833 </span>
4834 ))}
4835 </div>
4836 </div>
4837 )}
fc1817aClaude4838 </div>
ea9ed4cClaude4839 <DiffView
4840 raw={raw}
4841 files={files}
4842 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
4843 />
fc1817aClaude4844 </Layout>
4845 );
4846});
4847
79136bbClaude4848// Raw file download
4849web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
4850 const { owner, repo } = c.req.param();
4851 const refAndPath = c.req.param("ref");
4852
4853 const branches = await listBranches(owner, repo);
4854 let ref = "";
4855 let filePath = "";
4856
4857 for (const branch of branches) {
4858 if (refAndPath.startsWith(branch + "/")) {
4859 ref = branch;
4860 filePath = refAndPath.slice(branch.length + 1);
4861 break;
4862 }
4863 }
4864
4865 if (!ref) {
4866 const slashIdx = refAndPath.indexOf("/");
4867 if (slashIdx === -1) return c.text("Not found", 404);
4868 ref = refAndPath.slice(0, slashIdx);
4869 filePath = refAndPath.slice(slashIdx + 1);
4870 }
4871
4872 const data = await getRawBlob(owner, repo, ref, filePath);
4873 if (!data) return c.text("Not found", 404);
4874
4875 const fileName = filePath.split("/").pop() || "file";
772a24fClaude4876 return new Response(data as BodyInit, {
79136bbClaude4877 headers: {
4878 "Content-Type": "application/octet-stream",
4879 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude4880 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude4881 },
4882 });
4883});
4884
4885// Blame view
4886web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
4887 const { owner, repo } = c.req.param();
4888 const user = c.get("user");
4889 const refAndPath = c.req.param("ref");
4890
4891 const branches = await listBranches(owner, repo);
4892 let ref = "";
4893 let filePath = "";
4894
4895 for (const branch of branches) {
4896 if (refAndPath.startsWith(branch + "/")) {
4897 ref = branch;
4898 filePath = refAndPath.slice(branch.length + 1);
4899 break;
4900 }
4901 }
4902
4903 if (!ref) {
4904 const slashIdx = refAndPath.indexOf("/");
4905 if (slashIdx === -1) return c.text("Not found", 404);
4906 ref = refAndPath.slice(0, slashIdx);
4907 filePath = refAndPath.slice(slashIdx + 1);
4908 }
4909
4910 const blameLines = await getBlame(owner, repo, ref, filePath);
4911 if (blameLines.length === 0) {
4912 return c.html(
4913 <Layout title="Not Found" user={user}>
4914 <div class="empty-state">
4915 <h2>File not found</h2>
4916 </div>
4917 </Layout>,
4918 404
4919 );
4920 }
4921
4922 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4923 // Unique contributors (by author) tracked once for the header chip.
4924 const blameAuthors = new Set<string>();
4925 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude4926
4927 return c.html(
4928 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4929 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude4930 <RepoHeader owner={owner} repo={repo} />
4931 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude4932 <header class="blame-head">
4933 <div class="blame-eyebrow">
4934 <span class="blame-eyebrow-dot" aria-hidden="true" />
4935 Blame · Line-by-line history
4936 </div>
4937 <h1 class="blame-title">
4938 <code>{fileName}</code>
4939 </h1>
4940 <p class="blame-sub">
4941 Each line is annotated with the commit that last touched it.
4942 Click any SHA to jump to that commit and see the surrounding
4943 change.
4944 </p>
4945 </header>
efb11c5Claude4946 <div class="blame-toolbar">
4947 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4948 </div>
398a10cClaude4949 <div class="blame-card">
4950 <div class="blame-header">
efb11c5Claude4951 <div class="blame-header-meta">
4952 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
4953 <span class="blame-header-name">{fileName}</span>
4954 <span class="blame-header-tag">Blame</span>
4955 <span class="blame-header-stats">
4956 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
4957 {blameAuthors.size} contributor
4958 {blameAuthors.size === 1 ? "" : "s"}
4959 </span>
4960 </div>
4961 <div class="blame-header-actions">
4962 <a
4963 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
4964 class="blob-pill"
4965 >
4966 Normal view
4967 </a>
4968 <a
4969 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4970 class="blob-pill"
4971 >
4972 Raw
4973 </a>
4974 </div>
79136bbClaude4975 </div>
398a10cClaude4976 <div style="overflow-x:auto">
4977 <table class="blame-table">
79136bbClaude4978 <tbody>
4979 {blameLines.map((line, i) => {
4980 const showInfo =
4981 i === 0 || blameLines[i - 1].sha !== line.sha;
4982 return (
398a10cClaude4983 <tr class={showInfo ? "blame-row-first" : ""}>
4984 <td class="blame-gutter">
79136bbClaude4985 {showInfo && (
398a10cClaude4986 <span class="blame-gutter-inner">
79136bbClaude4987 <a
4988 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude4989 class="blame-gutter-sha"
4990 title={`Commit ${line.sha}`}
79136bbClaude4991 >
4992 {line.sha.slice(0, 7)}
398a10cClaude4993 </a>
4994 <span class="blame-gutter-author" title={line.author}>
4995 {line.author}
4996 </span>
4997 </span>
79136bbClaude4998 )}
4999 </td>
398a10cClaude5000 <td class="blame-line-num">{line.lineNum}</td>
5001 <td class="blame-line-content">{line.content}</td>
79136bbClaude5002 </tr>
5003 );
5004 })}
5005 </tbody>
5006 </table>
5007 </div>
5008 </div>
5009 </Layout>
5010 );
5011});
5012
5013// Search
5014web.get("/:owner/:repo/search", async (c) => {
5015 const { owner, repo } = c.req.param();
5016 const user = c.get("user");
5017 const q = c.req.query("q") || "";
5018
5019 if (!(await repoExists(owner, repo))) return c.notFound();
5020
5021 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
5022 let results: Array<{ file: string; lineNum: number; line: string }> = [];
5023
5024 if (q.trim()) {
5025 results = await searchCode(owner, repo, defaultBranch, q.trim());
5026 }
5027
5028 return c.html(
5029 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude5030 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5031 <RepoHeader owner={owner} repo={repo} />
5032 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5033 <div class="search-hero">
5034 <div class="search-eyebrow">
5035 <strong>Search</strong> · {owner}/{repo}
5036 </div>
5037 <h1 class="search-title">
5038 Find any line in <span class="gradient-text">{repo}</span>
5039 </h1>
5040 <form
5041 method="get"
5042 action={`/${owner}/${repo}/search`}
5043 class="search-form"
5044 role="search"
5045 >
5046 <div class="search-input-wrap">
5047 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5048 <input
5049 type="text"
5050 name="q"
5051 value={q}
5052 placeholder="Search code on the default branch…"
5053 aria-label="Search code"
5054 class="search-input"
5055 autocomplete="off"
5056 autofocus
5057 />
5058 </div>
5059 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5060 Search
5061 </button>
efb11c5Claude5062 </form>
5063 </div>
79136bbClaude5064 {q && (
efb11c5Claude5065 <div class="search-results-head">
5066 <span class="search-results-count">
5067 <strong>{results.length}</strong> result
5068 {results.length !== 1 ? "s" : ""}
5069 </span>
5070 <span class="search-results-query">
5071 for <span class="search-results-q">"{q}"</span> on{" "}
5072 <code>{defaultBranch}</code>
5073 </span>
5074 </div>
79136bbClaude5075 )}
efb11c5Claude5076 {q && results.length === 0 ? (
5077 <div class="search-empty">
5078 <p>
5079 No matches for <strong>"{q}"</strong>. Try a shorter query or check
5080 you're on the right branch.
5081 </p>
5082 </div>
5083 ) : results.length > 0 ? (
79136bbClaude5084 <div class="search-results">
5085 {(() => {
5086 // Group by file
5087 const grouped: Record<
5088 string,
5089 Array<{ lineNum: number; line: string }>
5090 > = {};
5091 for (const r of results) {
5092 if (!grouped[r.file]) grouped[r.file] = [];
5093 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5094 }
5095 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude5096 <div class="search-file diff-file">
5097 <div class="search-file-head diff-file-header">
79136bbClaude5098 <a
5099 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude5100 class="search-file-link"
79136bbClaude5101 >
5102 {file}
5103 </a>
efb11c5Claude5104 <span class="search-file-count">
5105 {matches.length} match{matches.length === 1 ? "" : "es"}
5106 </span>
79136bbClaude5107 </div>
5108 <div class="blob-code">
5109 <table>
5110 <tbody>
5111 {matches.map((m) => (
5112 <tr>
5113 <td class="line-num">{m.lineNum}</td>
5114 <td class="line-content">{m.line}</td>
5115 </tr>
5116 ))}
5117 </tbody>
5118 </table>
5119 </div>
5120 </div>
5121 ));
5122 })()}
5123 </div>
efb11c5Claude5124 ) : null}
79136bbClaude5125 </Layout>
5126 );
5127});
5128
fc1817aClaude5129export default web;