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.tsxBlame3269 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";
18import {
19 RepoHeader,
20 RepoNav,
21 Breadcrumb,
22 FileTable,
23 CommitList,
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,
40 repoExists,
06d5ffeClaude41 initBareRepo,
79136bbClaude42 getBlame,
43 getRawBlob,
44 searchCode,
fc1817aClaude45} from "../git/repository";
79136bbClaude46import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude47import { highlightCode } from "../lib/highlight";
48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude50import { trackByName } from "../lib/traffic";
534f04aClaude51import { LandingPage, type LandingLiveFeed } from "../views/landing";
52ad8b1Claude52import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude53import {
54 listQueuedAiBuildIssues,
55 listRecentAutoMerges,
56 listRecentAiReviews,
57 countAiReviewsSince,
58 listDemoActivityFeed,
59} from "../lib/demo-activity";
fc1817aClaude60
06d5ffeClaude61const web = new Hono<AuthEnv>();
62
63// Soft auth on all web routes — c.get("user") available but may be null
64web.use("*", softAuth);
fc1817aClaude65
efb11c5Claude66/**
67 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
68 *
69 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
70 * scope is route-local and `layout.tsx` is locked. Each polished handler
71 * injects this via a `<style>` tag; the rules are namespaced by surface
72 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
73 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
74 * `.repo-home-*` styling Agent A already shipped.
75 */
76const codeBrowseCss = `
77 /* ───────── shared primitives ───────── */
78 .cb-hairline::before,
79 .new-repo-hero::before,
80 .profile-hero::before,
81 .commits-hero::before,
82 .commit-detail-card::before,
83 .search-hero::before {
84 content: '';
85 position: absolute;
86 top: 0; left: 0; right: 0;
87 height: 2px;
88 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
89 opacity: 0.7;
90 pointer-events: none;
91 }
92 @keyframes cbHeroOrb {
93 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
94 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
95 }
96 @media (prefers-reduced-motion: reduce) {
97 .new-repo-hero-orb,
98 .profile-hero-orb,
99 .commits-hero-orb { animation: none; }
100 }
101
102 /* ───────── new-repo ───────── */
103 .new-repo-hero {
104 position: relative;
105 margin-bottom: var(--space-5);
106 padding: var(--space-5) var(--space-6);
107 background: var(--bg-elevated);
108 border: 1px solid var(--border);
109 border-radius: 16px;
110 overflow: hidden;
111 }
112 .new-repo-hero-orb-wrap {
113 position: absolute;
114 inset: -25% -10% auto auto;
115 width: 360px;
116 height: 360px;
117 pointer-events: none;
118 z-index: 0;
119 }
120 .new-repo-hero-orb {
121 position: absolute;
122 inset: 0;
123 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
124 filter: blur(80px);
125 opacity: 0.7;
126 animation: cbHeroOrb 14s ease-in-out infinite;
127 }
128 .new-repo-hero-inner { position: relative; z-index: 1; }
129 .new-repo-eyebrow {
130 font-size: 12px;
131 font-family: var(--font-mono);
132 color: var(--text-muted);
133 letter-spacing: 0.1em;
134 text-transform: uppercase;
135 margin-bottom: var(--space-2);
136 }
137 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
138 .new-repo-title {
139 font-family: var(--font-display);
140 font-weight: 800;
141 letter-spacing: -0.028em;
142 font-size: clamp(28px, 4vw, 40px);
143 line-height: 1.05;
144 margin: 0 0 var(--space-2);
145 color: var(--text-strong);
146 }
147 .new-repo-sub {
148 font-size: 15px;
149 color: var(--text-muted);
150 margin: 0;
151 line-height: 1.55;
152 max-width: 620px;
153 }
154 .new-repo-form {
155 max-width: 680px;
156 }
157 .new-repo-error {
158 background: rgba(218, 54, 51, 0.12);
159 border: 1px solid rgba(218, 54, 51, 0.35);
160 color: #ffb3b3;
161 padding: 10px 14px;
162 border-radius: 10px;
163 margin-bottom: var(--space-4);
164 font-size: 14px;
165 }
166 .new-repo-form-grid {
167 display: flex;
168 flex-direction: column;
169 gap: var(--space-4);
170 }
171 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
172 .new-repo-label {
173 font-size: 13px;
174 color: var(--text-strong);
175 font-weight: 600;
176 }
177 .new-repo-label-optional {
178 color: var(--text-muted);
179 font-weight: 400;
180 font-size: 12px;
181 }
182 .new-repo-input {
183 appearance: none;
184 width: 100%;
185 background: var(--bg);
186 border: 1px solid var(--border);
187 color: var(--text-strong);
188 border-radius: 10px;
189 padding: 10px 12px;
190 font-size: 14px;
191 font-family: inherit;
192 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
193 }
194 .new-repo-input:focus {
195 outline: none;
196 border-color: var(--accent);
197 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
198 }
199 .new-repo-input-disabled {
200 color: var(--text-muted);
201 background: var(--bg-secondary);
202 cursor: not-allowed;
203 }
204 .new-repo-hint {
205 font-size: 12px;
206 color: var(--text-muted);
207 margin: 4px 0 0;
208 }
209 .new-repo-hint code {
210 font-family: var(--font-mono);
211 font-size: 11.5px;
212 background: var(--bg-secondary);
213 border: 1px solid var(--border);
214 border-radius: 5px;
215 padding: 1px 5px;
216 color: var(--text-strong);
217 }
218 .new-repo-visibility {
219 display: grid;
220 grid-template-columns: 1fr 1fr;
221 gap: var(--space-2);
222 }
223 @media (max-width: 600px) {
224 .new-repo-visibility { grid-template-columns: 1fr; }
225 }
226 .new-repo-vis-card {
227 display: flex;
228 gap: 10px;
229 padding: 14px;
230 background: var(--bg);
231 border: 1px solid var(--border);
232 border-radius: 12px;
233 cursor: pointer;
234 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
235 }
236 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
237 .new-repo-vis-card:has(input:checked) {
238 border-color: rgba(140,109,255,0.55);
239 background: rgba(140,109,255,0.06);
240 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
241 }
242 .new-repo-vis-radio {
243 margin-top: 3px;
244 accent-color: var(--accent);
245 }
246 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
247 .new-repo-vis-label {
248 font-size: 14px;
249 font-weight: 600;
250 color: var(--text-strong);
251 }
252 .new-repo-vis-desc {
253 font-size: 12.5px;
254 color: var(--text-muted);
255 line-height: 1.45;
256 }
257 .new-repo-callout {
258 margin-top: var(--space-2);
259 padding: var(--space-3) var(--space-4);
260 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
261 border: 1px solid rgba(140,109,255,0.2);
262 border-radius: 12px;
263 }
264 .new-repo-callout-eyebrow {
265 font-size: 11px;
266 font-family: var(--font-mono);
267 text-transform: uppercase;
268 letter-spacing: 0.12em;
269 color: var(--accent);
270 font-weight: 700;
271 margin-bottom: 4px;
272 }
273 .new-repo-callout-body {
274 font-size: 13px;
275 color: var(--text);
276 line-height: 1.5;
277 margin: 0;
278 }
279 .new-repo-callout-body code {
280 font-family: var(--font-mono);
281 font-size: 12px;
282 color: var(--accent);
283 background: rgba(140,109,255,0.1);
284 border-radius: 4px;
285 padding: 1px 5px;
286 }
287 .new-repo-actions {
288 display: flex;
289 gap: var(--space-2);
290 margin-top: var(--space-2);
291 }
292 .new-repo-submit { min-width: 180px; }
293
294 /* ───────── profile ───────── */
295 .profile-hero {
296 position: relative;
297 margin-bottom: var(--space-5);
298 padding: var(--space-5) var(--space-6);
299 background: var(--bg-elevated);
300 border: 1px solid var(--border);
301 border-radius: 16px;
302 overflow: hidden;
303 }
304 .profile-hero-orb-wrap {
305 position: absolute;
306 inset: -25% -10% auto auto;
307 width: 360px;
308 height: 360px;
309 pointer-events: none;
310 z-index: 0;
311 }
312 .profile-hero-orb {
313 position: absolute;
314 inset: 0;
315 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
316 filter: blur(80px);
317 opacity: 0.7;
318 animation: cbHeroOrb 14s ease-in-out infinite;
319 }
320 .profile-hero-inner {
321 position: relative;
322 z-index: 1;
323 display: flex;
324 align-items: flex-start;
325 gap: var(--space-5);
326 }
327 .profile-hero-avatar {
328 flex: 0 0 auto;
329 width: 88px;
330 height: 88px;
331 border-radius: 50%;
332 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
333 color: #fff;
334 display: flex;
335 align-items: center;
336 justify-content: center;
337 font-size: 38px;
338 font-weight: 700;
339 font-family: var(--font-display);
340 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
341 }
342 .profile-hero-text { flex: 1; min-width: 0; }
343 .profile-eyebrow {
344 font-size: 12px;
345 font-family: var(--font-mono);
346 color: var(--text-muted);
347 letter-spacing: 0.1em;
348 text-transform: uppercase;
349 margin-bottom: var(--space-2);
350 }
351 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
352 .profile-name {
353 font-family: var(--font-display);
354 font-weight: 800;
355 letter-spacing: -0.028em;
356 font-size: clamp(28px, 3.6vw, 36px);
357 line-height: 1.05;
358 margin: 0 0 4px;
359 color: var(--text-strong);
360 }
361 .profile-handle {
362 font-family: var(--font-mono);
363 font-size: 13px;
364 color: var(--text-muted);
365 margin-bottom: var(--space-2);
366 }
367 .profile-bio {
368 font-size: 14.5px;
369 color: var(--text);
370 line-height: 1.55;
371 margin: 0 0 var(--space-3);
372 max-width: 640px;
373 }
374 .profile-meta {
375 display: flex;
376 align-items: center;
377 gap: var(--space-4);
378 flex-wrap: wrap;
379 font-size: 13px;
380 }
381 .profile-meta-link {
382 color: var(--text-muted);
383 transition: color var(--t-fast, 0.15s) ease;
384 }
385 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
386 .profile-meta-link strong {
387 color: var(--text-strong);
388 font-weight: 600;
389 font-variant-numeric: tabular-nums;
390 }
391 .profile-follow-form { margin: 0; }
392 @media (max-width: 600px) {
393 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
394 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
395 }
396 .profile-readme {
397 margin-bottom: var(--space-6);
398 background: var(--bg-elevated);
399 border: 1px solid var(--border);
400 border-radius: 12px;
401 overflow: hidden;
402 }
403 .profile-readme-head {
404 display: flex;
405 align-items: center;
406 gap: 8px;
407 padding: 10px 16px;
408 background: var(--bg-secondary);
409 border-bottom: 1px solid var(--border);
410 font-size: 13px;
411 color: var(--text-muted);
412 }
413 .profile-readme-icon { color: var(--accent); font-size: 14px; }
414 .profile-readme-body { padding: var(--space-5) var(--space-6); }
415 .profile-section-head {
416 display: flex;
417 align-items: baseline;
418 gap: var(--space-2);
419 margin-bottom: var(--space-3);
420 }
421 .profile-section-title {
422 font-family: var(--font-display);
423 font-weight: 700;
424 font-size: 20px;
425 letter-spacing: -0.015em;
426 margin: 0;
427 color: var(--text-strong);
428 }
429 .profile-section-count {
430 font-family: var(--font-mono);
431 font-size: 12px;
432 color: var(--text-muted);
433 background: var(--bg-secondary);
434 border: 1px solid var(--border);
435 border-radius: 999px;
436 padding: 2px 10px;
437 font-variant-numeric: tabular-nums;
438 }
439 .profile-empty {
440 display: flex;
441 align-items: center;
442 justify-content: space-between;
443 gap: var(--space-3);
444 padding: var(--space-4) var(--space-5);
445 background: var(--bg-elevated);
446 border: 1px dashed var(--border);
447 border-radius: 12px;
448 }
449 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
450
451 /* ───────── tree (file browser) ───────── */
452 .tree-header {
453 margin-bottom: var(--space-3);
454 display: flex;
455 flex-direction: column;
456 gap: var(--space-2);
457 }
458 .tree-header-row {
459 display: flex;
460 align-items: center;
461 justify-content: space-between;
462 gap: var(--space-3);
463 flex-wrap: wrap;
464 }
465 .tree-header-stats {
466 display: flex;
467 align-items: center;
468 gap: var(--space-3);
469 font-size: 12px;
470 color: var(--text-muted);
471 }
472 .tree-stat strong {
473 color: var(--text-strong);
474 font-weight: 600;
475 font-variant-numeric: tabular-nums;
476 }
477 .tree-stat-link {
478 color: var(--text-muted);
479 padding: 4px 10px;
480 border-radius: 999px;
481 border: 1px solid var(--border);
482 background: var(--bg-elevated);
483 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
484 }
485 .tree-stat-link:hover {
486 color: var(--accent);
487 border-color: rgba(140,109,255,0.45);
488 text-decoration: none;
489 }
490 .tree-breadcrumb-row {
491 font-size: 13px;
492 }
493
494 /* ───────── blob (file viewer) ───────── */
495 .blob-toolbar {
496 margin-bottom: var(--space-3);
497 display: flex;
498 flex-direction: column;
499 gap: var(--space-2);
500 }
501 .blob-breadcrumb { font-size: 13px; }
502 .blob-card {
503 background: var(--bg-elevated);
504 border: 1px solid var(--border);
505 border-radius: 12px;
506 overflow: hidden;
507 }
508 .blob-header-polished {
509 display: flex;
510 align-items: center;
511 justify-content: space-between;
512 gap: var(--space-3);
513 padding: 10px 14px;
514 background: var(--bg-secondary);
515 border-bottom: 1px solid var(--border);
516 }
517 .blob-header-meta {
518 display: flex;
519 align-items: center;
520 gap: var(--space-2);
521 min-width: 0;
522 flex: 1;
523 }
524 .blob-header-icon { font-size: 14px; opacity: 0.85; }
525 .blob-header-name {
526 font-family: var(--font-mono);
527 font-size: 13px;
528 color: var(--text-strong);
529 font-weight: 600;
530 overflow: hidden;
531 text-overflow: ellipsis;
532 white-space: nowrap;
533 }
534 .blob-header-size {
535 font-family: var(--font-mono);
536 font-size: 11.5px;
537 color: var(--text-muted);
538 font-variant-numeric: tabular-nums;
539 border-left: 1px solid var(--border);
540 padding-left: var(--space-2);
541 margin-left: 2px;
542 }
543 .blob-header-actions {
544 display: flex;
545 gap: 6px;
546 flex-shrink: 0;
547 }
548 .blob-pill {
549 display: inline-flex;
550 align-items: center;
551 padding: 4px 12px;
552 font-size: 12px;
553 font-weight: 500;
554 color: var(--text);
555 background: var(--bg-elevated);
556 border: 1px solid var(--border);
557 border-radius: 999px;
558 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
559 }
560 .blob-pill:hover {
561 color: var(--accent);
562 border-color: rgba(140,109,255,0.45);
563 text-decoration: none;
564 background: rgba(140,109,255,0.06);
565 }
566 .blob-pill-accent {
567 color: var(--accent);
568 border-color: rgba(140,109,255,0.35);
569 background: rgba(140,109,255,0.08);
570 }
571 .blob-pill-accent:hover {
572 background: rgba(140,109,255,0.14);
573 }
574 .blob-binary {
575 padding: var(--space-5);
576 color: var(--text-muted);
577 text-align: center;
578 font-size: 13px;
579 background: var(--bg);
580 }
581
582 /* ───────── commits list ───────── */
583 .commits-hero {
584 position: relative;
585 margin-bottom: var(--space-4);
586 padding: var(--space-5) var(--space-6);
587 background: var(--bg-elevated);
588 border: 1px solid var(--border);
589 border-radius: 16px;
590 overflow: hidden;
591 }
592 .commits-hero-orb-wrap {
593 position: absolute;
594 inset: -25% -10% auto auto;
595 width: 320px;
596 height: 320px;
597 pointer-events: none;
598 z-index: 0;
599 }
600 .commits-hero-orb {
601 position: absolute;
602 inset: 0;
603 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
604 filter: blur(80px);
605 opacity: 0.7;
606 animation: cbHeroOrb 14s ease-in-out infinite;
607 }
608 .commits-hero-inner { position: relative; z-index: 1; }
609 .commits-eyebrow {
610 font-size: 12px;
611 font-family: var(--font-mono);
612 color: var(--text-muted);
613 letter-spacing: 0.1em;
614 text-transform: uppercase;
615 margin-bottom: var(--space-2);
616 }
617 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
618 .commits-title {
619 font-family: var(--font-display);
620 font-weight: 800;
621 letter-spacing: -0.025em;
622 font-size: clamp(22px, 3vw, 30px);
623 line-height: 1.15;
624 margin: 0 0 var(--space-2);
625 color: var(--text-strong);
626 }
627 .commits-branch {
628 font-family: var(--font-mono);
629 font-size: 0.7em;
630 color: var(--text);
631 background: var(--bg-secondary);
632 border: 1px solid var(--border);
633 border-radius: 6px;
634 padding: 2px 8px;
635 font-weight: 500;
636 vertical-align: middle;
637 }
638 .commits-sub {
639 font-size: 14px;
640 color: var(--text-muted);
641 margin: 0;
642 line-height: 1.55;
643 max-width: 640px;
644 }
645 .commits-toolbar { margin-bottom: var(--space-3); }
646 .commits-list-wrap {
647 background: var(--bg-elevated);
648 border: 1px solid var(--border);
649 border-radius: 12px;
650 overflow: hidden;
651 }
652 .commits-empty {
653 padding: var(--space-6);
654 text-align: center;
655 color: var(--text-muted);
656 background: var(--bg-elevated);
657 border: 1px dashed var(--border);
658 border-radius: 12px;
659 }
660
661 /* ───────── commit detail ───────── */
662 .commit-detail-card {
663 position: relative;
664 margin-bottom: var(--space-5);
665 padding: var(--space-5) var(--space-5);
666 background: var(--bg-elevated);
667 border: 1px solid var(--border);
668 border-radius: 14px;
669 overflow: hidden;
670 }
671 .commit-detail-eyebrow {
672 display: flex;
673 align-items: center;
674 gap: var(--space-2);
675 font-size: 12px;
676 font-family: var(--font-mono);
677 text-transform: uppercase;
678 letter-spacing: 0.1em;
679 color: var(--text-muted);
680 margin-bottom: var(--space-2);
681 }
682 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
683 .commit-detail-sha-pill {
684 font-family: var(--font-mono);
685 font-size: 11.5px;
686 color: var(--text-strong);
687 background: var(--bg-secondary);
688 border: 1px solid var(--border);
689 border-radius: 999px;
690 padding: 2px 10px;
691 letter-spacing: 0.04em;
692 }
693 .commit-detail-verify {
694 font-size: 10px;
695 padding: 2px 8px;
696 border-radius: 999px;
697 text-transform: uppercase;
698 letter-spacing: 0.06em;
699 font-weight: 700;
700 color: #fff;
701 }
702 .commit-detail-verify-ok {
703 background: linear-gradient(135deg, #2ea043, #34d399);
704 }
705 .commit-detail-verify-warn {
706 background: linear-gradient(135deg, #d29922, #f59e0b);
707 }
708 .commit-detail-title {
709 font-family: var(--font-display);
710 font-weight: 700;
711 letter-spacing: -0.018em;
712 font-size: clamp(20px, 2.5vw, 26px);
713 line-height: 1.25;
714 margin: 0 0 var(--space-2);
715 color: var(--text-strong);
716 }
717 .commit-detail-body {
718 white-space: pre-wrap;
719 color: var(--text-muted);
720 font-size: 14px;
721 line-height: 1.55;
722 margin: 0 0 var(--space-3);
723 font-family: var(--font-mono);
724 background: var(--bg);
725 border: 1px solid var(--border);
726 border-radius: 8px;
727 padding: var(--space-3) var(--space-4);
728 max-height: 280px;
729 overflow: auto;
730 }
731 .commit-detail-meta {
732 display: flex;
733 flex-wrap: wrap;
734 gap: var(--space-3);
735 font-size: 13px;
736 color: var(--text-muted);
737 margin-bottom: var(--space-3);
738 }
739 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
740 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
741 .commit-detail-sha-link {
742 font-family: var(--font-mono);
743 font-size: 12.5px;
744 color: var(--accent);
745 background: rgba(140,109,255,0.08);
746 border-radius: 6px;
747 padding: 1px 6px;
748 margin-left: 2px;
749 }
750 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
751 .commit-detail-stats {
752 display: flex;
753 flex-wrap: wrap;
754 align-items: center;
755 gap: var(--space-3);
756 padding-top: var(--space-3);
757 border-top: 1px solid var(--border);
758 font-size: 13px;
759 color: var(--text-muted);
760 }
761 .commit-detail-stat {
762 display: inline-flex;
763 align-items: center;
764 gap: 4px;
765 font-variant-numeric: tabular-nums;
766 }
767 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
768 .commit-detail-stat-add strong { color: #34d399; }
769 .commit-detail-stat-del strong { color: #f87171; }
770 .commit-detail-stat-mark {
771 font-family: var(--font-mono);
772 font-weight: 700;
773 font-size: 14px;
774 }
775 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
776 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
777 .commit-detail-sha-full {
778 margin-left: auto;
779 font-family: var(--font-mono);
780 font-size: 11.5px;
781 color: var(--text-faint);
782 letter-spacing: 0.02em;
783 overflow: hidden;
784 text-overflow: ellipsis;
785 white-space: nowrap;
786 max-width: 100%;
787 }
788 .commit-detail-checks {
789 margin-top: var(--space-3);
790 padding-top: var(--space-3);
791 border-top: 1px solid var(--border);
792 }
793 .commit-detail-checks-head {
794 display: flex;
795 align-items: center;
796 gap: var(--space-2);
797 font-size: 13px;
798 color: var(--text-muted);
799 margin-bottom: 8px;
800 }
801 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
802 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
803 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
804 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
805 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
806 .commit-detail-check {
807 font-size: 11px;
808 padding: 2px 8px;
809 border-radius: 999px;
810 color: #fff;
811 font-weight: 500;
812 }
813 .commit-detail-check a { color: inherit; text-decoration: none; }
814 .commit-detail-check-success { background: #2ea043; }
815 .commit-detail-check-pending { background: #d29922; }
816 .commit-detail-check-failure { background: #da3633; }
817
818 /* ───────── blame ───────── */
819 .blame-toolbar {
820 margin-bottom: var(--space-3);
821 font-size: 13px;
822 }
823 .blame-card { background: var(--bg-elevated); }
824 .blame-header {
825 display: flex;
826 align-items: center;
827 justify-content: space-between;
828 gap: var(--space-3);
829 padding: 10px 14px;
830 background: var(--bg-secondary);
831 border-bottom: 1px solid var(--border);
832 }
833 .blame-header-meta {
834 display: flex;
835 align-items: center;
836 gap: var(--space-2);
837 min-width: 0;
838 flex-wrap: wrap;
839 }
840 .blame-header-icon { color: var(--accent); font-size: 14px; }
841 .blame-header-name {
842 font-family: var(--font-mono);
843 font-size: 13px;
844 color: var(--text-strong);
845 font-weight: 600;
846 }
847 .blame-header-tag {
848 font-size: 10.5px;
849 text-transform: uppercase;
850 letter-spacing: 0.08em;
851 font-family: var(--font-mono);
852 background: rgba(140,109,255,0.12);
853 color: var(--accent);
854 border-radius: 999px;
855 padding: 2px 8px;
856 font-weight: 600;
857 }
858 .blame-header-stats {
859 font-family: var(--font-mono);
860 font-size: 11.5px;
861 color: var(--text-muted);
862 font-variant-numeric: tabular-nums;
863 border-left: 1px solid var(--border);
864 padding-left: var(--space-2);
865 }
866 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
867
868 /* ───────── search ───────── */
869 .search-hero {
870 position: relative;
871 margin-bottom: var(--space-4);
872 padding: var(--space-5) var(--space-6);
873 background: var(--bg-elevated);
874 border: 1px solid var(--border);
875 border-radius: 16px;
876 overflow: hidden;
877 }
878 .search-eyebrow {
879 font-size: 12px;
880 font-family: var(--font-mono);
881 color: var(--text-muted);
882 letter-spacing: 0.1em;
883 text-transform: uppercase;
884 margin-bottom: var(--space-2);
885 }
886 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
887 .search-title {
888 font-family: var(--font-display);
889 font-weight: 800;
890 letter-spacing: -0.025em;
891 font-size: clamp(22px, 3vw, 30px);
892 line-height: 1.15;
893 margin: 0 0 var(--space-3);
894 color: var(--text-strong);
895 }
896 .search-form {
897 display: flex;
898 gap: var(--space-2);
899 align-items: stretch;
900 }
901 .search-input-wrap {
902 position: relative;
903 flex: 1;
904 display: flex;
905 align-items: center;
906 }
907 .search-input-icon {
908 position: absolute;
909 left: 12px;
910 color: var(--text-muted);
911 font-size: 15px;
912 pointer-events: none;
913 }
914 .search-input {
915 appearance: none;
916 width: 100%;
917 padding: 10px 12px 10px 34px;
918 background: var(--bg);
919 border: 1px solid var(--border);
920 border-radius: 10px;
921 color: var(--text-strong);
922 font-size: 14px;
923 font-family: inherit;
924 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
925 }
926 .search-input:focus {
927 outline: none;
928 border-color: var(--accent);
929 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
930 }
931 .search-submit { min-width: 96px; }
932 .search-results-head {
933 display: flex;
934 align-items: baseline;
935 gap: var(--space-2);
936 margin-bottom: var(--space-3);
937 font-size: 13px;
938 color: var(--text-muted);
939 }
940 .search-results-count strong {
941 color: var(--text-strong);
942 font-weight: 600;
943 font-variant-numeric: tabular-nums;
944 }
945 .search-results-q { color: var(--text-strong); font-weight: 600; }
946 .search-results-head code {
947 font-family: var(--font-mono);
948 font-size: 12px;
949 background: var(--bg-secondary);
950 border: 1px solid var(--border);
951 border-radius: 5px;
952 padding: 1px 6px;
953 color: var(--text);
954 }
955 .search-empty {
956 padding: var(--space-5) var(--space-6);
957 background: var(--bg-elevated);
958 border: 1px dashed var(--border);
959 border-radius: 12px;
960 color: var(--text-muted);
961 font-size: 14px;
962 }
963 .search-empty strong { color: var(--text-strong); }
964 .search-results {
965 display: flex;
966 flex-direction: column;
967 gap: var(--space-3);
968 }
969 .search-file-head {
970 display: flex;
971 align-items: center;
972 justify-content: space-between;
973 gap: var(--space-2);
974 }
975 .search-file-link {
976 font-family: var(--font-mono);
977 font-size: 13px;
978 color: var(--text-strong);
979 font-weight: 600;
980 }
981 .search-file-link:hover { color: var(--accent); text-decoration: none; }
982 .search-file-count {
983 font-family: var(--font-mono);
984 font-size: 11.5px;
985 color: var(--text-muted);
986 font-variant-numeric: tabular-nums;
987 }
988`;
989
fc1817aClaude990// Home page
06d5ffeClaude991web.get("/", async (c) => {
992 const user = c.get("user");
993
994 if (user) {
0316dbbClaude995 return c.redirect("/dashboard");
06d5ffeClaude996 }
997
8e9f1d9Claude998 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude999 let publicStats: PublicStats | null = null;
8e9f1d9Claude1000 try {
1001 const [repoRow] = await db
1002 .select({ n: sql<number>`count(*)::int` })
1003 .from(repositories)
1004 .where(eq(repositories.isPrivate, false));
1005 const [userRow] = await db
1006 .select({ n: sql<number>`count(*)::int` })
1007 .from(users);
1008 stats = {
1009 publicRepos: Number(repoRow?.n ?? 0),
1010 users: Number(userRow?.n ?? 0),
1011 };
1012 } catch {
1013 stats = undefined;
1014 }
1015
52ad8b1Claude1016 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1017 try {
1018 publicStats = await computePublicStats();
1019 } catch {
1020 publicStats = null;
1021 }
1022
534f04aClaude1023 // Block M1 — initial SSR snapshot for the live-now demo feed.
1024 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1025 // in try/catch so a freak module-level explosion can't take down /.
1026 let liveFeed: LandingLiveFeed | null = null;
1027 try {
1028 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1029 listQueuedAiBuildIssues(3),
1030 listRecentAutoMerges(3, 24),
1031 listRecentAiReviews(3, 24),
1032 countAiReviewsSince(24),
1033 listDemoActivityFeed(10),
1034 ]);
1035 liveFeed = {
1036 queued: queued.map((i) => ({
1037 repo: i.repo,
1038 number: i.number,
1039 title: i.title,
1040 createdAt: i.createdAt,
1041 })),
1042 merges: merges.map((m) => ({
1043 repo: m.repo,
1044 number: m.number,
1045 title: m.title,
1046 mergedAt: m.mergedAt,
1047 })),
1048 reviews: reviewList.map((r) => ({
1049 repo: r.repo,
1050 prNumber: r.prNumber,
1051 commentSnippet: r.commentSnippet,
1052 createdAt: r.createdAt,
1053 })),
1054 reviewCount,
1055 feed: feed.map((e) => ({
1056 kind: e.kind,
1057 repo: e.repo,
1058 ref: e.ref,
1059 at: e.at,
1060 })),
1061 };
1062 } catch {
1063 liveFeed = null;
1064 }
1065
fc1817aClaude1066 return c.html(
5f2e749Claude1067 <Layout
1068 user={null}
1069 // Block L10 — SEO + Open Graph for the public landing.
1070 fullTitle="Gluecron — The git host built around Claude"
1071 description="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
1072 ogTitle="Gluecron — The git host built around Claude"
1073 ogDescription="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
1074 ogType="website"
1075 twitterCard="summary_large_image"
1076 >
534f04aClaude1077 <LandingPage stats={stats} publicStats={publicStats} liveFeed={liveFeed} />
fc1817aClaude1078 </Layout>
1079 );
1080});
1081
06d5ffeClaude1082// New repository form
1083web.get("/new", requireAuth, (c) => {
1084 const user = c.get("user")!;
1085 const error = c.req.query("error");
1086
1087 return c.html(
1088 <Layout title="New repository" user={user}>
efb11c5Claude1089 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1090 <div class="new-repo-hero">
1091 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1092 <div class="new-repo-hero-orb" />
1093 </div>
1094 <div class="new-repo-hero-inner">
1095 <div class="new-repo-eyebrow">
1096 <strong>Create</strong> · {user.username}
1097 </div>
1098 <h1 class="new-repo-title">
1099 Spin up a <span class="gradient-text">repository</span>.
1100 </h1>
1101 <p class="new-repo-sub">
1102 Push your first commit, and Gluecron wires up gate checks, AI review,
1103 and auto-merge from the moment your branch lands.
1104 </p>
1105 </div>
1106 </div>
06d5ffeClaude1107 <div class="new-repo-form">
efb11c5Claude1108 {error && (
1109 <div class="new-repo-error" role="alert">
1110 {decodeURIComponent(error)}
1111 </div>
1112 )}
1113 <form method="post" action="/new" class="new-repo-form-grid">
1114 <div class="new-repo-row">
1115 <label class="new-repo-label">Owner</label>
1116 <input
1117 type="text"
1118 value={user.username}
1119 disabled
1120 aria-label="Owner"
1121 class="new-repo-input new-repo-input-disabled"
1122 />
06d5ffeClaude1123 </div>
efb11c5Claude1124 <div class="new-repo-row">
1125 <label class="new-repo-label" for="name">
1126 Repository name
1127 </label>
06d5ffeClaude1128 <input
1129 type="text"
1130 id="name"
1131 name="name"
1132 required
1133 pattern="^[a-zA-Z0-9._-]+$"
1134 placeholder="my-project"
1135 autocomplete="off"
efb11c5Claude1136 class="new-repo-input"
06d5ffeClaude1137 />
efb11c5Claude1138 <p class="new-repo-hint">
1139 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1140 <code>{user.username}/&lt;name&gt;</code>.
1141 </p>
06d5ffeClaude1142 </div>
efb11c5Claude1143 <div class="new-repo-row">
1144 <label class="new-repo-label" for="description">
1145 Description{" "}
1146 <span class="new-repo-label-optional">(optional)</span>
1147 </label>
06d5ffeClaude1148 <input
1149 type="text"
1150 id="description"
1151 name="description"
1152 placeholder="A short description of your repository"
efb11c5Claude1153 class="new-repo-input"
06d5ffeClaude1154 />
1155 </div>
efb11c5Claude1156 <div class="new-repo-row">
1157 <span class="new-repo-label">Visibility</span>
1158 <div class="new-repo-visibility">
1159 <label class="new-repo-vis-card">
1160 <input
1161 type="radio"
1162 name="visibility"
1163 value="public"
1164 checked
1165 class="new-repo-vis-radio"
1166 />
1167 <span class="new-repo-vis-body">
1168 <span class="new-repo-vis-label">Public</span>
1169 <span class="new-repo-vis-desc">
1170 Anyone can see this repository. You choose who can commit.
1171 </span>
1172 </span>
1173 </label>
1174 <label class="new-repo-vis-card">
1175 <input
1176 type="radio"
1177 name="visibility"
1178 value="private"
1179 class="new-repo-vis-radio"
1180 />
1181 <span class="new-repo-vis-body">
1182 <span class="new-repo-vis-label">Private</span>
1183 <span class="new-repo-vis-desc">
1184 Only you (and collaborators you invite) can see this
1185 repository.
1186 </span>
1187 </span>
1188 </label>
1189 </div>
1190 </div>
1191 <div class="new-repo-callout">
1192 <div class="new-repo-callout-eyebrow">AI-native by default</div>
1193 <p class="new-repo-callout-body">
1194 Every push is gate-checked and reviewed by Claude automatically.
1195 Label an issue <code>ai-build</code> and Gluecron will open the PR
1196 for you.
1197 </p>
1198 </div>
1199 <div class="new-repo-actions">
1200 <button type="submit" class="btn btn-primary new-repo-submit">
1201 Create repository
1202 </button>
1203 <a href="/dashboard" class="btn new-repo-cancel">
1204 Cancel
1205 </a>
06d5ffeClaude1206 </div>
1207 </form>
1208 </div>
1209 </Layout>
1210 );
1211});
1212
1213web.post("/new", requireAuth, async (c) => {
1214 const user = c.get("user")!;
1215 const body = await c.req.parseBody();
1216 const name = String(body.name || "").trim();
1217 const description = String(body.description || "").trim();
1218 const isPrivate = body.visibility === "private";
1219
1220 if (!name) {
1221 return c.redirect("/new?error=Repository+name+is+required");
1222 }
1223
c63b860Claude1224 // P4 — plan-quota gate. Fail-open inside the helper so a billing
1225 // outage never blocks repo creation.
1226 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
1227 const gate = await checkRepoCreateAllowed(user.id);
1228 if (!gate.ok) {
1229 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
1230 }
1231
06d5ffeClaude1232 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
1233 return c.redirect("/new?error=Invalid+repository+name");
1234 }
1235
1236 if (await repoExists(user.username, name)) {
1237 return c.redirect("/new?error=Repository+already+exists");
1238 }
1239
1240 const diskPath = await initBareRepo(user.username, name);
1241
3ef4c9dClaude1242 const [newRepo] = await db
1243 .insert(repositories)
1244 .values({
1245 name,
1246 ownerId: user.id,
1247 description: description || null,
1248 isPrivate,
1249 diskPath,
1250 })
1251 .returning();
1252
1253 if (newRepo) {
1254 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
1255 await bootstrapRepository({
1256 repositoryId: newRepo.id,
1257 ownerUserId: user.id,
1258 defaultBranch: "main",
1259 });
1260 }
06d5ffeClaude1261
1262 return c.redirect(`/${user.username}/${name}`);
1263});
1264
1265// User profile
fc1817aClaude1266web.get("/:owner", async (c) => {
06d5ffeClaude1267 const { owner: ownerName } = c.req.param();
1268 const user = c.get("user");
1269
1270 // Avoid clashing with fixed routes
1271 if (
1272 ["login", "register", "logout", "new", "settings", "api"].includes(
1273 ownerName
1274 )
1275 ) {
1276 return c.notFound();
1277 }
1278
1279 let ownerUser;
1280 try {
1281 const [found] = await db
1282 .select()
1283 .from(users)
1284 .where(eq(users.username, ownerName))
1285 .limit(1);
1286 ownerUser = found;
1287 } catch {
1288 // DB not available — check if repos exist on disk
1289 ownerUser = null;
1290 }
1291
1292 // Even without DB, show repos if they exist on disk
1293 let repos: any[] = [];
1294 if (ownerUser) {
1295 const allRepos = await db
1296 .select()
1297 .from(repositories)
1298 .where(eq(repositories.ownerId, ownerUser.id))
1299 .orderBy(desc(repositories.updatedAt));
1300
1301 // Show public repos to everyone, private only to owner
1302 repos =
1303 user?.id === ownerUser.id
1304 ? allRepos
1305 : allRepos.filter((r) => !r.isPrivate);
1306 }
1307
7aa8b99Claude1308 // Block J4 — follow counts + viewer's follow state
1309 let followState = {
1310 followers: 0,
1311 following: 0,
1312 viewerFollows: false,
1313 };
1314 if (ownerUser) {
1315 try {
1316 const { followCounts, isFollowing } = await import("../lib/follows");
1317 const counts = await followCounts(ownerUser.id);
1318 followState.followers = counts.followers;
1319 followState.following = counts.following;
1320 if (user && user.id !== ownerUser.id) {
1321 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
1322 }
1323 } catch {
1324 // DB hiccup — fall back to zeros.
1325 }
1326 }
1327 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
1328
d412586Claude1329 // Block J5 — profile README. Render owner/owner repo's README on the
1330 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
1331 // back to "<user>/.github" for org-style profile repos.
1332 let profileReadmeHtml: string | null = null;
1333 try {
1334 const candidates = [ownerName, ".github"];
1335 for (const rname of candidates) {
1336 if (await repoExists(ownerName, rname)) {
1337 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
1338 const md = await getReadme(ownerName, rname, ref);
1339 if (md) {
1340 profileReadmeHtml = renderMarkdown(md);
1341 break;
1342 }
1343 }
1344 }
1345 } catch {
1346 profileReadmeHtml = null;
1347 }
1348
efb11c5Claude1349 const displayName = ownerUser?.displayName || ownerName;
1350 const memberSince = ownerUser?.createdAt
1351 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
1352 : null;
1353
fc1817aClaude1354 return c.html(
06d5ffeClaude1355 <Layout title={ownerName} user={user}>
efb11c5Claude1356 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1357 <div class="profile-hero">
1358 <div class="profile-hero-orb-wrap" aria-hidden="true">
1359 <div class="profile-hero-orb" />
06d5ffeClaude1360 </div>
efb11c5Claude1361 <div class="profile-hero-inner">
1362 <div class="profile-hero-avatar" aria-hidden="true">
1363 {displayName[0].toUpperCase()}
1364 </div>
1365 <div class="profile-hero-text">
1366 <div class="profile-eyebrow">
1367 <strong>Developer</strong>
1368 {memberSince && !Number.isNaN(memberSince.getTime()) && (
1369 <>
1370 {" "}· Joined{" "}
1371 {memberSince.toLocaleDateString("en-US", {
1372 month: "short",
1373 year: "numeric",
1374 })}
1375 </>
1376 )}
1377 </div>
1378 <h1 class="profile-name">
1379 <span class="gradient-text">{displayName}</span>
1380 </h1>
1381 <div class="profile-handle">@{ownerName}</div>
1382 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
1383 <div class="profile-meta">
1384 <a href={`/${ownerName}/followers`} class="profile-meta-link">
1385 <strong>{followState.followers}</strong> follower
1386 {followState.followers === 1 ? "" : "s"}
1387 </a>
1388 <a href={`/${ownerName}/following`} class="profile-meta-link">
1389 <strong>{followState.following}</strong> following
1390 </a>
1391 <a href={`/${ownerName}`} class="profile-meta-link">
1392 <strong>{repos.length}</strong> repo
1393 {repos.length === 1 ? "" : "s"}
1394 </a>
1395 {canFollow && (
1396 <form
1397 method="post"
1398 action={`/${ownerName}/${
1399 followState.viewerFollows ? "unfollow" : "follow"
1400 }`}
1401 class="profile-follow-form"
7aa8b99Claude1402 >
efb11c5Claude1403 <button
1404 type="submit"
1405 class={`btn ${
1406 followState.viewerFollows ? "" : "btn-primary"
1407 } btn-sm`}
1408 >
1409 {followState.viewerFollows ? "Unfollow" : "Follow"}
1410 </button>
1411 </form>
1412 )}
1413 </div>
7aa8b99Claude1414 </div>
06d5ffeClaude1415 </div>
1416 </div>
d412586Claude1417 {profileReadmeHtml && (
efb11c5Claude1418 <div class="profile-readme">
1419 <div class="profile-readme-head">
1420 <span class="profile-readme-icon">{"☰"}</span>
1421 <span>{ownerName}/{ownerName} README.md</span>
1422 </div>
1423 <div
1424 class="markdown-body profile-readme-body"
1425 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
1426 />
1427 </div>
d412586Claude1428 )}
efb11c5Claude1429 <div class="profile-section-head">
1430 <h2 class="profile-section-title">Repositories</h2>
1431 <span class="profile-section-count">{repos.length}</span>
1432 </div>
06d5ffeClaude1433 {repos.length === 0 ? (
efb11c5Claude1434 <div class="profile-empty">
1435 <p class="profile-empty-text">
1436 No repositories yet
1437 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
1438 </p>
1439 {user?.id === ownerUser?.id && (
1440 <a href="/new" class="btn btn-primary btn-sm">
1441 + Create your first
1442 </a>
1443 )}
1444 </div>
06d5ffeClaude1445 ) : (
1446 <div class="card-grid">
1447 {repos.map((repo) => (
1448 <RepoCard repo={repo} ownerName={ownerName} />
1449 ))}
1450 </div>
1451 )}
fc1817aClaude1452 </Layout>
1453 );
1454});
1455
06d5ffeClaude1456// Star/unstar a repo
1457web.post("/:owner/:repo/star", requireAuth, async (c) => {
1458 const { owner: ownerName, repo: repoName } = c.req.param();
1459 const user = c.get("user")!;
1460
1461 try {
1462 const [ownerUser] = await db
1463 .select()
1464 .from(users)
1465 .where(eq(users.username, ownerName))
1466 .limit(1);
1467 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
1468
1469 const [repo] = await db
1470 .select()
1471 .from(repositories)
1472 .where(
1473 and(
1474 eq(repositories.ownerId, ownerUser.id),
1475 eq(repositories.name, repoName)
1476 )
1477 )
1478 .limit(1);
1479 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
1480
1481 // Toggle star
1482 const [existing] = await db
1483 .select()
1484 .from(stars)
1485 .where(
1486 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
1487 )
1488 .limit(1);
1489
1490 if (existing) {
1491 await db.delete(stars).where(eq(stars.id, existing.id));
1492 await db
1493 .update(repositories)
1494 .set({ starCount: Math.max(0, repo.starCount - 1) })
1495 .where(eq(repositories.id, repo.id));
1496 } else {
1497 await db.insert(stars).values({
1498 userId: user.id,
1499 repositoryId: repo.id,
1500 });
1501 await db
1502 .update(repositories)
1503 .set({ starCount: repo.starCount + 1 })
1504 .where(eq(repositories.id, repo.id));
1505 }
1506 } catch {
1507 // DB error — ignore
1508 }
1509
1510 return c.redirect(`/${ownerName}/${repoName}`);
1511});
1512
fc1817aClaude1513// Repository overview — file tree at HEAD
1514web.get("/:owner/:repo", async (c) => {
1515 const { owner, repo } = c.req.param();
06d5ffeClaude1516 const user = c.get("user");
fc1817aClaude1517
f1dc7c7Claude1518 // ── Loading skeleton (flag-gated) ──
1519 // Renders an SSR'd shell with file-tree + README placeholders when
1520 // `?skeleton=1` is set. Lets the user see the page structure before
1521 // git ops finish. Behind a flag for now so we never flash before the
1522 // real content lands.
1523 if (c.req.query("skeleton") === "1") {
1524 return c.html(
1525 <Layout title={`${owner}/${repo}`} user={user}>
1526 <style
1527 dangerouslySetInnerHTML={{
1528 __html: `
1529 .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; }
1530 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1531 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
1532 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
1533 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
1534 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
1535 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
1536 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
1537 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
1538 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
1539 .repo-skel-readme { height: 320px; border-radius: 12px; }
1540 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
1541 .repo-skel-side-card { height: 180px; border-radius: 12px; }
1542 `,
1543 }}
1544 />
1545 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
1546 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
1547 <div class="repo-skel-grid" aria-hidden="true">
1548 <div>
1549 <div class="repo-skel repo-skel-branch" />
1550 <div class="repo-skel-tree">
1551 {Array.from({ length: 8 }).map(() => (
1552 <div class="repo-skel repo-skel-tree-row" />
1553 ))}
1554 </div>
1555 <div class="repo-skel repo-skel-readme" />
1556 </div>
1557 <aside class="repo-skel-side">
1558 <div class="repo-skel repo-skel-side-card" />
1559 <div class="repo-skel repo-skel-side-card" />
1560 </aside>
1561 </div>
1562 <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">
1563 Loading {owner}/{repo}…
1564 </span>
1565 </Layout>
1566 );
1567 }
1568
8f50ed0Claude1569 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
1570 trackByName(owner, repo, "view", {
1571 userId: user?.id || null,
1572 path: `/${owner}/${repo}`,
1573 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
1574 userAgent: c.req.header("user-agent") || null,
1575 referer: c.req.header("referer") || null,
a28cedeClaude1576 }).catch((err) => {
1577 console.warn(
1578 `[web] view tracking failed for ${owner}/${repo}:`,
1579 err instanceof Error ? err.message : err
1580 );
1581 });
8f50ed0Claude1582
fc1817aClaude1583 if (!(await repoExists(owner, repo))) {
1584 return c.html(
06d5ffeClaude1585 <Layout title="Not Found" user={user}>
fc1817aClaude1586 <div class="empty-state">
1587 <h2>Repository not found</h2>
1588 <p>
1589 {owner}/{repo} does not exist.
1590 </p>
1591 </div>
1592 </Layout>,
1593 404
1594 );
1595 }
1596
05b973eClaude1597 // Parallelize all independent operations
1598 const [defaultBranch, branches] = await Promise.all([
1599 getDefaultBranch(owner, repo).then((b) => b || "main"),
1600 listBranches(owner, repo),
1601 ]);
1602 const [tree, starInfo] = await Promise.all([
1603 getTree(owner, repo, defaultBranch),
1604 // Star info fetched in parallel with tree
1605 (async () => {
1606 try {
1607 const [ownerUser] = await db
1608 .select()
1609 .from(users)
1610 .where(eq(users.username, owner))
1611 .limit(1);
71cd5ecClaude1612 if (!ownerUser)
1613 return {
1614 starCount: 0,
1615 starred: false,
1616 archived: false,
1617 isTemplate: false,
544d842Claude1618 forkCount: 0,
1619 description: null as string | null,
1620 pushedAt: null as Date | null,
1621 createdAt: null as Date | null,
71cd5ecClaude1622 };
05b973eClaude1623 const [repoRow] = await db
1624 .select()
1625 .from(repositories)
1626 .where(
1627 and(
1628 eq(repositories.ownerId, ownerUser.id),
1629 eq(repositories.name, repo)
1630 )
06d5ffeClaude1631 )
05b973eClaude1632 .limit(1);
71cd5ecClaude1633 if (!repoRow)
1634 return {
1635 starCount: 0,
1636 starred: false,
1637 archived: false,
1638 isTemplate: false,
544d842Claude1639 forkCount: 0,
1640 description: null as string | null,
1641 pushedAt: null as Date | null,
1642 createdAt: null as Date | null,
71cd5ecClaude1643 };
05b973eClaude1644 let starred = false;
06d5ffeClaude1645 if (user) {
1646 const [star] = await db
1647 .select()
1648 .from(stars)
1649 .where(
1650 and(
1651 eq(stars.userId, user.id),
1652 eq(stars.repositoryId, repoRow.id)
1653 )
1654 )
1655 .limit(1);
1656 starred = !!star;
1657 }
71cd5ecClaude1658 return {
1659 starCount: repoRow.starCount,
1660 starred,
1661 archived: repoRow.isArchived,
1662 isTemplate: repoRow.isTemplate,
544d842Claude1663 forkCount: repoRow.forkCount,
1664 description: repoRow.description as string | null,
1665 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
1666 createdAt: (repoRow.createdAt as Date | null) ?? null,
71cd5ecClaude1667 };
05b973eClaude1668 } catch {
71cd5ecClaude1669 return {
1670 starCount: 0,
1671 starred: false,
1672 archived: false,
1673 isTemplate: false,
544d842Claude1674 forkCount: 0,
1675 description: null as string | null,
1676 pushedAt: null as Date | null,
1677 createdAt: null as Date | null,
71cd5ecClaude1678 };
06d5ffeClaude1679 }
05b973eClaude1680 })(),
1681 ]);
544d842Claude1682 const {
1683 starCount,
1684 starred,
1685 archived,
1686 isTemplate,
1687 forkCount,
1688 description,
1689 pushedAt,
1690 createdAt,
1691 } = starInfo;
1692
1693 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
1694 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
1695 const repoHomeCss = `
1696 .repo-home-hero {
1697 position: relative;
1698 margin-bottom: var(--space-5);
1699 padding: var(--space-5) var(--space-6);
1700 background: var(--bg-elevated);
1701 border: 1px solid var(--border);
1702 border-radius: 16px;
1703 overflow: hidden;
1704 }
1705 .repo-home-hero::before {
1706 content: '';
1707 position: absolute;
1708 top: 0; left: 0; right: 0;
1709 height: 2px;
1710 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1711 opacity: 0.7;
1712 pointer-events: none;
1713 }
1714 .repo-home-hero-orb-wrap {
1715 position: absolute;
1716 inset: -25% -10% auto auto;
1717 width: 360px;
1718 height: 360px;
1719 pointer-events: none;
1720 z-index: 0;
1721 }
1722 .repo-home-hero-orb {
1723 position: absolute;
1724 inset: 0;
1725 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
1726 filter: blur(80px);
1727 opacity: 0.7;
1728 animation: repoHomeOrb 14s ease-in-out infinite;
1729 }
1730 @keyframes repoHomeOrb {
1731 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
1732 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
1733 }
1734 @media (prefers-reduced-motion: reduce) {
1735 .repo-home-hero-orb { animation: none; }
1736 }
1737 .repo-home-hero-inner {
1738 position: relative;
1739 z-index: 1;
1740 }
1741 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
1742 .repo-home-eyebrow {
1743 font-size: 12px;
1744 font-family: var(--font-mono);
1745 color: var(--text-muted);
1746 letter-spacing: 0.1em;
1747 text-transform: uppercase;
1748 margin-bottom: var(--space-2);
1749 }
1750 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
1751 .repo-home-description {
1752 font-size: 15px;
1753 line-height: 1.55;
1754 color: var(--text);
1755 margin: 0;
1756 max-width: 720px;
1757 }
1758 .repo-home-description-empty {
1759 font-size: 14px;
1760 color: var(--text-muted);
1761 font-style: italic;
1762 margin: 0;
1763 }
1764 .repo-home-stat-row {
1765 display: flex;
1766 flex-wrap: wrap;
1767 gap: var(--space-4);
1768 margin-top: var(--space-3);
1769 font-size: 13px;
1770 color: var(--text-muted);
1771 }
1772 .repo-home-stat {
1773 display: inline-flex;
1774 align-items: center;
1775 gap: 6px;
1776 }
1777 .repo-home-stat strong {
1778 color: var(--text-strong);
1779 font-weight: 600;
1780 font-variant-numeric: tabular-nums;
1781 }
1782 .repo-home-stat .repo-home-stat-icon {
1783 color: var(--text-faint);
1784 font-size: 14px;
1785 line-height: 1;
1786 }
1787 .repo-home-stat a {
1788 color: var(--text-muted);
1789 transition: color var(--t-fast) var(--ease);
1790 }
1791 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
1792
1793 /* Two-column layout: file tree + sidebar */
1794 .repo-home-grid {
1795 display: grid;
1796 grid-template-columns: minmax(0, 1fr) 280px;
1797 gap: var(--space-5);
1798 align-items: start;
1799 }
1800 @media (max-width: 960px) {
1801 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
1802 }
1803 .repo-home-main { min-width: 0; }
1804
1805 /* Sidebar card */
1806 .repo-home-side {
1807 display: flex;
1808 flex-direction: column;
1809 gap: var(--space-4);
1810 }
1811 .repo-home-side-card {
1812 background: var(--bg-elevated);
1813 border: 1px solid var(--border);
1814 border-radius: 12px;
1815 padding: var(--space-4);
1816 }
1817 .repo-home-side-title {
1818 font-size: 11px;
1819 font-family: var(--font-mono);
1820 letter-spacing: 0.12em;
1821 text-transform: uppercase;
1822 color: var(--text-muted);
1823 margin: 0 0 var(--space-3);
1824 font-weight: 600;
1825 }
1826 .repo-home-side-row {
1827 display: flex;
1828 justify-content: space-between;
1829 align-items: center;
1830 gap: var(--space-2);
1831 font-size: 13px;
1832 padding: 6px 0;
1833 border-top: 1px solid var(--border);
1834 }
1835 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
1836 .repo-home-side-key {
1837 color: var(--text-muted);
1838 display: inline-flex;
1839 align-items: center;
1840 gap: 6px;
1841 }
1842 .repo-home-side-val {
1843 color: var(--text-strong);
1844 font-weight: 500;
1845 font-variant-numeric: tabular-nums;
1846 max-width: 60%;
1847 text-align: right;
1848 overflow: hidden;
1849 text-overflow: ellipsis;
1850 white-space: nowrap;
1851 }
1852 .repo-home-side-val a { color: var(--text-strong); }
1853 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
1854
1855 /* Clone / Code tabs */
1856 .repo-home-clone {
1857 background: var(--bg-elevated);
1858 border: 1px solid var(--border);
1859 border-radius: 12px;
1860 overflow: hidden;
1861 }
1862 .repo-home-clone-tabs {
1863 display: flex;
1864 gap: 0;
1865 background: var(--bg-secondary);
1866 border-bottom: 1px solid var(--border);
1867 padding: 0 var(--space-2);
1868 }
1869 .repo-home-clone-tab {
1870 appearance: none;
1871 background: transparent;
1872 border: 0;
1873 border-bottom: 2px solid transparent;
1874 padding: 9px 12px;
1875 font-size: 12px;
1876 font-weight: 500;
1877 color: var(--text-muted);
1878 cursor: pointer;
1879 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1880 font-family: inherit;
1881 margin-bottom: -1px;
1882 }
1883 .repo-home-clone-tab:hover { color: var(--text-strong); }
1884 .repo-home-clone-tab[aria-selected="true"] {
1885 color: var(--text-strong);
1886 border-bottom-color: var(--accent);
1887 }
1888 .repo-home-clone-body {
1889 padding: var(--space-3);
1890 display: flex;
1891 align-items: center;
1892 gap: var(--space-2);
1893 }
1894 .repo-home-clone-input {
1895 flex: 1;
1896 min-width: 0;
1897 font-family: var(--font-mono);
1898 font-size: 12px;
1899 background: var(--bg);
1900 border: 1px solid var(--border);
1901 border-radius: 8px;
1902 padding: 8px 10px;
1903 color: var(--text-strong);
1904 overflow: hidden;
1905 text-overflow: ellipsis;
1906 white-space: nowrap;
1907 }
1908 .repo-home-clone-copy {
1909 appearance: none;
1910 background: var(--bg-secondary);
1911 border: 1px solid var(--border);
1912 color: var(--text-strong);
1913 border-radius: 8px;
1914 padding: 8px 12px;
1915 font-size: 12px;
1916 font-weight: 600;
1917 cursor: pointer;
1918 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1919 font-family: inherit;
1920 }
1921 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
1922 .repo-home-clone-pane { display: none; }
1923 .repo-home-clone-pane[data-active="true"] { display: flex; }
1924
1925 /* README card */
1926 .repo-home-readme {
1927 margin-top: var(--space-5);
1928 background: var(--bg-elevated);
1929 border: 1px solid var(--border);
1930 border-radius: 12px;
1931 overflow: hidden;
1932 }
1933 .repo-home-readme-head {
1934 display: flex;
1935 align-items: center;
1936 gap: 8px;
1937 padding: 10px 16px;
1938 background: var(--bg-secondary);
1939 border-bottom: 1px solid var(--border);
1940 font-size: 13px;
1941 color: var(--text-muted);
1942 }
1943 .repo-home-readme-head .repo-home-readme-icon {
1944 color: var(--accent);
1945 font-size: 14px;
1946 }
1947 .repo-home-readme-body {
1948 padding: var(--space-5) var(--space-6);
1949 }
1950
1951 /* Empty-state CTA */
1952 .repo-home-empty {
1953 position: relative;
1954 margin-top: var(--space-4);
1955 background: var(--bg-elevated);
1956 border: 1px solid var(--border);
1957 border-radius: 16px;
1958 padding: var(--space-6);
1959 overflow: hidden;
1960 }
1961 .repo-home-empty::before {
1962 content: '';
1963 position: absolute;
1964 top: 0; left: 0; right: 0;
1965 height: 2px;
1966 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1967 opacity: 0.7;
1968 pointer-events: none;
1969 }
1970 .repo-home-empty-eyebrow {
1971 font-size: 12px;
1972 font-family: var(--font-mono);
1973 color: var(--accent);
1974 letter-spacing: 0.12em;
1975 text-transform: uppercase;
1976 margin-bottom: var(--space-2);
1977 font-weight: 600;
1978 }
1979 .repo-home-empty-title {
1980 font-family: var(--font-display);
1981 font-weight: 800;
1982 letter-spacing: -0.025em;
1983 font-size: clamp(22px, 3vw, 30px);
1984 line-height: 1.1;
1985 margin: 0 0 var(--space-2);
1986 color: var(--text-strong);
1987 }
1988 .repo-home-empty-sub {
1989 color: var(--text-muted);
1990 font-size: 14px;
1991 line-height: 1.55;
1992 max-width: 640px;
1993 margin: 0 0 var(--space-4);
1994 }
1995 .repo-home-empty-snippet {
1996 background: var(--bg);
1997 border: 1px solid var(--border);
1998 border-radius: 10px;
1999 padding: var(--space-3) var(--space-4);
2000 font-family: var(--font-mono);
2001 font-size: 12.5px;
2002 line-height: 1.7;
2003 color: var(--text-strong);
2004 overflow-x: auto;
2005 white-space: pre;
2006 margin: 0;
2007 }
2008 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2009 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2010
2011 @media (max-width: 720px) {
2012 .repo-home-hero { padding: var(--space-4) var(--space-4); }
2013 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
2014 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude2015 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
2016 .repo-home-side-val { max-width: 55%; }
2017 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
2018 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
2019 .repo-home-side-card { padding: var(--space-3); }
544d842Claude2020 }
2021 `;
2022 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
2023 // Best-effort SSH URL. If APP_BASE_URL is a hostname, this looks right; for
2024 // localhost it'll still render and just be slightly silly (which is fine for dev).
2025 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
2026 try {
2027 const host = new URL(config.appBaseUrl).hostname;
2028 if (host && host !== "localhost" && host !== "127.0.0.1") {
2029 cloneSshUrl = `git@${host}:${owner}/${repo}.git`;
2030 }
2031 } catch {
2032 // Fall through to default.
2033 }
2034 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
2035 const formatRelative = (date: Date | null): string => {
2036 if (!date) return "never";
2037 const ms = Date.now() - date.getTime();
2038 const s = Math.max(0, Math.round(ms / 1000));
2039 if (s < 60) return "just now";
2040 const m = Math.round(s / 60);
2041 if (m < 60) return `${m} min ago`;
2042 const h = Math.round(m / 60);
2043 if (h < 24) return `${h}h ago`;
2044 const d = Math.round(h / 24);
2045 if (d < 30) return `${d}d ago`;
2046 const mo = Math.round(d / 30);
2047 if (mo < 12) return `${mo}mo ago`;
2048 const y = Math.round(d / 365);
2049 return `${y}y ago`;
2050 };
06d5ffeClaude2051
fc1817aClaude2052 if (tree.length === 0) {
2053 return c.html(
06d5ffeClaude2054 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude2055 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2056 <div class="repo-home-hero">
2057 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2058 <div class="repo-home-hero-orb" />
2059 </div>
2060 <div class="repo-home-hero-inner">
2061 <div class="repo-home-eyebrow">
2062 <strong>Repository</strong> · {owner}
2063 </div>
2064 <RepoHeader
2065 owner={owner}
2066 repo={repo}
2067 starCount={starCount}
2068 starred={starred}
2069 forkCount={forkCount}
2070 currentUser={user?.username}
2071 archived={archived}
2072 isTemplate={isTemplate}
2073 />
2074 {description ? (
2075 <p class="repo-home-description">{description}</p>
2076 ) : (
2077 <p class="repo-home-description-empty">
2078 No description yet — push a README to tell the world what this
2079 ships.
2080 </p>
2081 )}
2082 </div>
2083 </div>
fc1817aClaude2084 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude2085 <div class="repo-home-empty">
2086 <div class="repo-home-empty-eyebrow">Getting started</div>
2087 <h2 class="repo-home-empty-title">
2088 Push your first commit to{" "}
2089 <span class="gradient-text">{repo}</span>.
2090 </h2>
2091 <p class="repo-home-empty-sub">
2092 This repository is empty. Paste the snippet below in an existing
2093 project directory to wire it up to Gluecron — your push triggers
2094 gate checks and AI review automatically.
2095 </p>
2096 <pre class="repo-home-empty-snippet">
2097 <span class="repo-home-cmt">
2098 # from an existing project directory
2099 </span>
2100 {"\n"}
2101 <span class="repo-home-cmd">git remote add</span>
2102 {` origin ${cloneHttpsUrl}`}
2103 {"\n"}
2104 <span class="repo-home-cmd">git branch</span>
2105 {` -M main`}
2106 {"\n"}
2107 <span class="repo-home-cmd">git push</span>
2108 {` -u origin main`}
2109 </pre>
fc1817aClaude2110 </div>
2111 </Layout>
2112 );
2113 }
2114
2115 const readme = await getReadme(owner, repo, defaultBranch);
2116
544d842Claude2117 // Sidebar facts — derived from data we already have.
2118 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
2119 const dirCount = tree.filter((e: any) => e.type === "tree").length;
2120
fc1817aClaude2121 return c.html(
06d5ffeClaude2122 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude2123 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2124 <div class="repo-home-hero">
2125 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2126 <div class="repo-home-hero-orb" />
2127 </div>
2128 <div class="repo-home-hero-inner">
2129 <div class="repo-home-eyebrow">
2130 <strong>Repository</strong> · {owner}
2131 </div>
2132 <RepoHeader
2133 owner={owner}
2134 repo={repo}
2135 starCount={starCount}
2136 starred={starred}
2137 forkCount={forkCount}
2138 currentUser={user?.username}
2139 archived={archived}
2140 isTemplate={isTemplate}
2141 />
2142 {description ? (
2143 <p class="repo-home-description">{description}</p>
2144 ) : (
2145 <p class="repo-home-description-empty">
2146 No description yet.
2147 </p>
2148 )}
2149 <div class="repo-home-stat-row" aria-label="Repository stats">
2150 <span class="repo-home-stat" title="Default branch">
2151 <span class="repo-home-stat-icon">{"⎇"}</span>
2152 <strong>{defaultBranch}</strong>
2153 </span>
2154 <a
2155 href={`/${owner}/${repo}/commits/${defaultBranch}`}
2156 class="repo-home-stat"
2157 title="Browse all branches"
2158 >
2159 <span class="repo-home-stat-icon">{"⊢"}</span>
2160 <strong>{branches.length}</strong>{" "}
2161 branch{branches.length === 1 ? "" : "es"}
2162 </a>
2163 <span class="repo-home-stat" title="Top-level entries">
2164 <span class="repo-home-stat-icon">{"■"}</span>
2165 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
2166 {dirCount > 0 && (
2167 <>
2168 {" · "}
2169 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
2170 </>
2171 )}
2172 </span>
2173 {pushedAt && (
2174 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
2175 <span class="repo-home-stat-icon">{"↻"}</span>
2176 Updated <strong>{formatRelative(pushedAt)}</strong>
2177 </span>
2178 )}
2179 </div>
2180 </div>
2181 </div>
71cd5ecClaude2182 {isTemplate && user && user.username !== owner && (
2183 <div
2184 class="panel"
dc26881CC LABS App2185 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude2186 >
2187 <div style="font-size:13px">
2188 <strong>Template repository.</strong> Create a new repository from
2189 this template's files.
2190 </div>
2191 <form
001af43Claude2192 method="post"
71cd5ecClaude2193 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App2194 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude2195 >
2196 <input
2197 type="text"
2198 name="name"
2199 placeholder="new-repo-name"
2200 required
2c3ba6ecopilot-swe-agent[bot]2201 aria-label="New repository name"
71cd5ecClaude2202 style="width:200px"
2203 />
2204 <button type="submit" class="btn btn-primary">
2205 Use this template
2206 </button>
2207 </form>
2208 </div>
2209 )}
fc1817aClaude2210 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude2211 <div class="repo-home-grid">
2212 <div class="repo-home-main">
2213 <BranchSwitcher
2214 owner={owner}
2215 repo={repo}
2216 currentRef={defaultBranch}
2217 branches={branches}
2218 pathType="tree"
2219 />
2220 <FileTable
2221 entries={tree}
2222 owner={owner}
2223 repo={repo}
2224 ref={defaultBranch}
2225 path=""
2226 />
2227 {readme && (() => {
2228 const readmeHtml = renderMarkdown(readme);
2229 return (
2230 <div class="repo-home-readme">
2231 <div class="repo-home-readme-head">
2232 <span class="repo-home-readme-icon">{"☰"}</span>
2233 <span>README.md</span>
2234 </div>
2235 <style>{markdownCss}</style>
2236 <div class="markdown-body repo-home-readme-body">
2237 {html([readmeHtml] as unknown as TemplateStringsArray)}
2238 </div>
2239 </div>
2240 );
2241 })()}
2242 </div>
2243 <aside class="repo-home-side" aria-label="Repository details">
2244 <div class="repo-home-clone">
2245 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
2246 <button
2247 type="button"
2248 class="repo-home-clone-tab"
2249 role="tab"
2250 aria-selected="true"
2251 data-pane="https"
2252 data-repo-home-clone-tab
2253 >
2254 HTTPS
2255 </button>
2256 <button
2257 type="button"
2258 class="repo-home-clone-tab"
2259 role="tab"
2260 aria-selected="false"
2261 data-pane="ssh"
2262 data-repo-home-clone-tab
2263 >
2264 SSH
2265 </button>
2266 <button
2267 type="button"
2268 class="repo-home-clone-tab"
2269 role="tab"
2270 aria-selected="false"
2271 data-pane="cli"
2272 data-repo-home-clone-tab
2273 >
2274 CLI
2275 </button>
2276 </div>
2277 <div
2278 class="repo-home-clone-pane"
2279 data-pane="https"
2280 data-active="true"
2281 role="tabpanel"
2282 >
2283 <div class="repo-home-clone-body">
2284 <input
2285 class="repo-home-clone-input"
2286 type="text"
2287 value={cloneHttpsUrl}
2288 readonly
2289 aria-label="HTTPS clone URL"
2290 data-repo-home-clone-input
2291 />
2292 <button
2293 type="button"
2294 class="repo-home-clone-copy"
2295 data-repo-home-copy={cloneHttpsUrl}
2296 >
2297 Copy
2298 </button>
2299 </div>
2300 </div>
2301 <div
2302 class="repo-home-clone-pane"
2303 data-pane="ssh"
2304 data-active="false"
2305 role="tabpanel"
2306 >
2307 <div class="repo-home-clone-body">
2308 <input
2309 class="repo-home-clone-input"
2310 type="text"
2311 value={cloneSshUrl}
2312 readonly
2313 aria-label="SSH clone URL"
2314 data-repo-home-clone-input
2315 />
2316 <button
2317 type="button"
2318 class="repo-home-clone-copy"
2319 data-repo-home-copy={cloneSshUrl}
2320 >
2321 Copy
2322 </button>
2323 </div>
2324 </div>
2325 <div
2326 class="repo-home-clone-pane"
2327 data-pane="cli"
2328 data-active="false"
2329 role="tabpanel"
2330 >
2331 <div class="repo-home-clone-body">
2332 <input
2333 class="repo-home-clone-input"
2334 type="text"
2335 value={cloneCliCmd}
2336 readonly
2337 aria-label="Gluecron CLI clone command"
2338 data-repo-home-clone-input
2339 />
2340 <button
2341 type="button"
2342 class="repo-home-clone-copy"
2343 data-repo-home-copy={cloneCliCmd}
2344 >
2345 Copy
2346 </button>
2347 </div>
79136bbClaude2348 </div>
fc1817aClaude2349 </div>
544d842Claude2350 <div class="repo-home-side-card">
2351 <h3 class="repo-home-side-title">About</h3>
2352 <div class="repo-home-side-row">
2353 <span class="repo-home-side-key">Default branch</span>
2354 <span class="repo-home-side-val">{defaultBranch}</span>
2355 </div>
2356 <div class="repo-home-side-row">
2357 <span class="repo-home-side-key">Branches</span>
2358 <span class="repo-home-side-val">
2359 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
2360 {branches.length}
2361 </a>
2362 </span>
2363 </div>
2364 <div class="repo-home-side-row">
2365 <span class="repo-home-side-key">Stars</span>
2366 <span class="repo-home-side-val">{starCount}</span>
2367 </div>
2368 <div class="repo-home-side-row">
2369 <span class="repo-home-side-key">Forks</span>
2370 <span class="repo-home-side-val">{forkCount}</span>
2371 </div>
2372 {pushedAt && (
2373 <div class="repo-home-side-row">
2374 <span class="repo-home-side-key">Last push</span>
2375 <span class="repo-home-side-val">
2376 {formatRelative(pushedAt)}
2377 </span>
2378 </div>
2379 )}
2380 {createdAt && (
2381 <div class="repo-home-side-row">
2382 <span class="repo-home-side-key">Created</span>
2383 <span class="repo-home-side-val">
2384 {formatRelative(createdAt)}
2385 </span>
2386 </div>
2387 )}
2388 {(archived || isTemplate) && (
2389 <div class="repo-home-side-row">
2390 <span class="repo-home-side-key">State</span>
2391 <span class="repo-home-side-val">
2392 {archived ? "Archived" : "Template"}
2393 </span>
2394 </div>
2395 )}
2396 </div>
2397 </aside>
2398 </div>
2399 <script
2400 dangerouslySetInnerHTML={{
2401 __html: `
2402 (function(){
2403 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
2404 tabs.forEach(function(tab){
2405 tab.addEventListener('click', function(){
2406 var target = tab.getAttribute('data-pane');
2407 tabs.forEach(function(t){
2408 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
2409 });
2410 var panes = document.querySelectorAll('.repo-home-clone-pane');
2411 panes.forEach(function(p){
2412 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
2413 });
2414 });
2415 });
2416 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
2417 copyBtns.forEach(function(btn){
2418 btn.addEventListener('click', function(){
2419 var text = btn.getAttribute('data-repo-home-copy') || '';
2420 var done = function(){
2421 var prev = btn.textContent;
2422 btn.textContent = 'Copied';
2423 setTimeout(function(){ btn.textContent = prev; }, 1200);
2424 };
2425 if (navigator.clipboard && navigator.clipboard.writeText) {
2426 navigator.clipboard.writeText(text).then(done, done);
2427 } else {
2428 var ta = document.createElement('textarea');
2429 ta.value = text;
2430 document.body.appendChild(ta);
2431 ta.select();
2432 try { document.execCommand('copy'); } catch (e) {}
2433 document.body.removeChild(ta);
2434 done();
2435 }
2436 });
2437 });
2438 })();
2439 `,
2440 }}
2441 />
fc1817aClaude2442 </Layout>
2443 );
2444});
2445
2446// Browse tree at ref/path
2447web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
2448 const { owner, repo } = c.req.param();
06d5ffeClaude2449 const user = c.get("user");
fc1817aClaude2450 const refAndPath = c.req.param("ref");
2451
2452 const branches = await listBranches(owner, repo);
2453 let ref = "";
2454 let treePath = "";
2455
2456 for (const branch of branches) {
2457 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
2458 ref = branch;
2459 treePath = refAndPath.slice(branch.length + 1);
2460 break;
2461 }
2462 }
2463
2464 if (!ref) {
2465 const slashIdx = refAndPath.indexOf("/");
2466 if (slashIdx === -1) {
2467 ref = refAndPath;
2468 } else {
2469 ref = refAndPath.slice(0, slashIdx);
2470 treePath = refAndPath.slice(slashIdx + 1);
2471 }
2472 }
2473
2474 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude2475 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
2476 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude2477
2478 return c.html(
06d5ffeClaude2479 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude2480 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2481 <RepoHeader owner={owner} repo={repo} />
2482 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude2483 <div class="tree-header">
2484 <div class="tree-header-row">
2485 <BranchSwitcher
2486 owner={owner}
2487 repo={repo}
2488 currentRef={ref}
2489 branches={branches}
2490 pathType="tree"
2491 subPath={treePath}
2492 />
2493 <div class="tree-header-stats">
2494 <span class="tree-stat" title="Entries in this directory">
2495 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
2496 {dirCount > 0 && (
2497 <>
2498 {" · "}
2499 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
2500 </>
2501 )}
2502 </span>
2503 <a
2504 href={`/${owner}/${repo}/search`}
2505 class="tree-stat tree-stat-link"
2506 title="Search code in this repository"
2507 >
2508 {"⌕"} Search
2509 </a>
2510 </div>
2511 </div>
2512 <div class="tree-breadcrumb-row">
2513 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
2514 </div>
2515 </div>
fc1817aClaude2516 <FileTable
2517 entries={tree}
2518 owner={owner}
2519 repo={repo}
2520 ref={ref}
2521 path={treePath}
2522 />
2523 </Layout>
2524 );
2525});
2526
06d5ffeClaude2527// View file blob with syntax highlighting
fc1817aClaude2528web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
2529 const { owner, repo } = c.req.param();
06d5ffeClaude2530 const user = c.get("user");
fc1817aClaude2531 const refAndPath = c.req.param("ref");
2532
2533 const branches = await listBranches(owner, repo);
2534 let ref = "";
2535 let filePath = "";
2536
2537 for (const branch of branches) {
2538 if (refAndPath.startsWith(branch + "/")) {
2539 ref = branch;
2540 filePath = refAndPath.slice(branch.length + 1);
2541 break;
2542 }
2543 }
2544
2545 if (!ref) {
2546 const slashIdx = refAndPath.indexOf("/");
2547 if (slashIdx === -1) return c.text("Not found", 404);
2548 ref = refAndPath.slice(0, slashIdx);
2549 filePath = refAndPath.slice(slashIdx + 1);
2550 }
2551
2552 const blob = await getBlob(owner, repo, ref, filePath);
2553 if (!blob) {
2554 return c.html(
06d5ffeClaude2555 <Layout title="Not Found" user={user}>
fc1817aClaude2556 <div class="empty-state">
2557 <h2>File not found</h2>
2558 </div>
2559 </Layout>,
2560 404
2561 );
2562 }
2563
06d5ffeClaude2564 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude2565 const lineCount = blob.isBinary
2566 ? 0
2567 : (blob.content.endsWith("\n")
2568 ? blob.content.split("\n").length - 1
2569 : blob.content.split("\n").length);
2570 const formatBytes = (n: number): string => {
2571 if (n < 1024) return `${n} B`;
2572 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2573 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
2574 };
fc1817aClaude2575
2576 return c.html(
06d5ffeClaude2577 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude2578 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2579 <RepoHeader owner={owner} repo={repo} />
2580 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude2581 <div class="blob-toolbar">
2582 <BranchSwitcher
2583 owner={owner}
2584 repo={repo}
2585 currentRef={ref}
2586 branches={branches}
2587 pathType="blob"
2588 subPath={filePath}
2589 />
2590 <div class="blob-breadcrumb">
2591 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
2592 </div>
2593 </div>
2594 <div class="blob-view blob-card">
2595 <div class="blob-header blob-header-polished">
2596 <div class="blob-header-meta">
2597 <span class="blob-header-icon" aria-hidden="true">
2598 {"📄"}
2599 </span>
2600 <span class="blob-header-name">{fileName}</span>
2601 <span class="blob-header-size">
2602 {formatBytes(blob.size)}
2603 {!blob.isBinary && (
2604 <>
2605 {" · "}
2606 {lineCount} line{lineCount === 1 ? "" : "s"}
2607 </>
2608 )}
2609 </span>
2610 </div>
2611 <div class="blob-header-actions">
2612 <a
2613 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
2614 class="blob-pill"
2615 >
79136bbClaude2616 Raw
2617 </a>
efb11c5Claude2618 <a
2619 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
2620 class="blob-pill"
2621 >
79136bbClaude2622 Blame
2623 </a>
efb11c5Claude2624 <a
2625 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
2626 class="blob-pill"
2627 >
16b325cClaude2628 History
2629 </a>
0074234Claude2630 {user && (
efb11c5Claude2631 <a
2632 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
2633 class="blob-pill blob-pill-accent"
2634 >
0074234Claude2635 Edit
2636 </a>
2637 )}
efb11c5Claude2638 </div>
fc1817aClaude2639 </div>
2640 {blob.isBinary ? (
efb11c5Claude2641 <div class="blob-binary">
fc1817aClaude2642 Binary file not shown.
2643 </div>
06d5ffeClaude2644 ) : (() => {
2645 const { html: highlighted, language } = highlightCode(
2646 blob.content,
2647 fileName
2648 );
2649 if (language) {
2650 return (
2651 <HighlightedCode
2652 highlightedHtml={highlighted}
efb11c5Claude2653 lineCount={lineCount}
06d5ffeClaude2654 />
2655 );
2656 }
2657 const lines = blob.content.split("\n");
2658 if (lines[lines.length - 1] === "") lines.pop();
2659 return <PlainCode lines={lines} />;
2660 })()}
fc1817aClaude2661 </div>
2662 </Layout>
2663 );
2664});
2665
2666// Commit log
2667web.get("/:owner/:repo/commits/:ref?", async (c) => {
2668 const { owner, repo } = c.req.param();
06d5ffeClaude2669 const user = c.get("user");
fc1817aClaude2670 const ref =
2671 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude2672 const branches = await listBranches(owner, repo);
fc1817aClaude2673
2674 const commits = await listCommits(owner, repo, ref, 50);
2675
3951454Claude2676 // Block J3 — batch-fetch cached verification results for the page.
2677 let verifications: Record<string, { verified: boolean; reason: string }> = {};
2678 try {
2679 const [ownerRow] = await db
2680 .select()
2681 .from(users)
2682 .where(eq(users.username, owner))
2683 .limit(1);
2684 if (ownerRow) {
2685 const [repoRow] = await db
2686 .select()
2687 .from(repositories)
2688 .where(
2689 and(
2690 eq(repositories.ownerId, ownerRow.id),
2691 eq(repositories.name, repo)
2692 )
2693 )
2694 .limit(1);
2695 if (repoRow && commits.length > 0) {
2696 const rows = await db
2697 .select()
2698 .from(commitVerifications)
2699 .where(
2700 and(
2701 eq(commitVerifications.repositoryId, repoRow.id),
2702 inArray(
2703 commitVerifications.commitSha,
2704 commits.map((c) => c.sha)
2705 )
2706 )
2707 );
2708 for (const r of rows) {
2709 verifications[r.commitSha] = {
2710 verified: r.verified,
2711 reason: r.reason,
2712 };
2713 }
2714 }
2715 }
2716 } catch {
2717 // DB unavailable — skip the badges gracefully.
2718 }
2719
fc1817aClaude2720 return c.html(
06d5ffeClaude2721 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude2722 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2723 <RepoHeader owner={owner} repo={repo} />
2724 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude2725 <div class="commits-hero">
2726 <div class="commits-hero-orb-wrap" aria-hidden="true">
2727 <div class="commits-hero-orb" />
fc1817aClaude2728 </div>
efb11c5Claude2729 <div class="commits-hero-inner">
2730 <div class="commits-eyebrow">
2731 <strong>History</strong> · {owner}/{repo}
2732 </div>
2733 <h1 class="commits-title">
2734 <span class="gradient-text">{commits.length}</span> recent commit
2735 {commits.length === 1 ? "" : "s"} on{" "}
2736 <span class="commits-branch">{ref}</span>
2737 </h1>
2738 <p class="commits-sub">
2739 Browse the project's history. Click any commit to see the full
2740 diff, AI review notes, and signature status.
2741 </p>
2742 </div>
2743 </div>
2744 <div class="commits-toolbar">
2745 <BranchSwitcher
3951454Claude2746 owner={owner}
2747 repo={repo}
efb11c5Claude2748 currentRef={ref}
2749 branches={branches}
2750 pathType="commits"
3951454Claude2751 />
efb11c5Claude2752 </div>
2753 {commits.length === 0 ? (
2754 <div class="commits-empty">
2755 <p>No commits yet on this branch.</p>
2756 </div>
2757 ) : (
2758 <div class="commits-list-wrap">
2759 <CommitList
2760 commits={commits}
2761 owner={owner}
2762 repo={repo}
2763 verifications={verifications}
2764 />
2765 </div>
fc1817aClaude2766 )}
2767 </Layout>
2768 );
2769});
2770
2771// Single commit with diff
2772web.get("/:owner/:repo/commit/:sha", async (c) => {
2773 const { owner, repo, sha } = c.req.param();
06d5ffeClaude2774 const user = c.get("user");
fc1817aClaude2775
05b973eClaude2776 // Fetch commit, full message, and diff in parallel
2777 const [commit, fullMessage, diffResult] = await Promise.all([
2778 getCommit(owner, repo, sha),
2779 getCommitFullMessage(owner, repo, sha),
2780 getDiff(owner, repo, sha),
2781 ]);
fc1817aClaude2782 if (!commit) {
2783 return c.html(
06d5ffeClaude2784 <Layout title="Not Found" user={user}>
fc1817aClaude2785 <div class="empty-state">
2786 <h2>Commit not found</h2>
2787 </div>
2788 </Layout>,
2789 404
2790 );
2791 }
2792
3951454Claude2793 // Block J3 — try to verify this commit's signature.
2794 let verification:
2795 | { verified: boolean; reason: string; signatureType: string | null }
2796 | null = null;
0cdfd89Claude2797 // Block J8 — external CI commit statuses rollup.
2798 let statusCombined:
2799 | {
2800 state: "pending" | "success" | "failure";
2801 total: number;
2802 contexts: Array<{
2803 context: string;
2804 state: string;
2805 description: string | null;
2806 targetUrl: string | null;
2807 }>;
2808 }
2809 | null = null;
3951454Claude2810 try {
2811 const [ownerRow] = await db
2812 .select()
2813 .from(users)
2814 .where(eq(users.username, owner))
2815 .limit(1);
2816 if (ownerRow) {
2817 const [repoRow] = await db
2818 .select()
2819 .from(repositories)
2820 .where(
2821 and(
2822 eq(repositories.ownerId, ownerRow.id),
2823 eq(repositories.name, repo)
2824 )
2825 )
2826 .limit(1);
2827 if (repoRow) {
2828 const { verifyCommit } = await import("../lib/signatures");
2829 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
2830 verification = {
2831 verified: v.verified,
2832 reason: v.reason,
2833 signatureType: v.signatureType,
2834 };
0cdfd89Claude2835 try {
2836 const { combinedStatus } = await import("../lib/commit-statuses");
2837 const combined = await combinedStatus(repoRow.id, commit.sha);
2838 if (combined.total > 0) {
2839 statusCombined = {
2840 state: combined.state as any,
2841 total: combined.total,
2842 contexts: combined.contexts.map((c) => ({
2843 context: c.context,
2844 state: c.state,
2845 description: c.description,
2846 targetUrl: c.targetUrl,
2847 })),
2848 };
2849 }
2850 } catch {
2851 statusCombined = null;
2852 }
3951454Claude2853 }
2854 }
2855 } catch {
2856 verification = null;
2857 }
2858
05b973eClaude2859 const { files, raw } = diffResult;
fc1817aClaude2860
efb11c5Claude2861 // Diff stats: count additions / deletions across all files for the
2862 // header summary bar. Computed here from the parsed diff so we don't
2863 // touch the DiffView component.
2864 let additions = 0;
2865 let deletions = 0;
2866 for (const f of files) {
2867 const hunks = (f as any).hunks as Array<any> | undefined;
2868 if (Array.isArray(hunks)) {
2869 for (const h of hunks) {
2870 const lines = (h?.lines || []) as Array<any>;
2871 for (const ln of lines) {
2872 const t = ln?.type || ln?.kind;
2873 if (t === "add" || t === "added" || t === "+") additions += 1;
2874 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
2875 deletions += 1;
2876 }
2877 }
2878 }
2879 }
2880 // Fall back: scan raw if file-level counting yielded zero (it's just a
2881 // header polish — never let a parsing miss break the page).
2882 if (additions === 0 && deletions === 0 && typeof raw === "string") {
2883 for (const line of raw.split("\n")) {
2884 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
2885 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
2886 }
2887 }
2888 const fileCount = files.length;
2889
fc1817aClaude2890 return c.html(
06d5ffeClaude2891 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude2892 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2893 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude2894 <div class="commit-detail-card">
2895 <div class="commit-detail-eyebrow">
2896 <strong>Commit</strong>
2897 <span class="commit-detail-sha-pill" title={commit.sha}>
2898 {commit.sha.slice(0, 7)}
2899 </span>
3951454Claude2900 {verification && verification.reason !== "unsigned" && (
2901 <span
efb11c5Claude2902 class={`commit-detail-verify ${
3951454Claude2903 verification.verified
efb11c5Claude2904 ? "commit-detail-verify-ok"
2905 : "commit-detail-verify-warn"
3951454Claude2906 }`}
2907 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
2908 >
2909 {verification.verified ? "Verified" : verification.reason}
2910 </span>
2911 )}
fc1817aClaude2912 </div>
efb11c5Claude2913 <h1 class="commit-detail-title">{commit.message}</h1>
2914 {fullMessage !== commit.message && (
2915 <pre class="commit-detail-body">{fullMessage}</pre>
2916 )}
2917 <div class="commit-detail-meta">
2918 <span class="commit-detail-author">
2919 <strong>{commit.author}</strong> committed on{" "}
2920 {new Date(commit.date).toLocaleDateString("en-US", {
2921 month: "long",
2922 day: "numeric",
2923 year: "numeric",
2924 })}
2925 </span>
fc1817aClaude2926 {commit.parentShas.length > 0 && (
efb11c5Claude2927 <span class="commit-detail-parents">
2928 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
2929 {commit.parentShas.map((p, idx) => (
2930 <>
2931 {idx > 0 && " "}
2932 <a
2933 href={`/${owner}/${repo}/commit/${p}`}
2934 class="commit-detail-sha-link"
2935 >
2936 {p.slice(0, 7)}
2937 </a>
2938 </>
fc1817aClaude2939 ))}
2940 </span>
2941 )}
2942 </div>
efb11c5Claude2943 <div class="commit-detail-stats">
2944 <span class="commit-detail-stat">
2945 <strong>{fileCount}</strong>{" "}
2946 file{fileCount === 1 ? "" : "s"} changed
2947 </span>
2948 <span class="commit-detail-stat commit-detail-stat-add">
2949 <span class="commit-detail-stat-mark">+</span>
2950 <strong>{additions}</strong>
2951 </span>
2952 <span class="commit-detail-stat commit-detail-stat-del">
2953 <span class="commit-detail-stat-mark">−</span>
2954 <strong>{deletions}</strong>
2955 </span>
2956 <span class="commit-detail-sha-full" title="Full SHA">
2957 {commit.sha}
2958 </span>
2959 </div>
0cdfd89Claude2960 {statusCombined && (
efb11c5Claude2961 <div class="commit-detail-checks">
2962 <div class="commit-detail-checks-head">
2963 <strong>Checks</strong>
2964 <span class="commit-detail-checks-summary">
2965 {statusCombined.total} total ·{" "}
2966 <span
2967 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
2968 >
2969 {statusCombined.state}
2970 </span>
0cdfd89Claude2971 </span>
efb11c5Claude2972 </div>
2973 <div class="commit-detail-check-row">
0cdfd89Claude2974 {statusCombined.contexts.map((cx) => (
2975 <span
efb11c5Claude2976 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude2977 title={cx.description || cx.context}
2978 >
2979 {cx.targetUrl ? (
efb11c5Claude2980 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude2981 {cx.context}: {cx.state}
2982 </a>
2983 ) : (
2984 <>
2985 {cx.context}: {cx.state}
2986 </>
2987 )}
2988 </span>
2989 ))}
2990 </div>
2991 </div>
2992 )}
fc1817aClaude2993 </div>
ea9ed4cClaude2994 <DiffView
2995 raw={raw}
2996 files={files}
2997 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
2998 />
fc1817aClaude2999 </Layout>
3000 );
3001});
3002
79136bbClaude3003// Raw file download
3004web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
3005 const { owner, repo } = c.req.param();
3006 const refAndPath = c.req.param("ref");
3007
3008 const branches = await listBranches(owner, repo);
3009 let ref = "";
3010 let filePath = "";
3011
3012 for (const branch of branches) {
3013 if (refAndPath.startsWith(branch + "/")) {
3014 ref = branch;
3015 filePath = refAndPath.slice(branch.length + 1);
3016 break;
3017 }
3018 }
3019
3020 if (!ref) {
3021 const slashIdx = refAndPath.indexOf("/");
3022 if (slashIdx === -1) return c.text("Not found", 404);
3023 ref = refAndPath.slice(0, slashIdx);
3024 filePath = refAndPath.slice(slashIdx + 1);
3025 }
3026
3027 const data = await getRawBlob(owner, repo, ref, filePath);
3028 if (!data) return c.text("Not found", 404);
3029
3030 const fileName = filePath.split("/").pop() || "file";
772a24fClaude3031 return new Response(data as BodyInit, {
79136bbClaude3032 headers: {
3033 "Content-Type": "application/octet-stream",
3034 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude3035 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude3036 },
3037 });
3038});
3039
3040// Blame view
3041web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
3042 const { owner, repo } = c.req.param();
3043 const user = c.get("user");
3044 const refAndPath = c.req.param("ref");
3045
3046 const branches = await listBranches(owner, repo);
3047 let ref = "";
3048 let filePath = "";
3049
3050 for (const branch of branches) {
3051 if (refAndPath.startsWith(branch + "/")) {
3052 ref = branch;
3053 filePath = refAndPath.slice(branch.length + 1);
3054 break;
3055 }
3056 }
3057
3058 if (!ref) {
3059 const slashIdx = refAndPath.indexOf("/");
3060 if (slashIdx === -1) return c.text("Not found", 404);
3061 ref = refAndPath.slice(0, slashIdx);
3062 filePath = refAndPath.slice(slashIdx + 1);
3063 }
3064
3065 const blameLines = await getBlame(owner, repo, ref, filePath);
3066 if (blameLines.length === 0) {
3067 return c.html(
3068 <Layout title="Not Found" user={user}>
3069 <div class="empty-state">
3070 <h2>File not found</h2>
3071 </div>
3072 </Layout>,
3073 404
3074 );
3075 }
3076
3077 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3078 // Unique contributors (by author) tracked once for the header chip.
3079 const blameAuthors = new Set<string>();
3080 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude3081
3082 return c.html(
3083 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3084 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude3085 <RepoHeader owner={owner} repo={repo} />
3086 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3087 <div class="blame-toolbar">
3088 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3089 </div>
3090 <div class="blob-view blame-card">
3091 <div class="blob-header blame-header">
3092 <div class="blame-header-meta">
3093 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
3094 <span class="blame-header-name">{fileName}</span>
3095 <span class="blame-header-tag">Blame</span>
3096 <span class="blame-header-stats">
3097 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
3098 {blameAuthors.size} contributor
3099 {blameAuthors.size === 1 ? "" : "s"}
3100 </span>
3101 </div>
3102 <div class="blame-header-actions">
3103 <a
3104 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
3105 class="blob-pill"
3106 >
3107 Normal view
3108 </a>
3109 <a
3110 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
3111 class="blob-pill"
3112 >
3113 Raw
3114 </a>
3115 </div>
79136bbClaude3116 </div>
3117 <div class="blob-code" style="overflow-x: auto">
3118 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
3119 <tbody>
3120 {blameLines.map((line, i) => {
3121 const showInfo =
3122 i === 0 || blameLines[i - 1].sha !== line.sha;
3123 return (
3124 <tr style="border-bottom: 1px solid var(--border)">
3125 <td
3126 style={`width: 200px; padding: 0 8px; font-size: 11px; color: var(--text-muted); white-space: nowrap; vertical-align: top; ${showInfo ? "border-top: 1px solid var(--border)" : ""}`}
3127 >
3128 {showInfo && (
3129 <>
3130 <a
3131 href={`/${owner}/${repo}/commit/${line.sha}`}
3132 style="color: var(--text-link); font-family: var(--font-mono)"
3133 >
3134 {line.sha.slice(0, 7)}
3135 </a>{" "}
3136 <span>{line.author}</span>
3137 </>
3138 )}
3139 </td>
3140 <td class="line-num">{line.lineNum}</td>
3141 <td class="line-content">{line.content}</td>
3142 </tr>
3143 );
3144 })}
3145 </tbody>
3146 </table>
3147 </div>
3148 </div>
3149 </Layout>
3150 );
3151});
3152
3153// Search
3154web.get("/:owner/:repo/search", async (c) => {
3155 const { owner, repo } = c.req.param();
3156 const user = c.get("user");
3157 const q = c.req.query("q") || "";
3158
3159 if (!(await repoExists(owner, repo))) return c.notFound();
3160
3161 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3162 let results: Array<{ file: string; lineNum: number; line: string }> = [];
3163
3164 if (q.trim()) {
3165 results = await searchCode(owner, repo, defaultBranch, q.trim());
3166 }
3167
3168 return c.html(
3169 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude3170 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude3171 <RepoHeader owner={owner} repo={repo} />
3172 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3173 <div class="search-hero">
3174 <div class="search-eyebrow">
3175 <strong>Search</strong> · {owner}/{repo}
3176 </div>
3177 <h1 class="search-title">
3178 Find any line in <span class="gradient-text">{repo}</span>
3179 </h1>
3180 <form
3181 method="get"
3182 action={`/${owner}/${repo}/search`}
3183 class="search-form"
3184 role="search"
3185 >
3186 <div class="search-input-wrap">
3187 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
3188 <input
3189 type="text"
3190 name="q"
3191 value={q}
3192 placeholder="Search code on the default branch…"
3193 aria-label="Search code"
3194 class="search-input"
3195 autocomplete="off"
3196 autofocus
3197 />
3198 </div>
3199 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude3200 Search
3201 </button>
efb11c5Claude3202 </form>
3203 </div>
79136bbClaude3204 {q && (
efb11c5Claude3205 <div class="search-results-head">
3206 <span class="search-results-count">
3207 <strong>{results.length}</strong> result
3208 {results.length !== 1 ? "s" : ""}
3209 </span>
3210 <span class="search-results-query">
3211 for <span class="search-results-q">"{q}"</span> on{" "}
3212 <code>{defaultBranch}</code>
3213 </span>
3214 </div>
79136bbClaude3215 )}
efb11c5Claude3216 {q && results.length === 0 ? (
3217 <div class="search-empty">
3218 <p>
3219 No matches for <strong>"{q}"</strong>. Try a shorter query or check
3220 you're on the right branch.
3221 </p>
3222 </div>
3223 ) : results.length > 0 ? (
79136bbClaude3224 <div class="search-results">
3225 {(() => {
3226 // Group by file
3227 const grouped: Record<
3228 string,
3229 Array<{ lineNum: number; line: string }>
3230 > = {};
3231 for (const r of results) {
3232 if (!grouped[r.file]) grouped[r.file] = [];
3233 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
3234 }
3235 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude3236 <div class="search-file diff-file">
3237 <div class="search-file-head diff-file-header">
79136bbClaude3238 <a
3239 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude3240 class="search-file-link"
79136bbClaude3241 >
3242 {file}
3243 </a>
efb11c5Claude3244 <span class="search-file-count">
3245 {matches.length} match{matches.length === 1 ? "" : "es"}
3246 </span>
79136bbClaude3247 </div>
3248 <div class="blob-code">
3249 <table>
3250 <tbody>
3251 {matches.map((m) => (
3252 <tr>
3253 <td class="line-num">{m.lineNum}</td>
3254 <td class="line-content">{m.line}</td>
3255 </tr>
3256 ))}
3257 </tbody>
3258 </table>
3259 </div>
3260 </div>
3261 ));
3262 })()}
3263 </div>
efb11c5Claude3264 ) : null}
79136bbClaude3265 </Layout>
3266 );
3267});
3268
fc1817aClaude3269export default web;