Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame3209 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,
24 DiffView,
06d5ffeClaude25 RepoCard,
26 BranchSwitcher,
27 HighlightedCode,
28 PlainCode,
fc1817aClaude29} from "../views/components";
30import {
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
8f50ed0Claude1518 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
1519 trackByName(owner, repo, "view", {
1520 userId: user?.id || null,
1521 path: `/${owner}/${repo}`,
1522 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
1523 userAgent: c.req.header("user-agent") || null,
1524 referer: c.req.header("referer") || null,
a28cedeClaude1525 }).catch((err) => {
1526 console.warn(
1527 `[web] view tracking failed for ${owner}/${repo}:`,
1528 err instanceof Error ? err.message : err
1529 );
1530 });
8f50ed0Claude1531
fc1817aClaude1532 if (!(await repoExists(owner, repo))) {
1533 return c.html(
06d5ffeClaude1534 <Layout title="Not Found" user={user}>
fc1817aClaude1535 <div class="empty-state">
1536 <h2>Repository not found</h2>
1537 <p>
1538 {owner}/{repo} does not exist.
1539 </p>
1540 </div>
1541 </Layout>,
1542 404
1543 );
1544 }
1545
05b973eClaude1546 // Parallelize all independent operations
1547 const [defaultBranch, branches] = await Promise.all([
1548 getDefaultBranch(owner, repo).then((b) => b || "main"),
1549 listBranches(owner, repo),
1550 ]);
1551 const [tree, starInfo] = await Promise.all([
1552 getTree(owner, repo, defaultBranch),
1553 // Star info fetched in parallel with tree
1554 (async () => {
1555 try {
1556 const [ownerUser] = await db
1557 .select()
1558 .from(users)
1559 .where(eq(users.username, owner))
1560 .limit(1);
71cd5ecClaude1561 if (!ownerUser)
1562 return {
1563 starCount: 0,
1564 starred: false,
1565 archived: false,
1566 isTemplate: false,
544d842Claude1567 forkCount: 0,
1568 description: null as string | null,
1569 pushedAt: null as Date | null,
1570 createdAt: null as Date | null,
71cd5ecClaude1571 };
05b973eClaude1572 const [repoRow] = await db
1573 .select()
1574 .from(repositories)
1575 .where(
1576 and(
1577 eq(repositories.ownerId, ownerUser.id),
1578 eq(repositories.name, repo)
1579 )
06d5ffeClaude1580 )
05b973eClaude1581 .limit(1);
71cd5ecClaude1582 if (!repoRow)
1583 return {
1584 starCount: 0,
1585 starred: false,
1586 archived: false,
1587 isTemplate: false,
544d842Claude1588 forkCount: 0,
1589 description: null as string | null,
1590 pushedAt: null as Date | null,
1591 createdAt: null as Date | null,
71cd5ecClaude1592 };
05b973eClaude1593 let starred = false;
06d5ffeClaude1594 if (user) {
1595 const [star] = await db
1596 .select()
1597 .from(stars)
1598 .where(
1599 and(
1600 eq(stars.userId, user.id),
1601 eq(stars.repositoryId, repoRow.id)
1602 )
1603 )
1604 .limit(1);
1605 starred = !!star;
1606 }
71cd5ecClaude1607 return {
1608 starCount: repoRow.starCount,
1609 starred,
1610 archived: repoRow.isArchived,
1611 isTemplate: repoRow.isTemplate,
544d842Claude1612 forkCount: repoRow.forkCount,
1613 description: repoRow.description as string | null,
1614 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
1615 createdAt: (repoRow.createdAt as Date | null) ?? null,
71cd5ecClaude1616 };
05b973eClaude1617 } catch {
71cd5ecClaude1618 return {
1619 starCount: 0,
1620 starred: false,
1621 archived: false,
1622 isTemplate: false,
544d842Claude1623 forkCount: 0,
1624 description: null as string | null,
1625 pushedAt: null as Date | null,
1626 createdAt: null as Date | null,
71cd5ecClaude1627 };
06d5ffeClaude1628 }
05b973eClaude1629 })(),
1630 ]);
544d842Claude1631 const {
1632 starCount,
1633 starred,
1634 archived,
1635 isTemplate,
1636 forkCount,
1637 description,
1638 pushedAt,
1639 createdAt,
1640 } = starInfo;
1641
1642 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
1643 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
1644 const repoHomeCss = `
1645 .repo-home-hero {
1646 position: relative;
1647 margin-bottom: var(--space-5);
1648 padding: var(--space-5) var(--space-6);
1649 background: var(--bg-elevated);
1650 border: 1px solid var(--border);
1651 border-radius: 16px;
1652 overflow: hidden;
1653 }
1654 .repo-home-hero::before {
1655 content: '';
1656 position: absolute;
1657 top: 0; left: 0; right: 0;
1658 height: 2px;
1659 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1660 opacity: 0.7;
1661 pointer-events: none;
1662 }
1663 .repo-home-hero-orb-wrap {
1664 position: absolute;
1665 inset: -25% -10% auto auto;
1666 width: 360px;
1667 height: 360px;
1668 pointer-events: none;
1669 z-index: 0;
1670 }
1671 .repo-home-hero-orb {
1672 position: absolute;
1673 inset: 0;
1674 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
1675 filter: blur(80px);
1676 opacity: 0.7;
1677 animation: repoHomeOrb 14s ease-in-out infinite;
1678 }
1679 @keyframes repoHomeOrb {
1680 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
1681 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
1682 }
1683 @media (prefers-reduced-motion: reduce) {
1684 .repo-home-hero-orb { animation: none; }
1685 }
1686 .repo-home-hero-inner {
1687 position: relative;
1688 z-index: 1;
1689 }
1690 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
1691 .repo-home-eyebrow {
1692 font-size: 12px;
1693 font-family: var(--font-mono);
1694 color: var(--text-muted);
1695 letter-spacing: 0.1em;
1696 text-transform: uppercase;
1697 margin-bottom: var(--space-2);
1698 }
1699 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
1700 .repo-home-description {
1701 font-size: 15px;
1702 line-height: 1.55;
1703 color: var(--text);
1704 margin: 0;
1705 max-width: 720px;
1706 }
1707 .repo-home-description-empty {
1708 font-size: 14px;
1709 color: var(--text-muted);
1710 font-style: italic;
1711 margin: 0;
1712 }
1713 .repo-home-stat-row {
1714 display: flex;
1715 flex-wrap: wrap;
1716 gap: var(--space-4);
1717 margin-top: var(--space-3);
1718 font-size: 13px;
1719 color: var(--text-muted);
1720 }
1721 .repo-home-stat {
1722 display: inline-flex;
1723 align-items: center;
1724 gap: 6px;
1725 }
1726 .repo-home-stat strong {
1727 color: var(--text-strong);
1728 font-weight: 600;
1729 font-variant-numeric: tabular-nums;
1730 }
1731 .repo-home-stat .repo-home-stat-icon {
1732 color: var(--text-faint);
1733 font-size: 14px;
1734 line-height: 1;
1735 }
1736 .repo-home-stat a {
1737 color: var(--text-muted);
1738 transition: color var(--t-fast) var(--ease);
1739 }
1740 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
1741
1742 /* Two-column layout: file tree + sidebar */
1743 .repo-home-grid {
1744 display: grid;
1745 grid-template-columns: minmax(0, 1fr) 280px;
1746 gap: var(--space-5);
1747 align-items: start;
1748 }
1749 @media (max-width: 960px) {
1750 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
1751 }
1752 .repo-home-main { min-width: 0; }
1753
1754 /* Sidebar card */
1755 .repo-home-side {
1756 display: flex;
1757 flex-direction: column;
1758 gap: var(--space-4);
1759 }
1760 .repo-home-side-card {
1761 background: var(--bg-elevated);
1762 border: 1px solid var(--border);
1763 border-radius: 12px;
1764 padding: var(--space-4);
1765 }
1766 .repo-home-side-title {
1767 font-size: 11px;
1768 font-family: var(--font-mono);
1769 letter-spacing: 0.12em;
1770 text-transform: uppercase;
1771 color: var(--text-muted);
1772 margin: 0 0 var(--space-3);
1773 font-weight: 600;
1774 }
1775 .repo-home-side-row {
1776 display: flex;
1777 justify-content: space-between;
1778 align-items: center;
1779 gap: var(--space-2);
1780 font-size: 13px;
1781 padding: 6px 0;
1782 border-top: 1px solid var(--border);
1783 }
1784 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
1785 .repo-home-side-key {
1786 color: var(--text-muted);
1787 display: inline-flex;
1788 align-items: center;
1789 gap: 6px;
1790 }
1791 .repo-home-side-val {
1792 color: var(--text-strong);
1793 font-weight: 500;
1794 font-variant-numeric: tabular-nums;
1795 max-width: 60%;
1796 text-align: right;
1797 overflow: hidden;
1798 text-overflow: ellipsis;
1799 white-space: nowrap;
1800 }
1801 .repo-home-side-val a { color: var(--text-strong); }
1802 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
1803
1804 /* Clone / Code tabs */
1805 .repo-home-clone {
1806 background: var(--bg-elevated);
1807 border: 1px solid var(--border);
1808 border-radius: 12px;
1809 overflow: hidden;
1810 }
1811 .repo-home-clone-tabs {
1812 display: flex;
1813 gap: 0;
1814 background: var(--bg-secondary);
1815 border-bottom: 1px solid var(--border);
1816 padding: 0 var(--space-2);
1817 }
1818 .repo-home-clone-tab {
1819 appearance: none;
1820 background: transparent;
1821 border: 0;
1822 border-bottom: 2px solid transparent;
1823 padding: 9px 12px;
1824 font-size: 12px;
1825 font-weight: 500;
1826 color: var(--text-muted);
1827 cursor: pointer;
1828 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1829 font-family: inherit;
1830 margin-bottom: -1px;
1831 }
1832 .repo-home-clone-tab:hover { color: var(--text-strong); }
1833 .repo-home-clone-tab[aria-selected="true"] {
1834 color: var(--text-strong);
1835 border-bottom-color: var(--accent);
1836 }
1837 .repo-home-clone-body {
1838 padding: var(--space-3);
1839 display: flex;
1840 align-items: center;
1841 gap: var(--space-2);
1842 }
1843 .repo-home-clone-input {
1844 flex: 1;
1845 min-width: 0;
1846 font-family: var(--font-mono);
1847 font-size: 12px;
1848 background: var(--bg);
1849 border: 1px solid var(--border);
1850 border-radius: 8px;
1851 padding: 8px 10px;
1852 color: var(--text-strong);
1853 overflow: hidden;
1854 text-overflow: ellipsis;
1855 white-space: nowrap;
1856 }
1857 .repo-home-clone-copy {
1858 appearance: none;
1859 background: var(--bg-secondary);
1860 border: 1px solid var(--border);
1861 color: var(--text-strong);
1862 border-radius: 8px;
1863 padding: 8px 12px;
1864 font-size: 12px;
1865 font-weight: 600;
1866 cursor: pointer;
1867 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
1868 font-family: inherit;
1869 }
1870 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
1871 .repo-home-clone-pane { display: none; }
1872 .repo-home-clone-pane[data-active="true"] { display: flex; }
1873
1874 /* README card */
1875 .repo-home-readme {
1876 margin-top: var(--space-5);
1877 background: var(--bg-elevated);
1878 border: 1px solid var(--border);
1879 border-radius: 12px;
1880 overflow: hidden;
1881 }
1882 .repo-home-readme-head {
1883 display: flex;
1884 align-items: center;
1885 gap: 8px;
1886 padding: 10px 16px;
1887 background: var(--bg-secondary);
1888 border-bottom: 1px solid var(--border);
1889 font-size: 13px;
1890 color: var(--text-muted);
1891 }
1892 .repo-home-readme-head .repo-home-readme-icon {
1893 color: var(--accent);
1894 font-size: 14px;
1895 }
1896 .repo-home-readme-body {
1897 padding: var(--space-5) var(--space-6);
1898 }
1899
1900 /* Empty-state CTA */
1901 .repo-home-empty {
1902 position: relative;
1903 margin-top: var(--space-4);
1904 background: var(--bg-elevated);
1905 border: 1px solid var(--border);
1906 border-radius: 16px;
1907 padding: var(--space-6);
1908 overflow: hidden;
1909 }
1910 .repo-home-empty::before {
1911 content: '';
1912 position: absolute;
1913 top: 0; left: 0; right: 0;
1914 height: 2px;
1915 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1916 opacity: 0.7;
1917 pointer-events: none;
1918 }
1919 .repo-home-empty-eyebrow {
1920 font-size: 12px;
1921 font-family: var(--font-mono);
1922 color: var(--accent);
1923 letter-spacing: 0.12em;
1924 text-transform: uppercase;
1925 margin-bottom: var(--space-2);
1926 font-weight: 600;
1927 }
1928 .repo-home-empty-title {
1929 font-family: var(--font-display);
1930 font-weight: 800;
1931 letter-spacing: -0.025em;
1932 font-size: clamp(22px, 3vw, 30px);
1933 line-height: 1.1;
1934 margin: 0 0 var(--space-2);
1935 color: var(--text-strong);
1936 }
1937 .repo-home-empty-sub {
1938 color: var(--text-muted);
1939 font-size: 14px;
1940 line-height: 1.55;
1941 max-width: 640px;
1942 margin: 0 0 var(--space-4);
1943 }
1944 .repo-home-empty-snippet {
1945 background: var(--bg);
1946 border: 1px solid var(--border);
1947 border-radius: 10px;
1948 padding: var(--space-3) var(--space-4);
1949 font-family: var(--font-mono);
1950 font-size: 12.5px;
1951 line-height: 1.7;
1952 color: var(--text-strong);
1953 overflow-x: auto;
1954 white-space: pre;
1955 margin: 0;
1956 }
1957 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
1958 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
1959
1960 @media (max-width: 720px) {
1961 .repo-home-hero { padding: var(--space-4) var(--space-4); }
1962 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
1963 .repo-home-clone-copy { width: 100%; }
1964 }
1965 `;
1966 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
1967 // Best-effort SSH URL. If APP_BASE_URL is a hostname, this looks right; for
1968 // localhost it'll still render and just be slightly silly (which is fine for dev).
1969 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
1970 try {
1971 const host = new URL(config.appBaseUrl).hostname;
1972 if (host && host !== "localhost" && host !== "127.0.0.1") {
1973 cloneSshUrl = `git@${host}:${owner}/${repo}.git`;
1974 }
1975 } catch {
1976 // Fall through to default.
1977 }
1978 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
1979 const formatRelative = (date: Date | null): string => {
1980 if (!date) return "never";
1981 const ms = Date.now() - date.getTime();
1982 const s = Math.max(0, Math.round(ms / 1000));
1983 if (s < 60) return "just now";
1984 const m = Math.round(s / 60);
1985 if (m < 60) return `${m} min ago`;
1986 const h = Math.round(m / 60);
1987 if (h < 24) return `${h}h ago`;
1988 const d = Math.round(h / 24);
1989 if (d < 30) return `${d}d ago`;
1990 const mo = Math.round(d / 30);
1991 if (mo < 12) return `${mo}mo ago`;
1992 const y = Math.round(d / 365);
1993 return `${y}y ago`;
1994 };
06d5ffeClaude1995
fc1817aClaude1996 if (tree.length === 0) {
1997 return c.html(
06d5ffeClaude1998 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude1999 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2000 <div class="repo-home-hero">
2001 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2002 <div class="repo-home-hero-orb" />
2003 </div>
2004 <div class="repo-home-hero-inner">
2005 <div class="repo-home-eyebrow">
2006 <strong>Repository</strong> · {owner}
2007 </div>
2008 <RepoHeader
2009 owner={owner}
2010 repo={repo}
2011 starCount={starCount}
2012 starred={starred}
2013 forkCount={forkCount}
2014 currentUser={user?.username}
2015 archived={archived}
2016 isTemplate={isTemplate}
2017 />
2018 {description ? (
2019 <p class="repo-home-description">{description}</p>
2020 ) : (
2021 <p class="repo-home-description-empty">
2022 No description yet — push a README to tell the world what this
2023 ships.
2024 </p>
2025 )}
2026 </div>
2027 </div>
fc1817aClaude2028 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude2029 <div class="repo-home-empty">
2030 <div class="repo-home-empty-eyebrow">Getting started</div>
2031 <h2 class="repo-home-empty-title">
2032 Push your first commit to{" "}
2033 <span class="gradient-text">{repo}</span>.
2034 </h2>
2035 <p class="repo-home-empty-sub">
2036 This repository is empty. Paste the snippet below in an existing
2037 project directory to wire it up to Gluecron — your push triggers
2038 gate checks and AI review automatically.
2039 </p>
2040 <pre class="repo-home-empty-snippet">
2041 <span class="repo-home-cmt">
2042 # from an existing project directory
2043 </span>
2044 {"\n"}
2045 <span class="repo-home-cmd">git remote add</span>
2046 {` origin ${cloneHttpsUrl}`}
2047 {"\n"}
2048 <span class="repo-home-cmd">git branch</span>
2049 {` -M main`}
2050 {"\n"}
2051 <span class="repo-home-cmd">git push</span>
2052 {` -u origin main`}
2053 </pre>
fc1817aClaude2054 </div>
2055 </Layout>
2056 );
2057 }
2058
2059 const readme = await getReadme(owner, repo, defaultBranch);
2060
544d842Claude2061 // Sidebar facts — derived from data we already have.
2062 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
2063 const dirCount = tree.filter((e: any) => e.type === "tree").length;
2064
fc1817aClaude2065 return c.html(
06d5ffeClaude2066 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude2067 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2068 <div class="repo-home-hero">
2069 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2070 <div class="repo-home-hero-orb" />
2071 </div>
2072 <div class="repo-home-hero-inner">
2073 <div class="repo-home-eyebrow">
2074 <strong>Repository</strong> · {owner}
2075 </div>
2076 <RepoHeader
2077 owner={owner}
2078 repo={repo}
2079 starCount={starCount}
2080 starred={starred}
2081 forkCount={forkCount}
2082 currentUser={user?.username}
2083 archived={archived}
2084 isTemplate={isTemplate}
2085 />
2086 {description ? (
2087 <p class="repo-home-description">{description}</p>
2088 ) : (
2089 <p class="repo-home-description-empty">
2090 No description yet.
2091 </p>
2092 )}
2093 <div class="repo-home-stat-row" aria-label="Repository stats">
2094 <span class="repo-home-stat" title="Default branch">
2095 <span class="repo-home-stat-icon">{"⎇"}</span>
2096 <strong>{defaultBranch}</strong>
2097 </span>
2098 <a
2099 href={`/${owner}/${repo}/commits/${defaultBranch}`}
2100 class="repo-home-stat"
2101 title="Browse all branches"
2102 >
2103 <span class="repo-home-stat-icon">{"⊢"}</span>
2104 <strong>{branches.length}</strong>{" "}
2105 branch{branches.length === 1 ? "" : "es"}
2106 </a>
2107 <span class="repo-home-stat" title="Top-level entries">
2108 <span class="repo-home-stat-icon">{"■"}</span>
2109 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
2110 {dirCount > 0 && (
2111 <>
2112 {" · "}
2113 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
2114 </>
2115 )}
2116 </span>
2117 {pushedAt && (
2118 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
2119 <span class="repo-home-stat-icon">{"↻"}</span>
2120 Updated <strong>{formatRelative(pushedAt)}</strong>
2121 </span>
2122 )}
2123 </div>
2124 </div>
2125 </div>
71cd5ecClaude2126 {isTemplate && user && user.username !== owner && (
2127 <div
2128 class="panel"
dc26881CC LABS App2129 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude2130 >
2131 <div style="font-size:13px">
2132 <strong>Template repository.</strong> Create a new repository from
2133 this template's files.
2134 </div>
2135 <form
001af43Claude2136 method="post"
71cd5ecClaude2137 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App2138 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude2139 >
2140 <input
2141 type="text"
2142 name="name"
2143 placeholder="new-repo-name"
2144 required
2c3ba6ecopilot-swe-agent[bot]2145 aria-label="New repository name"
71cd5ecClaude2146 style="width:200px"
2147 />
2148 <button type="submit" class="btn btn-primary">
2149 Use this template
2150 </button>
2151 </form>
2152 </div>
2153 )}
fc1817aClaude2154 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude2155 <div class="repo-home-grid">
2156 <div class="repo-home-main">
2157 <BranchSwitcher
2158 owner={owner}
2159 repo={repo}
2160 currentRef={defaultBranch}
2161 branches={branches}
2162 pathType="tree"
2163 />
2164 <FileTable
2165 entries={tree}
2166 owner={owner}
2167 repo={repo}
2168 ref={defaultBranch}
2169 path=""
2170 />
2171 {readme && (() => {
2172 const readmeHtml = renderMarkdown(readme);
2173 return (
2174 <div class="repo-home-readme">
2175 <div class="repo-home-readme-head">
2176 <span class="repo-home-readme-icon">{"☰"}</span>
2177 <span>README.md</span>
2178 </div>
2179 <style>{markdownCss}</style>
2180 <div class="markdown-body repo-home-readme-body">
2181 {html([readmeHtml] as unknown as TemplateStringsArray)}
2182 </div>
2183 </div>
2184 );
2185 })()}
2186 </div>
2187 <aside class="repo-home-side" aria-label="Repository details">
2188 <div class="repo-home-clone">
2189 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
2190 <button
2191 type="button"
2192 class="repo-home-clone-tab"
2193 role="tab"
2194 aria-selected="true"
2195 data-pane="https"
2196 data-repo-home-clone-tab
2197 >
2198 HTTPS
2199 </button>
2200 <button
2201 type="button"
2202 class="repo-home-clone-tab"
2203 role="tab"
2204 aria-selected="false"
2205 data-pane="ssh"
2206 data-repo-home-clone-tab
2207 >
2208 SSH
2209 </button>
2210 <button
2211 type="button"
2212 class="repo-home-clone-tab"
2213 role="tab"
2214 aria-selected="false"
2215 data-pane="cli"
2216 data-repo-home-clone-tab
2217 >
2218 CLI
2219 </button>
2220 </div>
2221 <div
2222 class="repo-home-clone-pane"
2223 data-pane="https"
2224 data-active="true"
2225 role="tabpanel"
2226 >
2227 <div class="repo-home-clone-body">
2228 <input
2229 class="repo-home-clone-input"
2230 type="text"
2231 value={cloneHttpsUrl}
2232 readonly
2233 aria-label="HTTPS clone URL"
2234 data-repo-home-clone-input
2235 />
2236 <button
2237 type="button"
2238 class="repo-home-clone-copy"
2239 data-repo-home-copy={cloneHttpsUrl}
2240 >
2241 Copy
2242 </button>
2243 </div>
2244 </div>
2245 <div
2246 class="repo-home-clone-pane"
2247 data-pane="ssh"
2248 data-active="false"
2249 role="tabpanel"
2250 >
2251 <div class="repo-home-clone-body">
2252 <input
2253 class="repo-home-clone-input"
2254 type="text"
2255 value={cloneSshUrl}
2256 readonly
2257 aria-label="SSH clone URL"
2258 data-repo-home-clone-input
2259 />
2260 <button
2261 type="button"
2262 class="repo-home-clone-copy"
2263 data-repo-home-copy={cloneSshUrl}
2264 >
2265 Copy
2266 </button>
2267 </div>
2268 </div>
2269 <div
2270 class="repo-home-clone-pane"
2271 data-pane="cli"
2272 data-active="false"
2273 role="tabpanel"
2274 >
2275 <div class="repo-home-clone-body">
2276 <input
2277 class="repo-home-clone-input"
2278 type="text"
2279 value={cloneCliCmd}
2280 readonly
2281 aria-label="Gluecron CLI clone command"
2282 data-repo-home-clone-input
2283 />
2284 <button
2285 type="button"
2286 class="repo-home-clone-copy"
2287 data-repo-home-copy={cloneCliCmd}
2288 >
2289 Copy
2290 </button>
2291 </div>
79136bbClaude2292 </div>
fc1817aClaude2293 </div>
544d842Claude2294 <div class="repo-home-side-card">
2295 <h3 class="repo-home-side-title">About</h3>
2296 <div class="repo-home-side-row">
2297 <span class="repo-home-side-key">Default branch</span>
2298 <span class="repo-home-side-val">{defaultBranch}</span>
2299 </div>
2300 <div class="repo-home-side-row">
2301 <span class="repo-home-side-key">Branches</span>
2302 <span class="repo-home-side-val">
2303 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
2304 {branches.length}
2305 </a>
2306 </span>
2307 </div>
2308 <div class="repo-home-side-row">
2309 <span class="repo-home-side-key">Stars</span>
2310 <span class="repo-home-side-val">{starCount}</span>
2311 </div>
2312 <div class="repo-home-side-row">
2313 <span class="repo-home-side-key">Forks</span>
2314 <span class="repo-home-side-val">{forkCount}</span>
2315 </div>
2316 {pushedAt && (
2317 <div class="repo-home-side-row">
2318 <span class="repo-home-side-key">Last push</span>
2319 <span class="repo-home-side-val">
2320 {formatRelative(pushedAt)}
2321 </span>
2322 </div>
2323 )}
2324 {createdAt && (
2325 <div class="repo-home-side-row">
2326 <span class="repo-home-side-key">Created</span>
2327 <span class="repo-home-side-val">
2328 {formatRelative(createdAt)}
2329 </span>
2330 </div>
2331 )}
2332 {(archived || isTemplate) && (
2333 <div class="repo-home-side-row">
2334 <span class="repo-home-side-key">State</span>
2335 <span class="repo-home-side-val">
2336 {archived ? "Archived" : "Template"}
2337 </span>
2338 </div>
2339 )}
2340 </div>
2341 </aside>
2342 </div>
2343 <script
2344 dangerouslySetInnerHTML={{
2345 __html: `
2346 (function(){
2347 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
2348 tabs.forEach(function(tab){
2349 tab.addEventListener('click', function(){
2350 var target = tab.getAttribute('data-pane');
2351 tabs.forEach(function(t){
2352 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
2353 });
2354 var panes = document.querySelectorAll('.repo-home-clone-pane');
2355 panes.forEach(function(p){
2356 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
2357 });
2358 });
2359 });
2360 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
2361 copyBtns.forEach(function(btn){
2362 btn.addEventListener('click', function(){
2363 var text = btn.getAttribute('data-repo-home-copy') || '';
2364 var done = function(){
2365 var prev = btn.textContent;
2366 btn.textContent = 'Copied';
2367 setTimeout(function(){ btn.textContent = prev; }, 1200);
2368 };
2369 if (navigator.clipboard && navigator.clipboard.writeText) {
2370 navigator.clipboard.writeText(text).then(done, done);
2371 } else {
2372 var ta = document.createElement('textarea');
2373 ta.value = text;
2374 document.body.appendChild(ta);
2375 ta.select();
2376 try { document.execCommand('copy'); } catch (e) {}
2377 document.body.removeChild(ta);
2378 done();
2379 }
2380 });
2381 });
2382 })();
2383 `,
2384 }}
2385 />
fc1817aClaude2386 </Layout>
2387 );
2388});
2389
2390// Browse tree at ref/path
2391web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
2392 const { owner, repo } = c.req.param();
06d5ffeClaude2393 const user = c.get("user");
fc1817aClaude2394 const refAndPath = c.req.param("ref");
2395
2396 const branches = await listBranches(owner, repo);
2397 let ref = "";
2398 let treePath = "";
2399
2400 for (const branch of branches) {
2401 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
2402 ref = branch;
2403 treePath = refAndPath.slice(branch.length + 1);
2404 break;
2405 }
2406 }
2407
2408 if (!ref) {
2409 const slashIdx = refAndPath.indexOf("/");
2410 if (slashIdx === -1) {
2411 ref = refAndPath;
2412 } else {
2413 ref = refAndPath.slice(0, slashIdx);
2414 treePath = refAndPath.slice(slashIdx + 1);
2415 }
2416 }
2417
2418 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude2419 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
2420 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude2421
2422 return c.html(
06d5ffeClaude2423 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude2424 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2425 <RepoHeader owner={owner} repo={repo} />
2426 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude2427 <div class="tree-header">
2428 <div class="tree-header-row">
2429 <BranchSwitcher
2430 owner={owner}
2431 repo={repo}
2432 currentRef={ref}
2433 branches={branches}
2434 pathType="tree"
2435 subPath={treePath}
2436 />
2437 <div class="tree-header-stats">
2438 <span class="tree-stat" title="Entries in this directory">
2439 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
2440 {dirCount > 0 && (
2441 <>
2442 {" · "}
2443 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
2444 </>
2445 )}
2446 </span>
2447 <a
2448 href={`/${owner}/${repo}/search`}
2449 class="tree-stat tree-stat-link"
2450 title="Search code in this repository"
2451 >
2452 {"⌕"} Search
2453 </a>
2454 </div>
2455 </div>
2456 <div class="tree-breadcrumb-row">
2457 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
2458 </div>
2459 </div>
fc1817aClaude2460 <FileTable
2461 entries={tree}
2462 owner={owner}
2463 repo={repo}
2464 ref={ref}
2465 path={treePath}
2466 />
2467 </Layout>
2468 );
2469});
2470
06d5ffeClaude2471// View file blob with syntax highlighting
fc1817aClaude2472web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
2473 const { owner, repo } = c.req.param();
06d5ffeClaude2474 const user = c.get("user");
fc1817aClaude2475 const refAndPath = c.req.param("ref");
2476
2477 const branches = await listBranches(owner, repo);
2478 let ref = "";
2479 let filePath = "";
2480
2481 for (const branch of branches) {
2482 if (refAndPath.startsWith(branch + "/")) {
2483 ref = branch;
2484 filePath = refAndPath.slice(branch.length + 1);
2485 break;
2486 }
2487 }
2488
2489 if (!ref) {
2490 const slashIdx = refAndPath.indexOf("/");
2491 if (slashIdx === -1) return c.text("Not found", 404);
2492 ref = refAndPath.slice(0, slashIdx);
2493 filePath = refAndPath.slice(slashIdx + 1);
2494 }
2495
2496 const blob = await getBlob(owner, repo, ref, filePath);
2497 if (!blob) {
2498 return c.html(
06d5ffeClaude2499 <Layout title="Not Found" user={user}>
fc1817aClaude2500 <div class="empty-state">
2501 <h2>File not found</h2>
2502 </div>
2503 </Layout>,
2504 404
2505 );
2506 }
2507
06d5ffeClaude2508 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude2509 const lineCount = blob.isBinary
2510 ? 0
2511 : (blob.content.endsWith("\n")
2512 ? blob.content.split("\n").length - 1
2513 : blob.content.split("\n").length);
2514 const formatBytes = (n: number): string => {
2515 if (n < 1024) return `${n} B`;
2516 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
2517 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
2518 };
fc1817aClaude2519
2520 return c.html(
06d5ffeClaude2521 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude2522 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2523 <RepoHeader owner={owner} repo={repo} />
2524 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude2525 <div class="blob-toolbar">
2526 <BranchSwitcher
2527 owner={owner}
2528 repo={repo}
2529 currentRef={ref}
2530 branches={branches}
2531 pathType="blob"
2532 subPath={filePath}
2533 />
2534 <div class="blob-breadcrumb">
2535 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
2536 </div>
2537 </div>
2538 <div class="blob-view blob-card">
2539 <div class="blob-header blob-header-polished">
2540 <div class="blob-header-meta">
2541 <span class="blob-header-icon" aria-hidden="true">
2542 {"📄"}
2543 </span>
2544 <span class="blob-header-name">{fileName}</span>
2545 <span class="blob-header-size">
2546 {formatBytes(blob.size)}
2547 {!blob.isBinary && (
2548 <>
2549 {" · "}
2550 {lineCount} line{lineCount === 1 ? "" : "s"}
2551 </>
2552 )}
2553 </span>
2554 </div>
2555 <div class="blob-header-actions">
2556 <a
2557 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
2558 class="blob-pill"
2559 >
79136bbClaude2560 Raw
2561 </a>
efb11c5Claude2562 <a
2563 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
2564 class="blob-pill"
2565 >
79136bbClaude2566 Blame
2567 </a>
efb11c5Claude2568 <a
2569 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
2570 class="blob-pill"
2571 >
16b325cClaude2572 History
2573 </a>
0074234Claude2574 {user && (
efb11c5Claude2575 <a
2576 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
2577 class="blob-pill blob-pill-accent"
2578 >
0074234Claude2579 Edit
2580 </a>
2581 )}
efb11c5Claude2582 </div>
fc1817aClaude2583 </div>
2584 {blob.isBinary ? (
efb11c5Claude2585 <div class="blob-binary">
fc1817aClaude2586 Binary file not shown.
2587 </div>
06d5ffeClaude2588 ) : (() => {
2589 const { html: highlighted, language } = highlightCode(
2590 blob.content,
2591 fileName
2592 );
2593 if (language) {
2594 return (
2595 <HighlightedCode
2596 highlightedHtml={highlighted}
efb11c5Claude2597 lineCount={lineCount}
06d5ffeClaude2598 />
2599 );
2600 }
2601 const lines = blob.content.split("\n");
2602 if (lines[lines.length - 1] === "") lines.pop();
2603 return <PlainCode lines={lines} />;
2604 })()}
fc1817aClaude2605 </div>
2606 </Layout>
2607 );
2608});
2609
2610// Commit log
2611web.get("/:owner/:repo/commits/:ref?", async (c) => {
2612 const { owner, repo } = c.req.param();
06d5ffeClaude2613 const user = c.get("user");
fc1817aClaude2614 const ref =
2615 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude2616 const branches = await listBranches(owner, repo);
fc1817aClaude2617
2618 const commits = await listCommits(owner, repo, ref, 50);
2619
3951454Claude2620 // Block J3 — batch-fetch cached verification results for the page.
2621 let verifications: Record<string, { verified: boolean; reason: string }> = {};
2622 try {
2623 const [ownerRow] = await db
2624 .select()
2625 .from(users)
2626 .where(eq(users.username, owner))
2627 .limit(1);
2628 if (ownerRow) {
2629 const [repoRow] = await db
2630 .select()
2631 .from(repositories)
2632 .where(
2633 and(
2634 eq(repositories.ownerId, ownerRow.id),
2635 eq(repositories.name, repo)
2636 )
2637 )
2638 .limit(1);
2639 if (repoRow && commits.length > 0) {
2640 const rows = await db
2641 .select()
2642 .from(commitVerifications)
2643 .where(
2644 and(
2645 eq(commitVerifications.repositoryId, repoRow.id),
2646 inArray(
2647 commitVerifications.commitSha,
2648 commits.map((c) => c.sha)
2649 )
2650 )
2651 );
2652 for (const r of rows) {
2653 verifications[r.commitSha] = {
2654 verified: r.verified,
2655 reason: r.reason,
2656 };
2657 }
2658 }
2659 }
2660 } catch {
2661 // DB unavailable — skip the badges gracefully.
2662 }
2663
fc1817aClaude2664 return c.html(
06d5ffeClaude2665 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude2666 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2667 <RepoHeader owner={owner} repo={repo} />
2668 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude2669 <div class="commits-hero">
2670 <div class="commits-hero-orb-wrap" aria-hidden="true">
2671 <div class="commits-hero-orb" />
fc1817aClaude2672 </div>
efb11c5Claude2673 <div class="commits-hero-inner">
2674 <div class="commits-eyebrow">
2675 <strong>History</strong> · {owner}/{repo}
2676 </div>
2677 <h1 class="commits-title">
2678 <span class="gradient-text">{commits.length}</span> recent commit
2679 {commits.length === 1 ? "" : "s"} on{" "}
2680 <span class="commits-branch">{ref}</span>
2681 </h1>
2682 <p class="commits-sub">
2683 Browse the project's history. Click any commit to see the full
2684 diff, AI review notes, and signature status.
2685 </p>
2686 </div>
2687 </div>
2688 <div class="commits-toolbar">
2689 <BranchSwitcher
3951454Claude2690 owner={owner}
2691 repo={repo}
efb11c5Claude2692 currentRef={ref}
2693 branches={branches}
2694 pathType="commits"
3951454Claude2695 />
efb11c5Claude2696 </div>
2697 {commits.length === 0 ? (
2698 <div class="commits-empty">
2699 <p>No commits yet on this branch.</p>
2700 </div>
2701 ) : (
2702 <div class="commits-list-wrap">
2703 <CommitList
2704 commits={commits}
2705 owner={owner}
2706 repo={repo}
2707 verifications={verifications}
2708 />
2709 </div>
fc1817aClaude2710 )}
2711 </Layout>
2712 );
2713});
2714
2715// Single commit with diff
2716web.get("/:owner/:repo/commit/:sha", async (c) => {
2717 const { owner, repo, sha } = c.req.param();
06d5ffeClaude2718 const user = c.get("user");
fc1817aClaude2719
05b973eClaude2720 // Fetch commit, full message, and diff in parallel
2721 const [commit, fullMessage, diffResult] = await Promise.all([
2722 getCommit(owner, repo, sha),
2723 getCommitFullMessage(owner, repo, sha),
2724 getDiff(owner, repo, sha),
2725 ]);
fc1817aClaude2726 if (!commit) {
2727 return c.html(
06d5ffeClaude2728 <Layout title="Not Found" user={user}>
fc1817aClaude2729 <div class="empty-state">
2730 <h2>Commit not found</h2>
2731 </div>
2732 </Layout>,
2733 404
2734 );
2735 }
2736
3951454Claude2737 // Block J3 — try to verify this commit's signature.
2738 let verification:
2739 | { verified: boolean; reason: string; signatureType: string | null }
2740 | null = null;
0cdfd89Claude2741 // Block J8 — external CI commit statuses rollup.
2742 let statusCombined:
2743 | {
2744 state: "pending" | "success" | "failure";
2745 total: number;
2746 contexts: Array<{
2747 context: string;
2748 state: string;
2749 description: string | null;
2750 targetUrl: string | null;
2751 }>;
2752 }
2753 | null = null;
3951454Claude2754 try {
2755 const [ownerRow] = await db
2756 .select()
2757 .from(users)
2758 .where(eq(users.username, owner))
2759 .limit(1);
2760 if (ownerRow) {
2761 const [repoRow] = await db
2762 .select()
2763 .from(repositories)
2764 .where(
2765 and(
2766 eq(repositories.ownerId, ownerRow.id),
2767 eq(repositories.name, repo)
2768 )
2769 )
2770 .limit(1);
2771 if (repoRow) {
2772 const { verifyCommit } = await import("../lib/signatures");
2773 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
2774 verification = {
2775 verified: v.verified,
2776 reason: v.reason,
2777 signatureType: v.signatureType,
2778 };
0cdfd89Claude2779 try {
2780 const { combinedStatus } = await import("../lib/commit-statuses");
2781 const combined = await combinedStatus(repoRow.id, commit.sha);
2782 if (combined.total > 0) {
2783 statusCombined = {
2784 state: combined.state as any,
2785 total: combined.total,
2786 contexts: combined.contexts.map((c) => ({
2787 context: c.context,
2788 state: c.state,
2789 description: c.description,
2790 targetUrl: c.targetUrl,
2791 })),
2792 };
2793 }
2794 } catch {
2795 statusCombined = null;
2796 }
3951454Claude2797 }
2798 }
2799 } catch {
2800 verification = null;
2801 }
2802
05b973eClaude2803 const { files, raw } = diffResult;
fc1817aClaude2804
efb11c5Claude2805 // Diff stats: count additions / deletions across all files for the
2806 // header summary bar. Computed here from the parsed diff so we don't
2807 // touch the DiffView component.
2808 let additions = 0;
2809 let deletions = 0;
2810 for (const f of files) {
2811 const hunks = (f as any).hunks as Array<any> | undefined;
2812 if (Array.isArray(hunks)) {
2813 for (const h of hunks) {
2814 const lines = (h?.lines || []) as Array<any>;
2815 for (const ln of lines) {
2816 const t = ln?.type || ln?.kind;
2817 if (t === "add" || t === "added" || t === "+") additions += 1;
2818 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
2819 deletions += 1;
2820 }
2821 }
2822 }
2823 }
2824 // Fall back: scan raw if file-level counting yielded zero (it's just a
2825 // header polish — never let a parsing miss break the page).
2826 if (additions === 0 && deletions === 0 && typeof raw === "string") {
2827 for (const line of raw.split("\n")) {
2828 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
2829 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
2830 }
2831 }
2832 const fileCount = files.length;
2833
fc1817aClaude2834 return c.html(
06d5ffeClaude2835 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude2836 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude2837 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude2838 <div class="commit-detail-card">
2839 <div class="commit-detail-eyebrow">
2840 <strong>Commit</strong>
2841 <span class="commit-detail-sha-pill" title={commit.sha}>
2842 {commit.sha.slice(0, 7)}
2843 </span>
3951454Claude2844 {verification && verification.reason !== "unsigned" && (
2845 <span
efb11c5Claude2846 class={`commit-detail-verify ${
3951454Claude2847 verification.verified
efb11c5Claude2848 ? "commit-detail-verify-ok"
2849 : "commit-detail-verify-warn"
3951454Claude2850 }`}
2851 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
2852 >
2853 {verification.verified ? "Verified" : verification.reason}
2854 </span>
2855 )}
fc1817aClaude2856 </div>
efb11c5Claude2857 <h1 class="commit-detail-title">{commit.message}</h1>
2858 {fullMessage !== commit.message && (
2859 <pre class="commit-detail-body">{fullMessage}</pre>
2860 )}
2861 <div class="commit-detail-meta">
2862 <span class="commit-detail-author">
2863 <strong>{commit.author}</strong> committed on{" "}
2864 {new Date(commit.date).toLocaleDateString("en-US", {
2865 month: "long",
2866 day: "numeric",
2867 year: "numeric",
2868 })}
2869 </span>
fc1817aClaude2870 {commit.parentShas.length > 0 && (
efb11c5Claude2871 <span class="commit-detail-parents">
2872 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
2873 {commit.parentShas.map((p, idx) => (
2874 <>
2875 {idx > 0 && " "}
2876 <a
2877 href={`/${owner}/${repo}/commit/${p}`}
2878 class="commit-detail-sha-link"
2879 >
2880 {p.slice(0, 7)}
2881 </a>
2882 </>
fc1817aClaude2883 ))}
2884 </span>
2885 )}
2886 </div>
efb11c5Claude2887 <div class="commit-detail-stats">
2888 <span class="commit-detail-stat">
2889 <strong>{fileCount}</strong>{" "}
2890 file{fileCount === 1 ? "" : "s"} changed
2891 </span>
2892 <span class="commit-detail-stat commit-detail-stat-add">
2893 <span class="commit-detail-stat-mark">+</span>
2894 <strong>{additions}</strong>
2895 </span>
2896 <span class="commit-detail-stat commit-detail-stat-del">
2897 <span class="commit-detail-stat-mark">−</span>
2898 <strong>{deletions}</strong>
2899 </span>
2900 <span class="commit-detail-sha-full" title="Full SHA">
2901 {commit.sha}
2902 </span>
2903 </div>
0cdfd89Claude2904 {statusCombined && (
efb11c5Claude2905 <div class="commit-detail-checks">
2906 <div class="commit-detail-checks-head">
2907 <strong>Checks</strong>
2908 <span class="commit-detail-checks-summary">
2909 {statusCombined.total} total ·{" "}
2910 <span
2911 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
2912 >
2913 {statusCombined.state}
2914 </span>
0cdfd89Claude2915 </span>
efb11c5Claude2916 </div>
2917 <div class="commit-detail-check-row">
0cdfd89Claude2918 {statusCombined.contexts.map((cx) => (
2919 <span
efb11c5Claude2920 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude2921 title={cx.description || cx.context}
2922 >
2923 {cx.targetUrl ? (
efb11c5Claude2924 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude2925 {cx.context}: {cx.state}
2926 </a>
2927 ) : (
2928 <>
2929 {cx.context}: {cx.state}
2930 </>
2931 )}
2932 </span>
2933 ))}
2934 </div>
2935 </div>
2936 )}
fc1817aClaude2937 </div>
2938 <DiffView raw={raw} files={files} />
2939 </Layout>
2940 );
2941});
2942
79136bbClaude2943// Raw file download
2944web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
2945 const { owner, repo } = c.req.param();
2946 const refAndPath = c.req.param("ref");
2947
2948 const branches = await listBranches(owner, repo);
2949 let ref = "";
2950 let filePath = "";
2951
2952 for (const branch of branches) {
2953 if (refAndPath.startsWith(branch + "/")) {
2954 ref = branch;
2955 filePath = refAndPath.slice(branch.length + 1);
2956 break;
2957 }
2958 }
2959
2960 if (!ref) {
2961 const slashIdx = refAndPath.indexOf("/");
2962 if (slashIdx === -1) return c.text("Not found", 404);
2963 ref = refAndPath.slice(0, slashIdx);
2964 filePath = refAndPath.slice(slashIdx + 1);
2965 }
2966
2967 const data = await getRawBlob(owner, repo, ref, filePath);
2968 if (!data) return c.text("Not found", 404);
2969
2970 const fileName = filePath.split("/").pop() || "file";
772a24fClaude2971 return new Response(data as BodyInit, {
79136bbClaude2972 headers: {
2973 "Content-Type": "application/octet-stream",
2974 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude2975 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude2976 },
2977 });
2978});
2979
2980// Blame view
2981web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
2982 const { owner, repo } = c.req.param();
2983 const user = c.get("user");
2984 const refAndPath = c.req.param("ref");
2985
2986 const branches = await listBranches(owner, repo);
2987 let ref = "";
2988 let filePath = "";
2989
2990 for (const branch of branches) {
2991 if (refAndPath.startsWith(branch + "/")) {
2992 ref = branch;
2993 filePath = refAndPath.slice(branch.length + 1);
2994 break;
2995 }
2996 }
2997
2998 if (!ref) {
2999 const slashIdx = refAndPath.indexOf("/");
3000 if (slashIdx === -1) return c.text("Not found", 404);
3001 ref = refAndPath.slice(0, slashIdx);
3002 filePath = refAndPath.slice(slashIdx + 1);
3003 }
3004
3005 const blameLines = await getBlame(owner, repo, ref, filePath);
3006 if (blameLines.length === 0) {
3007 return c.html(
3008 <Layout title="Not Found" user={user}>
3009 <div class="empty-state">
3010 <h2>File not found</h2>
3011 </div>
3012 </Layout>,
3013 404
3014 );
3015 }
3016
3017 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3018 // Unique contributors (by author) tracked once for the header chip.
3019 const blameAuthors = new Set<string>();
3020 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude3021
3022 return c.html(
3023 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3024 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude3025 <RepoHeader owner={owner} repo={repo} />
3026 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3027 <div class="blame-toolbar">
3028 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3029 </div>
3030 <div class="blob-view blame-card">
3031 <div class="blob-header blame-header">
3032 <div class="blame-header-meta">
3033 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
3034 <span class="blame-header-name">{fileName}</span>
3035 <span class="blame-header-tag">Blame</span>
3036 <span class="blame-header-stats">
3037 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
3038 {blameAuthors.size} contributor
3039 {blameAuthors.size === 1 ? "" : "s"}
3040 </span>
3041 </div>
3042 <div class="blame-header-actions">
3043 <a
3044 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
3045 class="blob-pill"
3046 >
3047 Normal view
3048 </a>
3049 <a
3050 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
3051 class="blob-pill"
3052 >
3053 Raw
3054 </a>
3055 </div>
79136bbClaude3056 </div>
3057 <div class="blob-code" style="overflow-x: auto">
3058 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
3059 <tbody>
3060 {blameLines.map((line, i) => {
3061 const showInfo =
3062 i === 0 || blameLines[i - 1].sha !== line.sha;
3063 return (
3064 <tr style="border-bottom: 1px solid var(--border)">
3065 <td
3066 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)" : ""}`}
3067 >
3068 {showInfo && (
3069 <>
3070 <a
3071 href={`/${owner}/${repo}/commit/${line.sha}`}
3072 style="color: var(--text-link); font-family: var(--font-mono)"
3073 >
3074 {line.sha.slice(0, 7)}
3075 </a>{" "}
3076 <span>{line.author}</span>
3077 </>
3078 )}
3079 </td>
3080 <td class="line-num">{line.lineNum}</td>
3081 <td class="line-content">{line.content}</td>
3082 </tr>
3083 );
3084 })}
3085 </tbody>
3086 </table>
3087 </div>
3088 </div>
3089 </Layout>
3090 );
3091});
3092
3093// Search
3094web.get("/:owner/:repo/search", async (c) => {
3095 const { owner, repo } = c.req.param();
3096 const user = c.get("user");
3097 const q = c.req.query("q") || "";
3098
3099 if (!(await repoExists(owner, repo))) return c.notFound();
3100
3101 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3102 let results: Array<{ file: string; lineNum: number; line: string }> = [];
3103
3104 if (q.trim()) {
3105 results = await searchCode(owner, repo, defaultBranch, q.trim());
3106 }
3107
3108 return c.html(
3109 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude3110 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude3111 <RepoHeader owner={owner} repo={repo} />
3112 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3113 <div class="search-hero">
3114 <div class="search-eyebrow">
3115 <strong>Search</strong> · {owner}/{repo}
3116 </div>
3117 <h1 class="search-title">
3118 Find any line in <span class="gradient-text">{repo}</span>
3119 </h1>
3120 <form
3121 method="get"
3122 action={`/${owner}/${repo}/search`}
3123 class="search-form"
3124 role="search"
3125 >
3126 <div class="search-input-wrap">
3127 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
3128 <input
3129 type="text"
3130 name="q"
3131 value={q}
3132 placeholder="Search code on the default branch…"
3133 aria-label="Search code"
3134 class="search-input"
3135 autocomplete="off"
3136 autofocus
3137 />
3138 </div>
3139 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude3140 Search
3141 </button>
efb11c5Claude3142 </form>
3143 </div>
79136bbClaude3144 {q && (
efb11c5Claude3145 <div class="search-results-head">
3146 <span class="search-results-count">
3147 <strong>{results.length}</strong> result
3148 {results.length !== 1 ? "s" : ""}
3149 </span>
3150 <span class="search-results-query">
3151 for <span class="search-results-q">"{q}"</span> on{" "}
3152 <code>{defaultBranch}</code>
3153 </span>
3154 </div>
79136bbClaude3155 )}
efb11c5Claude3156 {q && results.length === 0 ? (
3157 <div class="search-empty">
3158 <p>
3159 No matches for <strong>"{q}"</strong>. Try a shorter query or check
3160 you're on the right branch.
3161 </p>
3162 </div>
3163 ) : results.length > 0 ? (
79136bbClaude3164 <div class="search-results">
3165 {(() => {
3166 // Group by file
3167 const grouped: Record<
3168 string,
3169 Array<{ lineNum: number; line: string }>
3170 > = {};
3171 for (const r of results) {
3172 if (!grouped[r.file]) grouped[r.file] = [];
3173 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
3174 }
3175 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude3176 <div class="search-file diff-file">
3177 <div class="search-file-head diff-file-header">
79136bbClaude3178 <a
3179 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude3180 class="search-file-link"
79136bbClaude3181 >
3182 {file}
3183 </a>
efb11c5Claude3184 <span class="search-file-count">
3185 {matches.length} match{matches.length === 1 ? "" : "es"}
3186 </span>
79136bbClaude3187 </div>
3188 <div class="blob-code">
3189 <table>
3190 <tbody>
3191 {matches.map((m) => (
3192 <tr>
3193 <td class="line-num">{m.lineNum}</td>
3194 <td class="line-content">{m.line}</td>
3195 </tr>
3196 ))}
3197 </tbody>
3198 </table>
3199 </div>
3200 </div>
3201 ));
3202 })()}
3203 </div>
efb11c5Claude3204 ) : null}
79136bbClaude3205 </Layout>
3206 );
3207});
3208
fc1817aClaude3209export default web;