Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

web.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

web.tsxBlame4768 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,
06d5ffeClaude23 RepoCard,
24 BranchSwitcher,
25 HighlightedCode,
26 PlainCode,
fc1817aClaude27} from "../views/components";
ea9ed4cClaude28import { DiffView } from "../views/diff-view";
fc1817aClaude29import {
30 getTree,
31 getBlob,
32 listCommits,
33 getCommit,
34 getCommitFullMessage,
35 getDiff,
36 getReadme,
37 getDefaultBranch,
38 listBranches,
398a10cClaude39 listTags,
fc1817aClaude40 repoExists,
06d5ffeClaude41 initBareRepo,
79136bbClaude42 getBlame,
43 getRawBlob,
44 searchCode,
398a10cClaude45 getRepoPath,
fc1817aClaude46} from "../git/repository";
79136bbClaude47import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude48import { highlightCode } from "../lib/highlight";
49import { softAuth, requireAuth } from "../middleware/auth";
50import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude51import { trackByName } from "../lib/traffic";
534f04aClaude52import { LandingPage, type LandingLiveFeed } from "../views/landing";
52ad8b1Claude53import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude54import {
55 listQueuedAiBuildIssues,
56 listRecentAutoMerges,
57 listRecentAiReviews,
58 countAiReviewsSince,
59 listDemoActivityFeed,
60} from "../lib/demo-activity";
fc1817aClaude61
06d5ffeClaude62const web = new Hono<AuthEnv>();
63
64// Soft auth on all web routes — c.get("user") available but may be null
65web.use("*", softAuth);
fc1817aClaude66
efb11c5Claude67/**
68 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
69 *
70 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
71 * scope is route-local and `layout.tsx` is locked. Each polished handler
72 * injects this via a `<style>` tag; the rules are namespaced by surface
73 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
74 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
75 * `.repo-home-*` styling Agent A already shipped.
76 */
77const codeBrowseCss = `
78 /* ───────── shared primitives ───────── */
79 .cb-hairline::before,
80 .new-repo-hero::before,
81 .profile-hero::before,
82 .commits-hero::before,
83 .commit-detail-card::before,
84 .search-hero::before {
85 content: '';
86 position: absolute;
87 top: 0; left: 0; right: 0;
88 height: 2px;
89 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
90 opacity: 0.7;
91 pointer-events: none;
92 }
93 @keyframes cbHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .new-repo-hero-orb,
99 .profile-hero-orb,
100 .commits-hero-orb { animation: none; }
101 }
102
103 /* ───────── new-repo ───────── */
104 .new-repo-hero {
105 position: relative;
106 margin-bottom: var(--space-5);
107 padding: var(--space-5) var(--space-6);
108 background: var(--bg-elevated);
109 border: 1px solid var(--border);
110 border-radius: 16px;
111 overflow: hidden;
112 }
113 .new-repo-hero-orb-wrap {
114 position: absolute;
115 inset: -25% -10% auto auto;
116 width: 360px;
117 height: 360px;
118 pointer-events: none;
119 z-index: 0;
120 }
121 .new-repo-hero-orb {
122 position: absolute;
123 inset: 0;
124 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
125 filter: blur(80px);
126 opacity: 0.7;
127 animation: cbHeroOrb 14s ease-in-out infinite;
128 }
129 .new-repo-hero-inner { position: relative; z-index: 1; }
130 .new-repo-eyebrow {
131 font-size: 12px;
132 font-family: var(--font-mono);
133 color: var(--text-muted);
134 letter-spacing: 0.1em;
135 text-transform: uppercase;
136 margin-bottom: var(--space-2);
137 }
138 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
139 .new-repo-title {
140 font-family: var(--font-display);
141 font-weight: 800;
142 letter-spacing: -0.028em;
143 font-size: clamp(28px, 4vw, 40px);
144 line-height: 1.05;
145 margin: 0 0 var(--space-2);
146 color: var(--text-strong);
147 }
148 .new-repo-sub {
149 font-size: 15px;
150 color: var(--text-muted);
151 margin: 0;
152 line-height: 1.55;
153 max-width: 620px;
154 }
155 .new-repo-form {
156 max-width: 680px;
157 }
158 .new-repo-error {
159 background: rgba(218, 54, 51, 0.12);
160 border: 1px solid rgba(218, 54, 51, 0.35);
161 color: #ffb3b3;
162 padding: 10px 14px;
163 border-radius: 10px;
164 margin-bottom: var(--space-4);
165 font-size: 14px;
166 }
167 .new-repo-form-grid {
168 display: flex;
169 flex-direction: column;
170 gap: var(--space-4);
171 }
172 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
173 .new-repo-label {
174 font-size: 13px;
175 color: var(--text-strong);
176 font-weight: 600;
177 }
178 .new-repo-label-optional {
179 color: var(--text-muted);
180 font-weight: 400;
181 font-size: 12px;
182 }
183 .new-repo-input {
184 appearance: none;
185 width: 100%;
186 background: var(--bg);
187 border: 1px solid var(--border);
188 color: var(--text-strong);
189 border-radius: 10px;
190 padding: 10px 12px;
191 font-size: 14px;
192 font-family: inherit;
193 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
194 }
195 .new-repo-input:focus {
196 outline: none;
197 border-color: var(--accent);
198 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
199 }
200 .new-repo-input-disabled {
201 color: var(--text-muted);
202 background: var(--bg-secondary);
203 cursor: not-allowed;
204 }
205 .new-repo-hint {
206 font-size: 12px;
207 color: var(--text-muted);
208 margin: 4px 0 0;
209 }
210 .new-repo-hint code {
211 font-family: var(--font-mono);
212 font-size: 11.5px;
213 background: var(--bg-secondary);
214 border: 1px solid var(--border);
215 border-radius: 5px;
216 padding: 1px 5px;
217 color: var(--text-strong);
218 }
219 .new-repo-visibility {
220 display: grid;
221 grid-template-columns: 1fr 1fr;
222 gap: var(--space-2);
223 }
224 @media (max-width: 600px) {
225 .new-repo-visibility { grid-template-columns: 1fr; }
226 }
227 .new-repo-vis-card {
228 display: flex;
229 gap: 10px;
230 padding: 14px;
231 background: var(--bg);
232 border: 1px solid var(--border);
233 border-radius: 12px;
234 cursor: pointer;
235 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
236 }
237 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
238 .new-repo-vis-card:has(input:checked) {
239 border-color: rgba(140,109,255,0.55);
240 background: rgba(140,109,255,0.06);
241 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
242 }
243 .new-repo-vis-radio {
244 margin-top: 3px;
245 accent-color: var(--accent);
246 }
247 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
248 .new-repo-vis-label {
249 font-size: 14px;
250 font-weight: 600;
251 color: var(--text-strong);
252 }
253 .new-repo-vis-desc {
254 font-size: 12.5px;
255 color: var(--text-muted);
256 line-height: 1.45;
257 }
258 .new-repo-callout {
259 margin-top: var(--space-2);
260 padding: var(--space-3) var(--space-4);
261 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
262 border: 1px solid rgba(140,109,255,0.2);
263 border-radius: 12px;
264 }
265 .new-repo-callout-eyebrow {
266 font-size: 11px;
267 font-family: var(--font-mono);
268 text-transform: uppercase;
269 letter-spacing: 0.12em;
270 color: var(--accent);
271 font-weight: 700;
272 margin-bottom: 4px;
273 }
274 .new-repo-callout-body {
275 font-size: 13px;
276 color: var(--text);
277 line-height: 1.5;
278 margin: 0;
279 }
280 .new-repo-callout-body code {
281 font-family: var(--font-mono);
282 font-size: 12px;
283 color: var(--accent);
284 background: rgba(140,109,255,0.1);
285 border-radius: 4px;
286 padding: 1px 5px;
287 }
398a10cClaude288 .new-repo-templates {
289 display: flex;
290 flex-wrap: wrap;
291 gap: 8px;
292 margin-top: 4px;
293 }
294 .new-repo-template-chip {
295 position: relative;
296 display: inline-flex;
297 align-items: center;
298 gap: 6px;
299 padding: 8px 14px;
300 background: var(--bg);
301 border: 1px solid var(--border);
302 border-radius: 999px;
303 font-size: 13px;
304 color: var(--text);
305 cursor: pointer;
306 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
307 }
308 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
309 .new-repo-template-chip:hover {
310 border-color: var(--border-strong, var(--border));
311 color: var(--text-strong);
312 }
313 .new-repo-template-chip:has(input:checked) {
314 border-color: rgba(140,109,255,0.55);
315 background: rgba(140,109,255,0.10);
316 color: var(--text-strong);
317 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
318 }
319 .new-repo-template-chip-dot {
320 width: 8px; height: 8px;
321 border-radius: 999px;
322 background: var(--text-faint, var(--text-muted));
323 transition: background 140ms ease, box-shadow 140ms ease;
324 }
325 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
326 background: linear-gradient(135deg, #8c6dff, #36c5d6);
327 box-shadow: 0 0 8px rgba(140,109,255,0.6);
328 }
efb11c5Claude329 .new-repo-actions {
330 display: flex;
331 gap: var(--space-2);
332 margin-top: var(--space-2);
398a10cClaude333 align-items: center;
334 }
335 .new-repo-submit {
336 min-width: 180px;
337 border: 1px solid rgba(140,109,255,0.45);
338 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
339 color: #fff;
340 font-weight: 700;
341 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
342 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
343 }
344 .new-repo-submit:hover {
345 transform: translateY(-1px);
346 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
347 filter: brightness(1.06);
348 }
349 .new-repo-submit:focus-visible {
350 outline: 3px solid rgba(140,109,255,0.45);
351 outline-offset: 2px;
efb11c5Claude352 }
353
354 /* ───────── profile ───────── */
355 .profile-hero {
356 position: relative;
357 margin-bottom: var(--space-5);
358 padding: var(--space-5) var(--space-6);
359 background: var(--bg-elevated);
360 border: 1px solid var(--border);
361 border-radius: 16px;
362 overflow: hidden;
363 }
364 .profile-hero-orb-wrap {
365 position: absolute;
366 inset: -25% -10% auto auto;
367 width: 360px;
368 height: 360px;
369 pointer-events: none;
370 z-index: 0;
371 }
372 .profile-hero-orb {
373 position: absolute;
374 inset: 0;
375 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
376 filter: blur(80px);
377 opacity: 0.7;
378 animation: cbHeroOrb 14s ease-in-out infinite;
379 }
380 .profile-hero-inner {
381 position: relative;
382 z-index: 1;
383 display: flex;
384 align-items: flex-start;
385 gap: var(--space-5);
386 }
387 .profile-hero-avatar {
388 flex: 0 0 auto;
389 width: 88px;
390 height: 88px;
391 border-radius: 50%;
392 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
393 color: #fff;
394 display: flex;
395 align-items: center;
396 justify-content: center;
397 font-size: 38px;
398 font-weight: 700;
399 font-family: var(--font-display);
400 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
401 }
402 .profile-hero-text { flex: 1; min-width: 0; }
403 .profile-eyebrow {
404 font-size: 12px;
405 font-family: var(--font-mono);
406 color: var(--text-muted);
407 letter-spacing: 0.1em;
408 text-transform: uppercase;
409 margin-bottom: var(--space-2);
410 }
411 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
412 .profile-name {
413 font-family: var(--font-display);
414 font-weight: 800;
415 letter-spacing: -0.028em;
416 font-size: clamp(28px, 3.6vw, 36px);
417 line-height: 1.05;
418 margin: 0 0 4px;
419 color: var(--text-strong);
420 }
421 .profile-handle {
422 font-family: var(--font-mono);
423 font-size: 13px;
424 color: var(--text-muted);
425 margin-bottom: var(--space-2);
426 }
427 .profile-bio {
428 font-size: 14.5px;
429 color: var(--text);
430 line-height: 1.55;
431 margin: 0 0 var(--space-3);
432 max-width: 640px;
433 }
434 .profile-meta {
435 display: flex;
436 align-items: center;
437 gap: var(--space-4);
438 flex-wrap: wrap;
439 font-size: 13px;
440 }
441 .profile-meta-link {
442 color: var(--text-muted);
443 transition: color var(--t-fast, 0.15s) ease;
444 }
445 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
446 .profile-meta-link strong {
447 color: var(--text-strong);
448 font-weight: 600;
449 font-variant-numeric: tabular-nums;
450 }
451 .profile-follow-form { margin: 0; }
452 @media (max-width: 600px) {
453 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
454 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
455 }
456 .profile-readme {
457 margin-bottom: var(--space-6);
458 background: var(--bg-elevated);
459 border: 1px solid var(--border);
460 border-radius: 12px;
461 overflow: hidden;
462 }
463 .profile-readme-head {
464 display: flex;
465 align-items: center;
466 gap: 8px;
467 padding: 10px 16px;
468 background: var(--bg-secondary);
469 border-bottom: 1px solid var(--border);
470 font-size: 13px;
471 color: var(--text-muted);
472 }
473 .profile-readme-icon { color: var(--accent); font-size: 14px; }
474 .profile-readme-body { padding: var(--space-5) var(--space-6); }
475 .profile-section-head {
476 display: flex;
477 align-items: baseline;
478 gap: var(--space-2);
479 margin-bottom: var(--space-3);
480 }
481 .profile-section-title {
482 font-family: var(--font-display);
483 font-weight: 700;
484 font-size: 20px;
485 letter-spacing: -0.015em;
486 margin: 0;
487 color: var(--text-strong);
488 }
489 .profile-section-count {
490 font-family: var(--font-mono);
491 font-size: 12px;
492 color: var(--text-muted);
493 background: var(--bg-secondary);
494 border: 1px solid var(--border);
495 border-radius: 999px;
496 padding: 2px 10px;
497 font-variant-numeric: tabular-nums;
498 }
499 .profile-empty {
500 display: flex;
501 align-items: center;
502 justify-content: space-between;
503 gap: var(--space-3);
504 padding: var(--space-4) var(--space-5);
505 background: var(--bg-elevated);
506 border: 1px dashed var(--border);
507 border-radius: 12px;
508 }
509 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
510
511 /* ───────── tree (file browser) ───────── */
512 .tree-header {
513 margin-bottom: var(--space-3);
514 display: flex;
515 flex-direction: column;
516 gap: var(--space-2);
517 }
518 .tree-header-row {
519 display: flex;
520 align-items: center;
521 justify-content: space-between;
522 gap: var(--space-3);
523 flex-wrap: wrap;
524 }
525 .tree-header-stats {
526 display: flex;
527 align-items: center;
528 gap: var(--space-3);
529 font-size: 12px;
530 color: var(--text-muted);
531 }
532 .tree-stat strong {
533 color: var(--text-strong);
534 font-weight: 600;
535 font-variant-numeric: tabular-nums;
536 }
537 .tree-stat-link {
538 color: var(--text-muted);
539 padding: 4px 10px;
540 border-radius: 999px;
541 border: 1px solid var(--border);
542 background: var(--bg-elevated);
543 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
544 }
545 .tree-stat-link:hover {
546 color: var(--accent);
547 border-color: rgba(140,109,255,0.45);
548 text-decoration: none;
549 }
550 .tree-breadcrumb-row {
551 font-size: 13px;
552 }
553
554 /* ───────── blob (file viewer) ───────── */
555 .blob-toolbar {
556 margin-bottom: var(--space-3);
557 display: flex;
558 flex-direction: column;
559 gap: var(--space-2);
560 }
561 .blob-breadcrumb { font-size: 13px; }
562 .blob-card {
563 background: var(--bg-elevated);
564 border: 1px solid var(--border);
565 border-radius: 12px;
566 overflow: hidden;
567 }
568 .blob-header-polished {
569 display: flex;
570 align-items: center;
571 justify-content: space-between;
572 gap: var(--space-3);
573 padding: 10px 14px;
574 background: var(--bg-secondary);
575 border-bottom: 1px solid var(--border);
576 }
577 .blob-header-meta {
578 display: flex;
579 align-items: center;
580 gap: var(--space-2);
581 min-width: 0;
582 flex: 1;
583 }
584 .blob-header-icon { font-size: 14px; opacity: 0.85; }
585 .blob-header-name {
586 font-family: var(--font-mono);
587 font-size: 13px;
588 color: var(--text-strong);
589 font-weight: 600;
590 overflow: hidden;
591 text-overflow: ellipsis;
592 white-space: nowrap;
593 }
594 .blob-header-size {
595 font-family: var(--font-mono);
596 font-size: 11.5px;
597 color: var(--text-muted);
598 font-variant-numeric: tabular-nums;
599 border-left: 1px solid var(--border);
600 padding-left: var(--space-2);
601 margin-left: 2px;
602 }
603 .blob-header-actions {
604 display: flex;
605 gap: 6px;
606 flex-shrink: 0;
607 }
608 .blob-pill {
609 display: inline-flex;
610 align-items: center;
611 padding: 4px 12px;
612 font-size: 12px;
613 font-weight: 500;
614 color: var(--text);
615 background: var(--bg-elevated);
616 border: 1px solid var(--border);
617 border-radius: 999px;
618 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
619 }
620 .blob-pill:hover {
621 color: var(--accent);
622 border-color: rgba(140,109,255,0.45);
623 text-decoration: none;
624 background: rgba(140,109,255,0.06);
625 }
626 .blob-pill-accent {
627 color: var(--accent);
628 border-color: rgba(140,109,255,0.35);
629 background: rgba(140,109,255,0.08);
630 }
631 .blob-pill-accent:hover {
632 background: rgba(140,109,255,0.14);
633 }
634 .blob-binary {
635 padding: var(--space-5);
636 color: var(--text-muted);
637 text-align: center;
638 font-size: 13px;
639 background: var(--bg);
640 }
641
642 /* ───────── commits list ───────── */
643 .commits-hero {
644 position: relative;
645 margin-bottom: var(--space-4);
646 padding: var(--space-5) var(--space-6);
647 background: var(--bg-elevated);
648 border: 1px solid var(--border);
649 border-radius: 16px;
650 overflow: hidden;
651 }
652 .commits-hero-orb-wrap {
653 position: absolute;
654 inset: -25% -10% auto auto;
655 width: 320px;
656 height: 320px;
657 pointer-events: none;
658 z-index: 0;
659 }
660 .commits-hero-orb {
661 position: absolute;
662 inset: 0;
663 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
664 filter: blur(80px);
665 opacity: 0.7;
666 animation: cbHeroOrb 14s ease-in-out infinite;
667 }
668 .commits-hero-inner { position: relative; z-index: 1; }
669 .commits-eyebrow {
670 font-size: 12px;
671 font-family: var(--font-mono);
672 color: var(--text-muted);
673 letter-spacing: 0.1em;
674 text-transform: uppercase;
675 margin-bottom: var(--space-2);
676 }
677 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
678 .commits-title {
679 font-family: var(--font-display);
680 font-weight: 800;
681 letter-spacing: -0.025em;
682 font-size: clamp(22px, 3vw, 30px);
683 line-height: 1.15;
684 margin: 0 0 var(--space-2);
685 color: var(--text-strong);
686 }
687 .commits-branch {
688 font-family: var(--font-mono);
689 font-size: 0.7em;
690 color: var(--text);
691 background: var(--bg-secondary);
692 border: 1px solid var(--border);
693 border-radius: 6px;
694 padding: 2px 8px;
695 font-weight: 500;
696 vertical-align: middle;
697 }
698 .commits-sub {
699 font-size: 14px;
700 color: var(--text-muted);
701 margin: 0;
702 line-height: 1.55;
703 max-width: 640px;
704 }
398a10cClaude705 .commits-toolbar {
706 display: flex;
707 justify-content: space-between;
708 align-items: center;
709 flex-wrap: wrap;
710 gap: var(--space-2);
711 margin-bottom: var(--space-3);
712 }
713 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
714 .commits-toolbar-link {
715 display: inline-flex;
716 align-items: center;
717 gap: 6px;
718 padding: 6px 12px;
719 font-size: 12.5px;
720 font-weight: 500;
721 color: var(--text-muted);
722 background: rgba(255,255,255,0.025);
723 border: 1px solid var(--border);
724 border-radius: 8px;
725 text-decoration: none;
726 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
727 }
728 .commits-toolbar-link:hover {
729 border-color: var(--border-strong);
730 color: var(--text-strong);
731 background: rgba(255,255,255,0.04);
732 text-decoration: none;
733 }
efb11c5Claude734 .commits-list-wrap {
735 background: var(--bg-elevated);
736 border: 1px solid var(--border);
398a10cClaude737 border-radius: 14px;
efb11c5Claude738 overflow: hidden;
398a10cClaude739 position: relative;
740 }
741 .commits-list-wrap::before {
742 content: '';
743 position: absolute;
744 top: 0; left: 0; right: 0;
745 height: 2px;
746 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
747 opacity: 0.55;
748 pointer-events: none;
749 }
750 .commits-day-head {
751 display: flex;
752 align-items: center;
753 gap: 10px;
754 padding: 10px 18px;
755 font-size: 11.5px;
756 font-family: var(--font-mono);
757 text-transform: uppercase;
758 letter-spacing: 0.08em;
759 color: var(--text-muted);
760 background: var(--bg-secondary);
761 border-bottom: 1px solid var(--border);
762 }
763 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
764 .commits-day-head-dot {
765 width: 6px; height: 6px;
766 border-radius: 9999px;
767 background: linear-gradient(135deg, #8c6dff, #36c5d6);
768 }
769 .commits-row {
770 display: flex;
771 align-items: flex-start;
772 gap: 14px;
773 padding: 14px 18px;
774 border-bottom: 1px solid var(--border-subtle);
775 transition: background 120ms ease;
776 }
777 .commits-row:last-child { border-bottom: none; }
778 .commits-row:hover { background: rgba(255,255,255,0.022); }
779 .commits-avatar {
780 width: 34px; height: 34px;
781 border-radius: 9999px;
782 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
783 color: #fff;
784 display: inline-flex;
785 align-items: center;
786 justify-content: center;
787 font-family: var(--font-display);
788 font-weight: 700;
789 font-size: 13.5px;
790 flex-shrink: 0;
791 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
792 }
793 .commits-row-body { flex: 1; min-width: 0; }
794 .commits-row-msg {
795 display: flex;
796 align-items: center;
797 gap: 8px;
798 flex-wrap: wrap;
799 }
800 .commits-row-msg a {
801 font-family: var(--font-display);
802 font-weight: 600;
803 font-size: 14px;
804 color: var(--text-strong);
805 text-decoration: none;
806 letter-spacing: -0.005em;
807 }
808 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
809 .commits-row-verified {
810 font-size: 9.5px;
811 padding: 1px 7px;
812 border-radius: 9999px;
813 background: rgba(52,211,153,0.16);
814 color: #6ee7b7;
815 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
816 text-transform: uppercase;
817 letter-spacing: 0.06em;
818 font-weight: 700;
819 }
820 .commits-row-meta {
821 margin-top: 4px;
822 display: flex;
823 align-items: center;
824 gap: 8px;
825 flex-wrap: wrap;
826 font-size: 12.5px;
827 color: var(--text-muted);
828 }
829 .commits-row-meta strong { color: var(--text); font-weight: 600; }
830 .commits-row-meta .sep { opacity: 0.4; }
831 .commits-row-time {
832 font-variant-numeric: tabular-nums;
833 }
834 .commits-row-side {
835 display: flex;
836 align-items: center;
837 gap: 8px;
838 flex-shrink: 0;
839 }
840 .commits-row-sha {
841 display: inline-flex;
842 align-items: center;
843 padding: 4px 10px;
844 border-radius: 9999px;
845 font-family: var(--font-mono);
846 font-size: 11.5px;
847 font-weight: 600;
848 color: var(--text-strong);
849 background: rgba(255,255,255,0.04);
850 border: 1px solid var(--border);
851 text-decoration: none;
852 letter-spacing: 0.04em;
853 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
854 }
855 .commits-row-sha:hover {
856 border-color: rgba(140,109,255,0.55);
857 color: var(--accent);
858 background: rgba(140,109,255,0.08);
859 text-decoration: none;
860 }
861 .commits-row-copy {
862 display: inline-flex;
863 align-items: center;
864 justify-content: center;
865 width: 28px; height: 28px;
866 padding: 0;
867 border-radius: 8px;
868 background: transparent;
869 border: 1px solid var(--border);
870 color: var(--text-muted);
871 cursor: pointer;
872 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude873 }
398a10cClaude874 .commits-row-copy:hover {
875 border-color: var(--border-strong);
876 color: var(--text);
877 background: rgba(255,255,255,0.04);
878 }
879 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
efb11c5Claude880 .commits-empty {
398a10cClaude881 position: relative;
882 overflow: hidden;
883 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude884 text-align: center;
398a10cClaude885 background: var(--bg-elevated);
886 border: 1px dashed var(--border-strong);
887 border-radius: 16px;
888 }
889 .commits-empty-orb {
890 position: absolute;
891 inset: -40% 30% auto 30%;
892 height: 280px;
893 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
894 filter: blur(70px);
895 opacity: 0.7;
896 pointer-events: none;
897 z-index: 0;
898 }
899 .commits-empty-inner { position: relative; z-index: 1; }
900 .commits-empty-icon {
901 width: 56px; height: 56px;
902 border-radius: 9999px;
903 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
904 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
905 display: inline-flex;
906 align-items: center;
907 justify-content: center;
908 color: #c4b5fd;
909 margin: 0 auto 14px;
910 }
911 .commits-empty-title {
912 font-family: var(--font-display);
913 font-size: 18px;
914 font-weight: 700;
915 margin: 0 0 6px;
916 color: var(--text-strong);
917 }
918 .commits-empty-sub {
919 margin: 0 auto 0;
920 font-size: 13.5px;
efb11c5Claude921 color: var(--text-muted);
398a10cClaude922 max-width: 420px;
923 line-height: 1.5;
924 }
925
926 /* ───────── branches list ───────── */
927 .branches-list {
efb11c5Claude928 background: var(--bg-elevated);
398a10cClaude929 border: 1px solid var(--border);
930 border-radius: 14px;
931 overflow: hidden;
932 position: relative;
933 }
934 .branches-list::before {
935 content: '';
936 position: absolute;
937 top: 0; left: 0; right: 0;
938 height: 2px;
939 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
940 opacity: 0.55;
941 pointer-events: none;
942 }
943 .branches-row {
944 display: flex;
945 align-items: center;
946 gap: var(--space-3);
947 padding: 14px 18px;
948 border-bottom: 1px solid var(--border-subtle);
949 transition: background 120ms ease;
950 flex-wrap: wrap;
951 }
952 .branches-row:last-child { border-bottom: none; }
953 .branches-row:hover { background: rgba(255,255,255,0.022); }
954 .branches-row-icon {
955 width: 32px; height: 32px;
956 border-radius: 8px;
957 background: rgba(140,109,255,0.10);
958 color: #c4b5fd;
959 display: inline-flex;
960 align-items: center;
961 justify-content: center;
962 flex-shrink: 0;
963 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
964 }
965 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
966 .branches-row-name {
967 display: inline-flex;
968 align-items: center;
969 gap: 8px;
970 flex-wrap: wrap;
971 }
972 .branches-row-name a {
973 font-family: var(--font-mono);
974 font-size: 13.5px;
975 color: var(--text-strong);
976 font-weight: 600;
977 text-decoration: none;
978 }
979 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
980 .branches-row-default {
981 font-size: 10px;
982 padding: 2px 8px;
983 border-radius: 9999px;
984 background: rgba(140,109,255,0.14);
985 color: #c4b5fd;
986 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
987 text-transform: uppercase;
988 letter-spacing: 0.08em;
989 font-weight: 700;
990 font-family: var(--font-mono);
991 }
992 .branches-row-meta {
993 display: flex;
994 align-items: center;
995 gap: 8px;
996 flex-wrap: wrap;
997 font-size: 12.5px;
998 color: var(--text-muted);
999 font-variant-numeric: tabular-nums;
1000 }
1001 .branches-row-meta .sep { opacity: 0.4; }
1002 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1003 .branches-row-side {
1004 display: flex;
1005 align-items: center;
1006 gap: 10px;
1007 flex-shrink: 0;
1008 flex-wrap: wrap;
1009 }
1010 .branches-row-divergence {
1011 display: inline-flex;
1012 align-items: center;
1013 gap: 8px;
1014 font-family: var(--font-mono);
1015 font-size: 11.5px;
1016 color: var(--text-muted);
1017 padding: 4px 10px;
1018 border-radius: 9999px;
1019 background: rgba(255,255,255,0.035);
1020 border: 1px solid var(--border);
1021 font-variant-numeric: tabular-nums;
1022 }
1023 .branches-row-divergence .ahead { color: #6ee7b7; }
1024 .branches-row-divergence .behind { color: #fca5a5; }
1025 .branches-row-actions {
1026 display: flex;
1027 align-items: center;
1028 gap: 6px;
1029 }
1030 .branches-btn {
1031 display: inline-flex;
1032 align-items: center;
1033 gap: 5px;
1034 padding: 6px 12px;
1035 border-radius: 9999px;
1036 font-size: 12px;
1037 font-weight: 600;
1038 text-decoration: none;
1039 border: 1px solid var(--border);
1040 background: transparent;
1041 color: var(--text-muted);
1042 cursor: pointer;
1043 font: inherit;
1044 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1045 }
1046 .branches-btn:hover {
1047 border-color: var(--border-strong);
1048 color: var(--text);
1049 background: rgba(255,255,255,0.04);
1050 text-decoration: none;
1051 }
1052 .branches-btn-danger {
1053 color: #fca5a5;
1054 border-color: rgba(248,113,113,0.30);
1055 }
1056 .branches-btn-danger:hover {
1057 border-style: dashed;
1058 border-color: rgba(248,113,113,0.65);
1059 background: rgba(248,113,113,0.06);
1060 color: #fecaca;
1061 }
1062
1063 /* ───────── tags list ───────── */
1064 .tags-list {
1065 background: var(--bg-elevated);
1066 border: 1px solid var(--border);
1067 border-radius: 14px;
1068 overflow: hidden;
1069 position: relative;
1070 }
1071 .tags-list::before {
1072 content: '';
1073 position: absolute;
1074 top: 0; left: 0; right: 0;
1075 height: 2px;
1076 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1077 opacity: 0.55;
1078 pointer-events: none;
1079 }
1080 .tags-row {
1081 display: flex;
1082 align-items: center;
1083 gap: var(--space-3);
1084 padding: 14px 18px;
1085 border-bottom: 1px solid var(--border-subtle);
1086 transition: background 120ms ease;
1087 flex-wrap: wrap;
1088 }
1089 .tags-row:last-child { border-bottom: none; }
1090 .tags-row:hover { background: rgba(255,255,255,0.022); }
1091 .tags-row-icon {
1092 width: 32px; height: 32px;
1093 border-radius: 8px;
1094 background: rgba(54,197,214,0.10);
1095 color: #67e8f9;
1096 display: inline-flex;
1097 align-items: center;
1098 justify-content: center;
1099 flex-shrink: 0;
1100 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1101 }
1102 .tags-row-main { flex: 1; min-width: 240px; }
1103 .tags-row-name {
1104 display: inline-flex;
1105 align-items: center;
1106 gap: 8px;
1107 flex-wrap: wrap;
1108 }
1109 .tags-row-version {
1110 display: inline-flex;
1111 align-items: center;
1112 padding: 3px 11px;
1113 border-radius: 9999px;
1114 font-family: var(--font-mono);
1115 font-size: 13px;
1116 font-weight: 700;
1117 color: #67e8f9;
1118 background: rgba(54,197,214,0.12);
1119 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1120 letter-spacing: 0.01em;
1121 }
1122 .tags-row-meta {
1123 margin-top: 4px;
1124 display: flex;
1125 align-items: center;
1126 gap: 8px;
1127 flex-wrap: wrap;
1128 font-size: 12.5px;
1129 color: var(--text-muted);
1130 font-variant-numeric: tabular-nums;
1131 }
1132 .tags-row-meta .sep { opacity: 0.4; }
1133 .tags-row-sha {
1134 font-family: var(--font-mono);
1135 font-size: 11.5px;
1136 color: var(--text-strong);
1137 padding: 2px 8px;
1138 border-radius: 6px;
1139 background: rgba(255,255,255,0.04);
1140 border: 1px solid var(--border);
1141 text-decoration: none;
1142 letter-spacing: 0.04em;
1143 }
1144 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1145 .tags-row-side {
1146 display: flex;
1147 align-items: center;
1148 gap: 6px;
1149 flex-shrink: 0;
1150 flex-wrap: wrap;
1151 }
1152 .tags-row-link {
1153 display: inline-flex;
1154 align-items: center;
1155 gap: 5px;
1156 padding: 6px 12px;
1157 border-radius: 9999px;
1158 font-size: 12px;
1159 font-weight: 600;
1160 text-decoration: none;
1161 color: var(--text-muted);
1162 background: rgba(255,255,255,0.025);
1163 border: 1px solid var(--border);
1164 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1165 }
1166 .tags-row-link:hover {
1167 border-color: rgba(140,109,255,0.45);
1168 color: var(--text-strong);
1169 background: rgba(140,109,255,0.06);
1170 text-decoration: none;
efb11c5Claude1171 }
1172
1173 /* ───────── commit detail ───────── */
1174 .commit-detail-card {
1175 position: relative;
1176 margin-bottom: var(--space-5);
1177 padding: var(--space-5) var(--space-5);
1178 background: var(--bg-elevated);
1179 border: 1px solid var(--border);
1180 border-radius: 14px;
1181 overflow: hidden;
1182 }
1183 .commit-detail-eyebrow {
1184 display: flex;
1185 align-items: center;
1186 gap: var(--space-2);
1187 font-size: 12px;
1188 font-family: var(--font-mono);
1189 text-transform: uppercase;
1190 letter-spacing: 0.1em;
1191 color: var(--text-muted);
1192 margin-bottom: var(--space-2);
1193 }
1194 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1195 .commit-detail-sha-pill {
1196 font-family: var(--font-mono);
1197 font-size: 11.5px;
1198 color: var(--text-strong);
1199 background: var(--bg-secondary);
1200 border: 1px solid var(--border);
1201 border-radius: 999px;
1202 padding: 2px 10px;
1203 letter-spacing: 0.04em;
1204 }
1205 .commit-detail-verify {
1206 font-size: 10px;
1207 padding: 2px 8px;
1208 border-radius: 999px;
1209 text-transform: uppercase;
1210 letter-spacing: 0.06em;
1211 font-weight: 700;
1212 color: #fff;
1213 }
1214 .commit-detail-verify-ok {
1215 background: linear-gradient(135deg, #2ea043, #34d399);
1216 }
1217 .commit-detail-verify-warn {
1218 background: linear-gradient(135deg, #d29922, #f59e0b);
1219 }
1220 .commit-detail-title {
1221 font-family: var(--font-display);
1222 font-weight: 700;
1223 letter-spacing: -0.018em;
1224 font-size: clamp(20px, 2.5vw, 26px);
1225 line-height: 1.25;
1226 margin: 0 0 var(--space-2);
1227 color: var(--text-strong);
1228 }
1229 .commit-detail-body {
1230 white-space: pre-wrap;
1231 color: var(--text-muted);
1232 font-size: 14px;
1233 line-height: 1.55;
1234 margin: 0 0 var(--space-3);
1235 font-family: var(--font-mono);
1236 background: var(--bg);
1237 border: 1px solid var(--border);
1238 border-radius: 8px;
1239 padding: var(--space-3) var(--space-4);
1240 max-height: 280px;
1241 overflow: auto;
1242 }
1243 .commit-detail-meta {
1244 display: flex;
1245 flex-wrap: wrap;
1246 gap: var(--space-3);
1247 font-size: 13px;
1248 color: var(--text-muted);
1249 margin-bottom: var(--space-3);
1250 }
1251 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1252 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1253 .commit-detail-sha-link {
1254 font-family: var(--font-mono);
1255 font-size: 12.5px;
1256 color: var(--accent);
1257 background: rgba(140,109,255,0.08);
1258 border-radius: 6px;
1259 padding: 1px 6px;
1260 margin-left: 2px;
1261 }
1262 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1263 .commit-detail-stats {
1264 display: flex;
1265 flex-wrap: wrap;
1266 align-items: center;
1267 gap: var(--space-3);
1268 padding-top: var(--space-3);
1269 border-top: 1px solid var(--border);
1270 font-size: 13px;
1271 color: var(--text-muted);
1272 }
1273 .commit-detail-stat {
1274 display: inline-flex;
1275 align-items: center;
1276 gap: 4px;
1277 font-variant-numeric: tabular-nums;
1278 }
1279 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1280 .commit-detail-stat-add strong { color: #34d399; }
1281 .commit-detail-stat-del strong { color: #f87171; }
1282 .commit-detail-stat-mark {
1283 font-family: var(--font-mono);
1284 font-weight: 700;
1285 font-size: 14px;
1286 }
1287 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1288 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1289 .commit-detail-sha-full {
1290 margin-left: auto;
1291 font-family: var(--font-mono);
1292 font-size: 11.5px;
1293 color: var(--text-faint);
1294 letter-spacing: 0.02em;
1295 overflow: hidden;
1296 text-overflow: ellipsis;
1297 white-space: nowrap;
1298 max-width: 100%;
1299 }
1300 .commit-detail-checks {
1301 margin-top: var(--space-3);
1302 padding-top: var(--space-3);
1303 border-top: 1px solid var(--border);
1304 }
1305 .commit-detail-checks-head {
1306 display: flex;
1307 align-items: center;
1308 gap: var(--space-2);
1309 font-size: 13px;
1310 color: var(--text-muted);
1311 margin-bottom: 8px;
1312 }
1313 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1314 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1315 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1316 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1317 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1318 .commit-detail-check {
1319 font-size: 11px;
1320 padding: 2px 8px;
1321 border-radius: 999px;
1322 color: #fff;
1323 font-weight: 500;
1324 }
398a10cClaude1325 .commit-detail-check a { color: inherit; text-decoration: none; }
1326 .commit-detail-check-success { background: #2ea043; }
1327 .commit-detail-check-pending { background: #d29922; }
1328 .commit-detail-check-failure { background: #da3633; }
1329
1330 /* ───────── blame ───────── */
1331 .blame-head { margin-bottom: var(--space-5); }
1332 .blame-eyebrow {
1333 display: inline-flex;
1334 align-items: center;
1335 gap: 8px;
1336 text-transform: uppercase;
1337 font-family: var(--font-mono);
1338 font-size: 11px;
1339 letter-spacing: 0.16em;
1340 color: var(--text-muted);
1341 font-weight: 600;
1342 margin-bottom: 10px;
1343 }
1344 .blame-eyebrow-dot {
1345 width: 8px; height: 8px;
1346 border-radius: 9999px;
1347 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1348 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1349 }
1350 .blame-title {
1351 font-family: var(--font-display);
1352 font-size: clamp(22px, 3vw, 30px);
1353 font-weight: 800;
1354 letter-spacing: -0.025em;
1355 line-height: 1.15;
1356 margin: 0 0 6px;
1357 color: var(--text-strong);
1358 }
1359 .blame-title code {
1360 font-family: var(--font-mono);
1361 font-size: 0.78em;
1362 color: var(--text-strong);
1363 font-weight: 700;
1364 }
1365 .blame-sub {
1366 margin: 0;
1367 font-size: 14px;
1368 color: var(--text-muted);
1369 line-height: 1.5;
1370 max-width: 700px;
1371 }
efb11c5Claude1372 .blame-toolbar {
398a10cClaude1373 display: flex;
1374 justify-content: space-between;
1375 align-items: center;
1376 gap: var(--space-3);
efb11c5Claude1377 margin-bottom: var(--space-3);
398a10cClaude1378 flex-wrap: wrap;
efb11c5Claude1379 font-size: 13px;
1380 }
398a10cClaude1381 .blame-toolbar-actions { display: flex; gap: 6px; }
1382 .blame-card {
1383 background: var(--bg-elevated);
1384 border: 1px solid var(--border);
1385 border-radius: 14px;
1386 overflow: hidden;
1387 position: relative;
1388 }
1389 .blame-card::before {
1390 content: '';
1391 position: absolute;
1392 top: 0; left: 0; right: 0;
1393 height: 2px;
1394 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1395 opacity: 0.55;
1396 pointer-events: none;
1397 }
efb11c5Claude1398 .blame-header {
1399 display: flex;
1400 align-items: center;
1401 justify-content: space-between;
1402 gap: var(--space-3);
1403 padding: 10px 14px;
1404 background: var(--bg-secondary);
1405 border-bottom: 1px solid var(--border);
1406 }
1407 .blame-header-meta {
1408 display: flex;
1409 align-items: center;
1410 gap: var(--space-2);
1411 min-width: 0;
1412 flex-wrap: wrap;
1413 }
1414 .blame-header-icon { color: var(--accent); font-size: 14px; }
1415 .blame-header-name {
1416 font-family: var(--font-mono);
1417 font-size: 13px;
1418 color: var(--text-strong);
1419 font-weight: 600;
1420 }
1421 .blame-header-tag {
1422 font-size: 10.5px;
1423 text-transform: uppercase;
1424 letter-spacing: 0.08em;
1425 font-family: var(--font-mono);
1426 background: rgba(140,109,255,0.12);
1427 color: var(--accent);
1428 border-radius: 999px;
1429 padding: 2px 8px;
1430 font-weight: 600;
1431 }
1432 .blame-header-stats {
1433 font-family: var(--font-mono);
1434 font-size: 11.5px;
1435 color: var(--text-muted);
1436 font-variant-numeric: tabular-nums;
1437 border-left: 1px solid var(--border);
1438 padding-left: var(--space-2);
1439 }
1440 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1441 .blame-table {
1442 width: 100%;
1443 border-collapse: collapse;
1444 font-family: var(--font-mono);
1445 font-size: 12.5px;
1446 line-height: 1.6;
1447 }
1448 .blame-table tr { border-bottom: 1px solid transparent; }
1449 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1450 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1451 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1452 .blame-gutter {
1453 width: 220px;
1454 min-width: 220px;
1455 padding: 0 12px;
1456 vertical-align: top;
1457 background: rgba(255,255,255,0.012);
1458 border-right: 1px solid var(--border-subtle);
1459 font-variant-numeric: tabular-nums;
1460 color: var(--text-muted);
1461 font-size: 11px;
1462 white-space: nowrap;
1463 overflow: hidden;
1464 text-overflow: ellipsis;
1465 padding-top: 2px;
1466 padding-bottom: 2px;
1467 }
1468 .blame-gutter-inner {
1469 display: inline-flex;
1470 align-items: center;
1471 gap: 7px;
1472 max-width: 100%;
1473 overflow: hidden;
1474 }
1475 .blame-gutter-sha {
1476 font-family: var(--font-mono);
1477 font-size: 10.5px;
1478 font-weight: 600;
1479 color: #c4b5fd;
1480 background: rgba(140,109,255,0.10);
1481 padding: 1px 7px;
1482 border-radius: 9999px;
1483 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1484 text-decoration: none;
1485 letter-spacing: 0.04em;
1486 flex-shrink: 0;
1487 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1488 }
1489 .blame-gutter-sha:hover {
1490 background: rgba(140,109,255,0.22);
1491 color: #ddd6fe;
1492 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1493 text-decoration: none;
1494 }
1495 .blame-gutter-author {
1496 color: var(--text);
1497 overflow: hidden;
1498 text-overflow: ellipsis;
1499 white-space: nowrap;
1500 font-size: 11px;
1501 font-family: var(--font-sans, inherit);
1502 }
1503 .blame-line-num {
1504 width: 1%;
1505 min-width: 50px;
1506 padding: 0 12px;
1507 text-align: right;
1508 color: var(--text-faint);
1509 user-select: none;
1510 border-right: 1px solid var(--border-subtle);
1511 font-variant-numeric: tabular-nums;
1512 }
1513 .blame-line-content {
1514 padding: 0 14px;
1515 white-space: pre;
1516 color: var(--text);
1517 transition: background 120ms ease;
1518 }
efb11c5Claude1519
1520 /* ───────── search ───────── */
1521 .search-hero {
1522 position: relative;
1523 margin-bottom: var(--space-4);
1524 padding: var(--space-5) var(--space-6);
1525 background: var(--bg-elevated);
1526 border: 1px solid var(--border);
1527 border-radius: 16px;
1528 overflow: hidden;
1529 }
1530 .search-eyebrow {
1531 font-size: 12px;
1532 font-family: var(--font-mono);
1533 color: var(--text-muted);
1534 letter-spacing: 0.1em;
1535 text-transform: uppercase;
1536 margin-bottom: var(--space-2);
1537 }
1538 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1539 .search-title {
1540 font-family: var(--font-display);
1541 font-weight: 800;
1542 letter-spacing: -0.025em;
1543 font-size: clamp(22px, 3vw, 30px);
1544 line-height: 1.15;
1545 margin: 0 0 var(--space-3);
1546 color: var(--text-strong);
1547 }
1548 .search-form {
1549 display: flex;
1550 gap: var(--space-2);
1551 align-items: stretch;
1552 }
1553 .search-input-wrap {
1554 position: relative;
1555 flex: 1;
1556 display: flex;
1557 align-items: center;
1558 }
1559 .search-input-icon {
1560 position: absolute;
1561 left: 12px;
1562 color: var(--text-muted);
1563 font-size: 15px;
1564 pointer-events: none;
1565 }
1566 .search-input {
1567 appearance: none;
1568 width: 100%;
1569 padding: 10px 12px 10px 34px;
1570 background: var(--bg);
1571 border: 1px solid var(--border);
1572 border-radius: 10px;
1573 color: var(--text-strong);
1574 font-size: 14px;
1575 font-family: inherit;
1576 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1577 }
1578 .search-input:focus {
1579 outline: none;
1580 border-color: var(--accent);
1581 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1582 }
1583 .search-submit { min-width: 96px; }
1584 .search-results-head {
1585 display: flex;
1586 align-items: baseline;
1587 gap: var(--space-2);
1588 margin-bottom: var(--space-3);
1589 font-size: 13px;
1590 color: var(--text-muted);
1591 }
1592 .search-results-count strong {
1593 color: var(--text-strong);
1594 font-weight: 600;
1595 font-variant-numeric: tabular-nums;
1596 }
1597 .search-results-q { color: var(--text-strong); font-weight: 600; }
1598 .search-results-head code {
1599 font-family: var(--font-mono);
1600 font-size: 12px;
1601 background: var(--bg-secondary);
1602 border: 1px solid var(--border);
1603 border-radius: 5px;
1604 padding: 1px 6px;
1605 color: var(--text);
1606 }
1607 .search-empty {
1608 padding: var(--space-5) var(--space-6);
1609 background: var(--bg-elevated);
1610 border: 1px dashed var(--border);
1611 border-radius: 12px;
1612 color: var(--text-muted);
1613 font-size: 14px;
1614 }
1615 .search-empty strong { color: var(--text-strong); }
1616 .search-results {
1617 display: flex;
1618 flex-direction: column;
1619 gap: var(--space-3);
1620 }
1621 .search-file-head {
1622 display: flex;
1623 align-items: center;
1624 justify-content: space-between;
1625 gap: var(--space-2);
1626 }
1627 .search-file-link {
1628 font-family: var(--font-mono);
1629 font-size: 13px;
1630 color: var(--text-strong);
1631 font-weight: 600;
1632 }
1633 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1634 .search-file-count {
1635 font-family: var(--font-mono);
1636 font-size: 11.5px;
1637 color: var(--text-muted);
1638 font-variant-numeric: tabular-nums;
1639 }
1640`;
1641
fc1817aClaude1642// Home page
06d5ffeClaude1643web.get("/", async (c) => {
1644 const user = c.get("user");
1645
1646 if (user) {
0316dbbClaude1647 return c.redirect("/dashboard");
06d5ffeClaude1648 }
1649
8e9f1d9Claude1650 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1651 let publicStats: PublicStats | null = null;
8e9f1d9Claude1652 try {
1653 const [repoRow] = await db
1654 .select({ n: sql<number>`count(*)::int` })
1655 .from(repositories)
1656 .where(eq(repositories.isPrivate, false));
1657 const [userRow] = await db
1658 .select({ n: sql<number>`count(*)::int` })
1659 .from(users);
1660 stats = {
1661 publicRepos: Number(repoRow?.n ?? 0),
1662 users: Number(userRow?.n ?? 0),
1663 };
1664 } catch {
1665 stats = undefined;
1666 }
1667
52ad8b1Claude1668 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1669 try {
1670 publicStats = await computePublicStats();
1671 } catch {
1672 publicStats = null;
1673 }
1674
534f04aClaude1675 // Block M1 — initial SSR snapshot for the live-now demo feed.
1676 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1677 // in try/catch so a freak module-level explosion can't take down /.
1678 let liveFeed: LandingLiveFeed | null = null;
1679 try {
1680 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1681 listQueuedAiBuildIssues(3),
1682 listRecentAutoMerges(3, 24),
1683 listRecentAiReviews(3, 24),
1684 countAiReviewsSince(24),
1685 listDemoActivityFeed(10),
1686 ]);
1687 liveFeed = {
1688 queued: queued.map((i) => ({
1689 repo: i.repo,
1690 number: i.number,
1691 title: i.title,
1692 createdAt: i.createdAt,
1693 })),
1694 merges: merges.map((m) => ({
1695 repo: m.repo,
1696 number: m.number,
1697 title: m.title,
1698 mergedAt: m.mergedAt,
1699 })),
1700 reviews: reviewList.map((r) => ({
1701 repo: r.repo,
1702 prNumber: r.prNumber,
1703 commentSnippet: r.commentSnippet,
1704 createdAt: r.createdAt,
1705 })),
1706 reviewCount,
1707 feed: feed.map((e) => ({
1708 kind: e.kind,
1709 repo: e.repo,
1710 ref: e.ref,
1711 at: e.at,
1712 })),
1713 };
1714 } catch {
1715 liveFeed = null;
1716 }
1717
fc1817aClaude1718 return c.html(
5f2e749Claude1719 <Layout
1720 user={null}
1721 // Block L10 — SEO + Open Graph for the public landing.
1722 fullTitle="Gluecron — The git host built around Claude"
1723 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."
1724 ogTitle="Gluecron — The git host built around Claude"
1725 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."
1726 ogType="website"
1727 twitterCard="summary_large_image"
1728 >
534f04aClaude1729 <LandingPage stats={stats} publicStats={publicStats} liveFeed={liveFeed} />
fc1817aClaude1730 </Layout>
1731 );
1732});
1733
06d5ffeClaude1734// New repository form
1735web.get("/new", requireAuth, (c) => {
1736 const user = c.get("user")!;
1737 const error = c.req.query("error");
1738
1739 return c.html(
1740 <Layout title="New repository" user={user}>
efb11c5Claude1741 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1742 <div class="new-repo-hero">
1743 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1744 <div class="new-repo-hero-orb" />
1745 </div>
1746 <div class="new-repo-hero-inner">
1747 <div class="new-repo-eyebrow">
1748 <strong>Create</strong> · {user.username}
1749 </div>
1750 <h1 class="new-repo-title">
1751 Spin up a <span class="gradient-text">repository</span>.
1752 </h1>
1753 <p class="new-repo-sub">
1754 Push your first commit, and Gluecron wires up gate checks, AI review,
1755 and auto-merge from the moment your branch lands.
1756 </p>
1757 </div>
1758 </div>
06d5ffeClaude1759 <div class="new-repo-form">
efb11c5Claude1760 {error && (
1761 <div class="new-repo-error" role="alert">
1762 {decodeURIComponent(error)}
1763 </div>
1764 )}
1765 <form method="post" action="/new" class="new-repo-form-grid">
1766 <div class="new-repo-row">
1767 <label class="new-repo-label">Owner</label>
1768 <input
1769 type="text"
1770 value={user.username}
1771 disabled
1772 aria-label="Owner"
1773 class="new-repo-input new-repo-input-disabled"
1774 />
06d5ffeClaude1775 </div>
efb11c5Claude1776 <div class="new-repo-row">
1777 <label class="new-repo-label" for="name">
1778 Repository name
1779 </label>
06d5ffeClaude1780 <input
1781 type="text"
1782 id="name"
1783 name="name"
1784 required
1785 pattern="^[a-zA-Z0-9._-]+$"
1786 placeholder="my-project"
1787 autocomplete="off"
efb11c5Claude1788 class="new-repo-input"
06d5ffeClaude1789 />
efb11c5Claude1790 <p class="new-repo-hint">
1791 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1792 <code>{user.username}/&lt;name&gt;</code>.
1793 </p>
06d5ffeClaude1794 </div>
efb11c5Claude1795 <div class="new-repo-row">
1796 <label class="new-repo-label" for="description">
1797 Description{" "}
1798 <span class="new-repo-label-optional">(optional)</span>
1799 </label>
06d5ffeClaude1800 <input
1801 type="text"
1802 id="description"
1803 name="description"
1804 placeholder="A short description of your repository"
efb11c5Claude1805 class="new-repo-input"
06d5ffeClaude1806 />
1807 </div>
efb11c5Claude1808 <div class="new-repo-row">
1809 <span class="new-repo-label">Visibility</span>
1810 <div class="new-repo-visibility">
1811 <label class="new-repo-vis-card">
1812 <input
1813 type="radio"
1814 name="visibility"
1815 value="public"
1816 checked
1817 class="new-repo-vis-radio"
1818 />
1819 <span class="new-repo-vis-body">
1820 <span class="new-repo-vis-label">Public</span>
1821 <span class="new-repo-vis-desc">
1822 Anyone can see this repository. You choose who can commit.
1823 </span>
1824 </span>
1825 </label>
1826 <label class="new-repo-vis-card">
1827 <input
1828 type="radio"
1829 name="visibility"
1830 value="private"
1831 class="new-repo-vis-radio"
1832 />
1833 <span class="new-repo-vis-body">
1834 <span class="new-repo-vis-label">Private</span>
1835 <span class="new-repo-vis-desc">
1836 Only you (and collaborators you invite) can see this
1837 repository.
1838 </span>
1839 </span>
1840 </label>
1841 </div>
1842 </div>
398a10cClaude1843 <div class="new-repo-row">
1844 <span class="new-repo-label">
1845 Starter content{" "}
1846 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1847 </span>
1848 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1849 <label class="new-repo-template-chip">
1850 <input type="radio" name="starter" value="empty" checked />
1851 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1852 Empty
1853 </label>
1854 <label class="new-repo-template-chip">
1855 <input type="radio" name="starter" value="readme" />
1856 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1857 README
1858 </label>
1859 <label class="new-repo-template-chip">
1860 <input type="radio" name="starter" value="readme-mit" />
1861 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1862 README + MIT
1863 </label>
1864 <label class="new-repo-template-chip">
1865 <input type="radio" name="starter" value="node" />
1866 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1867 Node + .gitignore
1868 </label>
1869 </div>
1870 <p class="new-repo-hint">
1871 Just a UI hint — push your own commits to fill the repo.
1872 </p>
1873 </div>
efb11c5Claude1874 <div class="new-repo-callout">
1875 <div class="new-repo-callout-eyebrow">AI-native by default</div>
1876 <p class="new-repo-callout-body">
1877 Every push is gate-checked and reviewed by Claude automatically.
1878 Label an issue <code>ai-build</code> and Gluecron will open the PR
1879 for you.
1880 </p>
1881 </div>
1882 <div class="new-repo-actions">
398a10cClaude1883 <button type="submit" class="btn new-repo-submit">
efb11c5Claude1884 Create repository
1885 </button>
1886 <a href="/dashboard" class="btn new-repo-cancel">
1887 Cancel
1888 </a>
06d5ffeClaude1889 </div>
1890 </form>
1891 </div>
1892 </Layout>
1893 );
1894});
1895
1896web.post("/new", requireAuth, async (c) => {
1897 const user = c.get("user")!;
1898 const body = await c.req.parseBody();
1899 const name = String(body.name || "").trim();
1900 const description = String(body.description || "").trim();
1901 const isPrivate = body.visibility === "private";
1902
1903 if (!name) {
1904 return c.redirect("/new?error=Repository+name+is+required");
1905 }
1906
c63b860Claude1907 // P4 — plan-quota gate. Fail-open inside the helper so a billing
1908 // outage never blocks repo creation.
1909 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
1910 const gate = await checkRepoCreateAllowed(user.id);
1911 if (!gate.ok) {
1912 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
1913 }
1914
06d5ffeClaude1915 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
1916 return c.redirect("/new?error=Invalid+repository+name");
1917 }
1918
1919 if (await repoExists(user.username, name)) {
1920 return c.redirect("/new?error=Repository+already+exists");
1921 }
1922
1923 const diskPath = await initBareRepo(user.username, name);
1924
3ef4c9dClaude1925 const [newRepo] = await db
1926 .insert(repositories)
1927 .values({
1928 name,
1929 ownerId: user.id,
1930 description: description || null,
1931 isPrivate,
1932 diskPath,
1933 })
1934 .returning();
1935
1936 if (newRepo) {
1937 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
1938 await bootstrapRepository({
1939 repositoryId: newRepo.id,
1940 ownerUserId: user.id,
1941 defaultBranch: "main",
1942 });
1943 }
06d5ffeClaude1944
1945 return c.redirect(`/${user.username}/${name}`);
1946});
1947
1948// User profile
fc1817aClaude1949web.get("/:owner", async (c) => {
06d5ffeClaude1950 const { owner: ownerName } = c.req.param();
1951 const user = c.get("user");
1952
1953 // Avoid clashing with fixed routes
1954 if (
1955 ["login", "register", "logout", "new", "settings", "api"].includes(
1956 ownerName
1957 )
1958 ) {
1959 return c.notFound();
1960 }
1961
1962 let ownerUser;
1963 try {
1964 const [found] = await db
1965 .select()
1966 .from(users)
1967 .where(eq(users.username, ownerName))
1968 .limit(1);
1969 ownerUser = found;
1970 } catch {
1971 // DB not available — check if repos exist on disk
1972 ownerUser = null;
1973 }
1974
1975 // Even without DB, show repos if they exist on disk
1976 let repos: any[] = [];
1977 if (ownerUser) {
1978 const allRepos = await db
1979 .select()
1980 .from(repositories)
1981 .where(eq(repositories.ownerId, ownerUser.id))
1982 .orderBy(desc(repositories.updatedAt));
1983
1984 // Show public repos to everyone, private only to owner
1985 repos =
1986 user?.id === ownerUser.id
1987 ? allRepos
1988 : allRepos.filter((r) => !r.isPrivate);
1989 }
1990
7aa8b99Claude1991 // Block J4 — follow counts + viewer's follow state
1992 let followState = {
1993 followers: 0,
1994 following: 0,
1995 viewerFollows: false,
1996 };
1997 if (ownerUser) {
1998 try {
1999 const { followCounts, isFollowing } = await import("../lib/follows");
2000 const counts = await followCounts(ownerUser.id);
2001 followState.followers = counts.followers;
2002 followState.following = counts.following;
2003 if (user && user.id !== ownerUser.id) {
2004 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2005 }
2006 } catch {
2007 // DB hiccup — fall back to zeros.
2008 }
2009 }
2010 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2011
d412586Claude2012 // Block J5 — profile README. Render owner/owner repo's README on the
2013 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2014 // back to "<user>/.github" for org-style profile repos.
2015 let profileReadmeHtml: string | null = null;
2016 try {
2017 const candidates = [ownerName, ".github"];
2018 for (const rname of candidates) {
2019 if (await repoExists(ownerName, rname)) {
2020 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2021 const md = await getReadme(ownerName, rname, ref);
2022 if (md) {
2023 profileReadmeHtml = renderMarkdown(md);
2024 break;
2025 }
2026 }
2027 }
2028 } catch {
2029 profileReadmeHtml = null;
2030 }
2031
efb11c5Claude2032 const displayName = ownerUser?.displayName || ownerName;
2033 const memberSince = ownerUser?.createdAt
2034 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2035 : null;
2036
fc1817aClaude2037 return c.html(
06d5ffeClaude2038 <Layout title={ownerName} user={user}>
efb11c5Claude2039 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2040 <div class="profile-hero">
2041 <div class="profile-hero-orb-wrap" aria-hidden="true">
2042 <div class="profile-hero-orb" />
06d5ffeClaude2043 </div>
efb11c5Claude2044 <div class="profile-hero-inner">
2045 <div class="profile-hero-avatar" aria-hidden="true">
2046 {displayName[0].toUpperCase()}
2047 </div>
2048 <div class="profile-hero-text">
2049 <div class="profile-eyebrow">
2050 <strong>Developer</strong>
2051 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2052 <>
2053 {" "}· Joined{" "}
2054 {memberSince.toLocaleDateString("en-US", {
2055 month: "short",
2056 year: "numeric",
2057 })}
2058 </>
2059 )}
2060 </div>
2061 <h1 class="profile-name">
2062 <span class="gradient-text">{displayName}</span>
2063 </h1>
2064 <div class="profile-handle">@{ownerName}</div>
2065 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2066 <div class="profile-meta">
2067 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2068 <strong>{followState.followers}</strong> follower
2069 {followState.followers === 1 ? "" : "s"}
2070 </a>
2071 <a href={`/${ownerName}/following`} class="profile-meta-link">
2072 <strong>{followState.following}</strong> following
2073 </a>
2074 <a href={`/${ownerName}`} class="profile-meta-link">
2075 <strong>{repos.length}</strong> repo
2076 {repos.length === 1 ? "" : "s"}
2077 </a>
2078 {canFollow && (
2079 <form
2080 method="post"
2081 action={`/${ownerName}/${
2082 followState.viewerFollows ? "unfollow" : "follow"
2083 }`}
2084 class="profile-follow-form"
7aa8b99Claude2085 >
efb11c5Claude2086 <button
2087 type="submit"
2088 class={`btn ${
2089 followState.viewerFollows ? "" : "btn-primary"
2090 } btn-sm`}
2091 >
2092 {followState.viewerFollows ? "Unfollow" : "Follow"}
2093 </button>
2094 </form>
2095 )}
2096 </div>
7aa8b99Claude2097 </div>
06d5ffeClaude2098 </div>
2099 </div>
d412586Claude2100 {profileReadmeHtml && (
efb11c5Claude2101 <div class="profile-readme">
2102 <div class="profile-readme-head">
2103 <span class="profile-readme-icon">{"☰"}</span>
2104 <span>{ownerName}/{ownerName} README.md</span>
2105 </div>
2106 <div
2107 class="markdown-body profile-readme-body"
2108 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2109 />
2110 </div>
d412586Claude2111 )}
efb11c5Claude2112 <div class="profile-section-head">
2113 <h2 class="profile-section-title">Repositories</h2>
2114 <span class="profile-section-count">{repos.length}</span>
2115 </div>
06d5ffeClaude2116 {repos.length === 0 ? (
efb11c5Claude2117 <div class="profile-empty">
2118 <p class="profile-empty-text">
2119 No repositories yet
2120 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2121 </p>
2122 {user?.id === ownerUser?.id && (
2123 <a href="/new" class="btn btn-primary btn-sm">
2124 + Create your first
2125 </a>
2126 )}
2127 </div>
06d5ffeClaude2128 ) : (
2129 <div class="card-grid">
2130 {repos.map((repo) => (
2131 <RepoCard repo={repo} ownerName={ownerName} />
2132 ))}
2133 </div>
2134 )}
fc1817aClaude2135 </Layout>
2136 );
2137});
2138
06d5ffeClaude2139// Star/unstar a repo
2140web.post("/:owner/:repo/star", requireAuth, async (c) => {
2141 const { owner: ownerName, repo: repoName } = c.req.param();
2142 const user = c.get("user")!;
2143
2144 try {
2145 const [ownerUser] = await db
2146 .select()
2147 .from(users)
2148 .where(eq(users.username, ownerName))
2149 .limit(1);
2150 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2151
2152 const [repo] = await db
2153 .select()
2154 .from(repositories)
2155 .where(
2156 and(
2157 eq(repositories.ownerId, ownerUser.id),
2158 eq(repositories.name, repoName)
2159 )
2160 )
2161 .limit(1);
2162 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2163
2164 // Toggle star
2165 const [existing] = await db
2166 .select()
2167 .from(stars)
2168 .where(
2169 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2170 )
2171 .limit(1);
2172
2173 if (existing) {
2174 await db.delete(stars).where(eq(stars.id, existing.id));
2175 await db
2176 .update(repositories)
2177 .set({ starCount: Math.max(0, repo.starCount - 1) })
2178 .where(eq(repositories.id, repo.id));
2179 } else {
2180 await db.insert(stars).values({
2181 userId: user.id,
2182 repositoryId: repo.id,
2183 });
2184 await db
2185 .update(repositories)
2186 .set({ starCount: repo.starCount + 1 })
2187 .where(eq(repositories.id, repo.id));
2188 }
2189 } catch {
2190 // DB error — ignore
2191 }
2192
2193 return c.redirect(`/${ownerName}/${repoName}`);
2194});
2195
fc1817aClaude2196// Repository overview — file tree at HEAD
2197web.get("/:owner/:repo", async (c) => {
2198 const { owner, repo } = c.req.param();
06d5ffeClaude2199 const user = c.get("user");
fc1817aClaude2200
f1dc7c7Claude2201 // ── Loading skeleton (flag-gated) ──
2202 // Renders an SSR'd shell with file-tree + README placeholders when
2203 // `?skeleton=1` is set. Lets the user see the page structure before
2204 // git ops finish. Behind a flag for now so we never flash before the
2205 // real content lands.
2206 if (c.req.query("skeleton") === "1") {
2207 return c.html(
2208 <Layout title={`${owner}/${repo}`} user={user}>
2209 <style
2210 dangerouslySetInnerHTML={{
2211 __html: `
2212 .repo-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: repoSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
2213 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2214 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2215 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2216 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2217 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2218 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2219 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2220 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2221 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2222 .repo-skel-readme { height: 320px; border-radius: 12px; }
2223 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2224 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2225 `,
2226 }}
2227 />
2228 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2229 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2230 <div class="repo-skel-grid" aria-hidden="true">
2231 <div>
2232 <div class="repo-skel repo-skel-branch" />
2233 <div class="repo-skel-tree">
2234 {Array.from({ length: 8 }).map(() => (
2235 <div class="repo-skel repo-skel-tree-row" />
2236 ))}
2237 </div>
2238 <div class="repo-skel repo-skel-readme" />
2239 </div>
2240 <aside class="repo-skel-side">
2241 <div class="repo-skel repo-skel-side-card" />
2242 <div class="repo-skel repo-skel-side-card" />
2243 </aside>
2244 </div>
2245 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
2246 Loading {owner}/{repo}…
2247 </span>
2248 </Layout>
2249 );
2250 }
2251
8f50ed0Claude2252 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2253 trackByName(owner, repo, "view", {
2254 userId: user?.id || null,
2255 path: `/${owner}/${repo}`,
2256 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2257 userAgent: c.req.header("user-agent") || null,
2258 referer: c.req.header("referer") || null,
a28cedeClaude2259 }).catch((err) => {
2260 console.warn(
2261 `[web] view tracking failed for ${owner}/${repo}:`,
2262 err instanceof Error ? err.message : err
2263 );
2264 });
8f50ed0Claude2265
fc1817aClaude2266 if (!(await repoExists(owner, repo))) {
2267 return c.html(
06d5ffeClaude2268 <Layout title="Not Found" user={user}>
fc1817aClaude2269 <div class="empty-state">
2270 <h2>Repository not found</h2>
2271 <p>
2272 {owner}/{repo} does not exist.
2273 </p>
2274 </div>
2275 </Layout>,
2276 404
2277 );
2278 }
2279
05b973eClaude2280 // Parallelize all independent operations
2281 const [defaultBranch, branches] = await Promise.all([
2282 getDefaultBranch(owner, repo).then((b) => b || "main"),
2283 listBranches(owner, repo),
2284 ]);
2285 const [tree, starInfo] = await Promise.all([
2286 getTree(owner, repo, defaultBranch),
2287 // Star info fetched in parallel with tree
2288 (async () => {
2289 try {
2290 const [ownerUser] = await db
2291 .select()
2292 .from(users)
2293 .where(eq(users.username, owner))
2294 .limit(1);
71cd5ecClaude2295 if (!ownerUser)
2296 return {
2297 starCount: 0,
2298 starred: false,
2299 archived: false,
2300 isTemplate: false,
544d842Claude2301 forkCount: 0,
2302 description: null as string | null,
2303 pushedAt: null as Date | null,
2304 createdAt: null as Date | null,
71cd5ecClaude2305 };
05b973eClaude2306 const [repoRow] = await db
2307 .select()
2308 .from(repositories)
2309 .where(
2310 and(
2311 eq(repositories.ownerId, ownerUser.id),
2312 eq(repositories.name, repo)
2313 )
06d5ffeClaude2314 )
05b973eClaude2315 .limit(1);
71cd5ecClaude2316 if (!repoRow)
2317 return {
2318 starCount: 0,
2319 starred: false,
2320 archived: false,
2321 isTemplate: false,
544d842Claude2322 forkCount: 0,
2323 description: null as string | null,
2324 pushedAt: null as Date | null,
2325 createdAt: null as Date | null,
71cd5ecClaude2326 };
05b973eClaude2327 let starred = false;
06d5ffeClaude2328 if (user) {
2329 const [star] = await db
2330 .select()
2331 .from(stars)
2332 .where(
2333 and(
2334 eq(stars.userId, user.id),
2335 eq(stars.repositoryId, repoRow.id)
2336 )
2337 )
2338 .limit(1);
2339 starred = !!star;
2340 }
71cd5ecClaude2341 return {
2342 starCount: repoRow.starCount,
2343 starred,
2344 archived: repoRow.isArchived,
2345 isTemplate: repoRow.isTemplate,
544d842Claude2346 forkCount: repoRow.forkCount,
2347 description: repoRow.description as string | null,
2348 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2349 createdAt: (repoRow.createdAt as Date | null) ?? null,
71cd5ecClaude2350 };
05b973eClaude2351 } catch {
71cd5ecClaude2352 return {
2353 starCount: 0,
2354 starred: false,
2355 archived: false,
2356 isTemplate: false,
544d842Claude2357 forkCount: 0,
2358 description: null as string | null,
2359 pushedAt: null as Date | null,
2360 createdAt: null as Date | null,
71cd5ecClaude2361 };
06d5ffeClaude2362 }
05b973eClaude2363 })(),
2364 ]);
544d842Claude2365 const {
2366 starCount,
2367 starred,
2368 archived,
2369 isTemplate,
2370 forkCount,
2371 description,
2372 pushedAt,
2373 createdAt,
2374 } = starInfo;
2375
2376 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2377 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2378 const repoHomeCss = `
2379 .repo-home-hero {
2380 position: relative;
2381 margin-bottom: var(--space-5);
2382 padding: var(--space-5) var(--space-6);
2383 background: var(--bg-elevated);
2384 border: 1px solid var(--border);
2385 border-radius: 16px;
2386 overflow: hidden;
2387 }
2388 .repo-home-hero::before {
2389 content: '';
2390 position: absolute;
2391 top: 0; left: 0; right: 0;
2392 height: 2px;
2393 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2394 opacity: 0.7;
2395 pointer-events: none;
2396 }
2397 .repo-home-hero-orb-wrap {
2398 position: absolute;
2399 inset: -25% -10% auto auto;
2400 width: 360px;
2401 height: 360px;
2402 pointer-events: none;
2403 z-index: 0;
2404 }
2405 .repo-home-hero-orb {
2406 position: absolute;
2407 inset: 0;
2408 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
2409 filter: blur(80px);
2410 opacity: 0.7;
2411 animation: repoHomeOrb 14s ease-in-out infinite;
2412 }
2413 @keyframes repoHomeOrb {
2414 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
2415 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
2416 }
2417 @media (prefers-reduced-motion: reduce) {
2418 .repo-home-hero-orb { animation: none; }
2419 }
2420 .repo-home-hero-inner {
2421 position: relative;
2422 z-index: 1;
2423 }
2424 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
2425 .repo-home-eyebrow {
2426 font-size: 12px;
2427 font-family: var(--font-mono);
2428 color: var(--text-muted);
2429 letter-spacing: 0.1em;
2430 text-transform: uppercase;
2431 margin-bottom: var(--space-2);
2432 }
2433 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
2434 .repo-home-description {
2435 font-size: 15px;
2436 line-height: 1.55;
2437 color: var(--text);
2438 margin: 0;
2439 max-width: 720px;
2440 }
2441 .repo-home-description-empty {
2442 font-size: 14px;
2443 color: var(--text-muted);
2444 font-style: italic;
2445 margin: 0;
2446 }
2447 .repo-home-stat-row {
2448 display: flex;
2449 flex-wrap: wrap;
2450 gap: var(--space-4);
2451 margin-top: var(--space-3);
2452 font-size: 13px;
2453 color: var(--text-muted);
2454 }
2455 .repo-home-stat {
2456 display: inline-flex;
2457 align-items: center;
2458 gap: 6px;
2459 }
2460 .repo-home-stat strong {
2461 color: var(--text-strong);
2462 font-weight: 600;
2463 font-variant-numeric: tabular-nums;
2464 }
2465 .repo-home-stat .repo-home-stat-icon {
2466 color: var(--text-faint);
2467 font-size: 14px;
2468 line-height: 1;
2469 }
2470 .repo-home-stat a {
2471 color: var(--text-muted);
2472 transition: color var(--t-fast) var(--ease);
2473 }
2474 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
2475
2476 /* Two-column layout: file tree + sidebar */
2477 .repo-home-grid {
2478 display: grid;
2479 grid-template-columns: minmax(0, 1fr) 280px;
2480 gap: var(--space-5);
2481 align-items: start;
2482 }
2483 @media (max-width: 960px) {
2484 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
2485 }
2486 .repo-home-main { min-width: 0; }
2487
2488 /* Sidebar card */
2489 .repo-home-side {
2490 display: flex;
2491 flex-direction: column;
2492 gap: var(--space-4);
2493 }
2494 .repo-home-side-card {
2495 background: var(--bg-elevated);
2496 border: 1px solid var(--border);
2497 border-radius: 12px;
2498 padding: var(--space-4);
2499 }
2500 .repo-home-side-title {
2501 font-size: 11px;
2502 font-family: var(--font-mono);
2503 letter-spacing: 0.12em;
2504 text-transform: uppercase;
2505 color: var(--text-muted);
2506 margin: 0 0 var(--space-3);
2507 font-weight: 600;
2508 }
2509 .repo-home-side-row {
2510 display: flex;
2511 justify-content: space-between;
2512 align-items: center;
2513 gap: var(--space-2);
2514 font-size: 13px;
2515 padding: 6px 0;
2516 border-top: 1px solid var(--border);
2517 }
2518 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
2519 .repo-home-side-key {
2520 color: var(--text-muted);
2521 display: inline-flex;
2522 align-items: center;
2523 gap: 6px;
2524 }
2525 .repo-home-side-val {
2526 color: var(--text-strong);
2527 font-weight: 500;
2528 font-variant-numeric: tabular-nums;
2529 max-width: 60%;
2530 text-align: right;
2531 overflow: hidden;
2532 text-overflow: ellipsis;
2533 white-space: nowrap;
2534 }
2535 .repo-home-side-val a { color: var(--text-strong); }
2536 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
2537
2538 /* Clone / Code tabs */
2539 .repo-home-clone {
2540 background: var(--bg-elevated);
2541 border: 1px solid var(--border);
2542 border-radius: 12px;
2543 overflow: hidden;
2544 }
2545 .repo-home-clone-tabs {
2546 display: flex;
2547 gap: 0;
2548 background: var(--bg-secondary);
2549 border-bottom: 1px solid var(--border);
2550 padding: 0 var(--space-2);
2551 }
2552 .repo-home-clone-tab {
2553 appearance: none;
2554 background: transparent;
2555 border: 0;
2556 border-bottom: 2px solid transparent;
2557 padding: 9px 12px;
2558 font-size: 12px;
2559 font-weight: 500;
2560 color: var(--text-muted);
2561 cursor: pointer;
2562 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2563 font-family: inherit;
2564 margin-bottom: -1px;
2565 }
2566 .repo-home-clone-tab:hover { color: var(--text-strong); }
2567 .repo-home-clone-tab[aria-selected="true"] {
2568 color: var(--text-strong);
2569 border-bottom-color: var(--accent);
2570 }
2571 .repo-home-clone-body {
2572 padding: var(--space-3);
2573 display: flex;
2574 align-items: center;
2575 gap: var(--space-2);
2576 }
2577 .repo-home-clone-input {
2578 flex: 1;
2579 min-width: 0;
2580 font-family: var(--font-mono);
2581 font-size: 12px;
2582 background: var(--bg);
2583 border: 1px solid var(--border);
2584 border-radius: 8px;
2585 padding: 8px 10px;
2586 color: var(--text-strong);
2587 overflow: hidden;
2588 text-overflow: ellipsis;
2589 white-space: nowrap;
2590 }
2591 .repo-home-clone-copy {
2592 appearance: none;
2593 background: var(--bg-secondary);
2594 border: 1px solid var(--border);
2595 color: var(--text-strong);
2596 border-radius: 8px;
2597 padding: 8px 12px;
2598 font-size: 12px;
2599 font-weight: 600;
2600 cursor: pointer;
2601 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2602 font-family: inherit;
2603 }
2604 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
2605 .repo-home-clone-pane { display: none; }
2606 .repo-home-clone-pane[data-active="true"] { display: flex; }
2607
2608 /* README card */
2609 .repo-home-readme {
2610 margin-top: var(--space-5);
2611 background: var(--bg-elevated);
2612 border: 1px solid var(--border);
2613 border-radius: 12px;
2614 overflow: hidden;
2615 }
2616 .repo-home-readme-head {
2617 display: flex;
2618 align-items: center;
2619 gap: 8px;
2620 padding: 10px 16px;
2621 background: var(--bg-secondary);
2622 border-bottom: 1px solid var(--border);
2623 font-size: 13px;
2624 color: var(--text-muted);
2625 }
2626 .repo-home-readme-head .repo-home-readme-icon {
2627 color: var(--accent);
2628 font-size: 14px;
2629 }
2630 .repo-home-readme-body {
2631 padding: var(--space-5) var(--space-6);
2632 }
2633
2634 /* Empty-state CTA */
2635 .repo-home-empty {
2636 position: relative;
2637 margin-top: var(--space-4);
2638 background: var(--bg-elevated);
2639 border: 1px solid var(--border);
2640 border-radius: 16px;
2641 padding: var(--space-6);
2642 overflow: hidden;
2643 }
2644 .repo-home-empty::before {
2645 content: '';
2646 position: absolute;
2647 top: 0; left: 0; right: 0;
2648 height: 2px;
2649 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2650 opacity: 0.7;
2651 pointer-events: none;
2652 }
2653 .repo-home-empty-eyebrow {
2654 font-size: 12px;
2655 font-family: var(--font-mono);
2656 color: var(--accent);
2657 letter-spacing: 0.12em;
2658 text-transform: uppercase;
2659 margin-bottom: var(--space-2);
2660 font-weight: 600;
2661 }
2662 .repo-home-empty-title {
2663 font-family: var(--font-display);
2664 font-weight: 800;
2665 letter-spacing: -0.025em;
2666 font-size: clamp(22px, 3vw, 30px);
2667 line-height: 1.1;
2668 margin: 0 0 var(--space-2);
2669 color: var(--text-strong);
2670 }
2671 .repo-home-empty-sub {
2672 color: var(--text-muted);
2673 font-size: 14px;
2674 line-height: 1.55;
2675 max-width: 640px;
2676 margin: 0 0 var(--space-4);
2677 }
2678 .repo-home-empty-snippet {
2679 background: var(--bg);
2680 border: 1px solid var(--border);
2681 border-radius: 10px;
2682 padding: var(--space-3) var(--space-4);
2683 font-family: var(--font-mono);
2684 font-size: 12.5px;
2685 line-height: 1.7;
2686 color: var(--text-strong);
2687 overflow-x: auto;
2688 white-space: pre;
2689 margin: 0;
2690 }
2691 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2692 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2693
2694 @media (max-width: 720px) {
2695 .repo-home-hero { padding: var(--space-4) var(--space-4); }
2696 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
2697 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude2698 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
2699 .repo-home-side-val { max-width: 55%; }
2700 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
2701 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
2702 .repo-home-side-card { padding: var(--space-3); }
544d842Claude2703 }
2704 `;
2705 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
2706 // Best-effort SSH URL. If APP_BASE_URL is a hostname, this looks right; for
2707 // localhost it'll still render and just be slightly silly (which is fine for dev).
2708 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
2709 try {
2710 const host = new URL(config.appBaseUrl).hostname;
2711 if (host && host !== "localhost" && host !== "127.0.0.1") {
2712 cloneSshUrl = `git@${host}:${owner}/${repo}.git`;
2713 }
2714 } catch {
2715 // Fall through to default.
2716 }
2717 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
2718 const formatRelative = (date: Date | null): string => {
2719 if (!date) return "never";
2720 const ms = Date.now() - date.getTime();
2721 const s = Math.max(0, Math.round(ms / 1000));
2722 if (s < 60) return "just now";
2723 const m = Math.round(s / 60);
2724 if (m < 60) return `${m} min ago`;
2725 const h = Math.round(m / 60);
2726 if (h < 24) return `${h}h ago`;
2727 const d = Math.round(h / 24);
2728 if (d < 30) return `${d}d ago`;
2729 const mo = Math.round(d / 30);
2730 if (mo < 12) return `${mo}mo ago`;
2731 const y = Math.round(d / 365);
2732 return `${y}y ago`;
2733 };
06d5ffeClaude2734
fc1817aClaude2735 if (tree.length === 0) {
2736 return c.html(
06d5ffeClaude2737 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude2738 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2739 <div class="repo-home-hero">
2740 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2741 <div class="repo-home-hero-orb" />
2742 </div>
2743 <div class="repo-home-hero-inner">
2744 <div class="repo-home-eyebrow">
2745 <strong>Repository</strong> · {owner}
2746 </div>
2747 <RepoHeader
2748 owner={owner}
2749 repo={repo}
2750 starCount={starCount}
2751 starred={starred}
2752 forkCount={forkCount}
2753 currentUser={user?.username}
2754 archived={archived}
2755 isTemplate={isTemplate}
2756 />
2757 {description ? (
2758 <p class="repo-home-description">{description}</p>
2759 ) : (
2760 <p class="repo-home-description-empty">
2761 No description yet — push a README to tell the world what this
2762 ships.
2763 </p>
2764 )}
2765 </div>
2766 </div>
fc1817aClaude2767 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude2768 <div class="repo-home-empty">
2769 <div class="repo-home-empty-eyebrow">Getting started</div>
2770 <h2 class="repo-home-empty-title">
2771 Push your first commit to{" "}
2772 <span class="gradient-text">{repo}</span>.
2773 </h2>
2774 <p class="repo-home-empty-sub">
2775 This repository is empty. Paste the snippet below in an existing
2776 project directory to wire it up to Gluecron — your push triggers
2777 gate checks and AI review automatically.
2778 </p>
2779 <pre class="repo-home-empty-snippet">
2780 <span class="repo-home-cmt">
2781 # from an existing project directory
2782 </span>
2783 {"\n"}
2784 <span class="repo-home-cmd">git remote add</span>
2785 {` origin ${cloneHttpsUrl}`}
2786 {"\n"}
2787 <span class="repo-home-cmd">git branch</span>
2788 {` -M main`}
2789 {"\n"}
2790 <span class="repo-home-cmd">git push</span>
2791 {` -u origin main`}
2792 </pre>
fc1817aClaude2793 </div>
2794 </Layout>
2795 );
2796 }
2797
2798 const readme = await getReadme(owner, repo, defaultBranch);
2799
544d842Claude2800 // Sidebar facts — derived from data we already have.
2801 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
2802 const dirCount = tree.filter((e: any) => e.type === "tree").length;
2803
fc1817aClaude2804 return c.html(
06d5ffeClaude2805 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude2806 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
2807 <div class="repo-home-hero">
2808 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
2809 <div class="repo-home-hero-orb" />
2810 </div>
2811 <div class="repo-home-hero-inner">
2812 <div class="repo-home-eyebrow">
2813 <strong>Repository</strong> · {owner}
2814 </div>
2815 <RepoHeader
2816 owner={owner}
2817 repo={repo}
2818 starCount={starCount}
2819 starred={starred}
2820 forkCount={forkCount}
2821 currentUser={user?.username}
2822 archived={archived}
2823 isTemplate={isTemplate}
2824 />
2825 {description ? (
2826 <p class="repo-home-description">{description}</p>
2827 ) : (
2828 <p class="repo-home-description-empty">
2829 No description yet.
2830 </p>
2831 )}
2832 <div class="repo-home-stat-row" aria-label="Repository stats">
2833 <span class="repo-home-stat" title="Default branch">
2834 <span class="repo-home-stat-icon">{"⎇"}</span>
2835 <strong>{defaultBranch}</strong>
2836 </span>
2837 <a
2838 href={`/${owner}/${repo}/commits/${defaultBranch}`}
2839 class="repo-home-stat"
2840 title="Browse all branches"
2841 >
2842 <span class="repo-home-stat-icon">{"⊢"}</span>
2843 <strong>{branches.length}</strong>{" "}
2844 branch{branches.length === 1 ? "" : "es"}
2845 </a>
2846 <span class="repo-home-stat" title="Top-level entries">
2847 <span class="repo-home-stat-icon">{"■"}</span>
2848 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
2849 {dirCount > 0 && (
2850 <>
2851 {" · "}
2852 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
2853 </>
2854 )}
2855 </span>
2856 {pushedAt && (
2857 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
2858 <span class="repo-home-stat-icon">{"↻"}</span>
2859 Updated <strong>{formatRelative(pushedAt)}</strong>
2860 </span>
2861 )}
2862 </div>
2863 </div>
2864 </div>
71cd5ecClaude2865 {isTemplate && user && user.username !== owner && (
2866 <div
2867 class="panel"
dc26881CC LABS App2868 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude2869 >
2870 <div style="font-size:13px">
2871 <strong>Template repository.</strong> Create a new repository from
2872 this template's files.
2873 </div>
2874 <form
001af43Claude2875 method="post"
71cd5ecClaude2876 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App2877 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude2878 >
2879 <input
2880 type="text"
2881 name="name"
2882 placeholder="new-repo-name"
2883 required
2c3ba6ecopilot-swe-agent[bot]2884 aria-label="New repository name"
71cd5ecClaude2885 style="width:200px"
2886 />
2887 <button type="submit" class="btn btn-primary">
2888 Use this template
2889 </button>
2890 </form>
2891 </div>
2892 )}
fc1817aClaude2893 <RepoNav owner={owner} repo={repo} active="code" />
c6018a5Claude2894 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
2895 row sits just below the nav as a slim CTA strip. Scoped under
2896 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
2897 <style
2898 dangerouslySetInnerHTML={{
2899 __html: `
2900 .repo-ai-cta-row {
2901 display: flex;
2902 flex-wrap: wrap;
2903 gap: 8px;
2904 margin: 12px 0 18px;
2905 padding: 10px 14px;
2906 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
2907 border: 1px solid var(--border);
2908 border-radius: 10px;
2909 position: relative;
2910 overflow: hidden;
2911 }
2912 .repo-ai-cta-row::before {
2913 content: '';
2914 position: absolute;
2915 top: 0; left: 0; right: 0;
2916 height: 1px;
2917 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
2918 opacity: 0.7;
2919 pointer-events: none;
2920 }
2921 .repo-ai-cta-label {
2922 font-size: 11px;
2923 font-weight: 600;
2924 letter-spacing: 0.06em;
2925 text-transform: uppercase;
2926 color: var(--accent);
2927 align-self: center;
2928 padding-right: 8px;
2929 border-right: 1px solid var(--border);
2930 margin-right: 4px;
2931 }
2932 .repo-ai-cta {
2933 display: inline-flex;
2934 align-items: center;
2935 gap: 6px;
2936 padding: 5px 10px;
2937 font-size: 12.5px;
2938 font-weight: 500;
2939 color: var(--text);
2940 background: var(--bg);
2941 border: 1px solid var(--border);
2942 border-radius: 7px;
2943 text-decoration: none;
2944 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
2945 }
2946 .repo-ai-cta:hover {
2947 border-color: rgba(140,109,255,0.45);
2948 color: var(--text-strong);
2949 background: rgba(140,109,255,0.06);
2950 text-decoration: none;
2951 }
2952 .repo-ai-cta-icon {
2953 opacity: 0.75;
2954 font-size: 12px;
2955 }
2956 @media (max-width: 640px) {
2957 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
2958 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
2959 }
2960 `,
2961 }}
2962 />
2963 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
2964 <span class="repo-ai-cta-label">AI surfaces</span>
2965 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
2966 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
2967 Chat
2968 </a>
2969 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
2970 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
2971 Previews
2972 </a>
2973 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
2974 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
2975 Migrations
2976 </a>
2977 <a class="repo-ai-cta" href={`/${owner}/${repo}/semantic-search`} title="Embedding-backed code search">
2978 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
2979 Semantic search
2980 </a>
2981 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
2982 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
2983 AI release notes
2984 </a>
2985 </nav>
544d842Claude2986 <div class="repo-home-grid">
2987 <div class="repo-home-main">
2988 <BranchSwitcher
2989 owner={owner}
2990 repo={repo}
2991 currentRef={defaultBranch}
2992 branches={branches}
2993 pathType="tree"
2994 />
2995 <FileTable
2996 entries={tree}
2997 owner={owner}
2998 repo={repo}
2999 ref={defaultBranch}
3000 path=""
3001 />
3002 {readme && (() => {
3003 const readmeHtml = renderMarkdown(readme);
3004 return (
3005 <div class="repo-home-readme">
3006 <div class="repo-home-readme-head">
3007 <span class="repo-home-readme-icon">{"☰"}</span>
3008 <span>README.md</span>
3009 </div>
3010 <style>{markdownCss}</style>
3011 <div class="markdown-body repo-home-readme-body">
3012 {html([readmeHtml] as unknown as TemplateStringsArray)}
3013 </div>
3014 </div>
3015 );
3016 })()}
3017 </div>
3018 <aside class="repo-home-side" aria-label="Repository details">
3019 <div class="repo-home-clone">
3020 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
3021 <button
3022 type="button"
3023 class="repo-home-clone-tab"
3024 role="tab"
3025 aria-selected="true"
3026 data-pane="https"
3027 data-repo-home-clone-tab
3028 >
3029 HTTPS
3030 </button>
3031 <button
3032 type="button"
3033 class="repo-home-clone-tab"
3034 role="tab"
3035 aria-selected="false"
3036 data-pane="ssh"
3037 data-repo-home-clone-tab
3038 >
3039 SSH
3040 </button>
3041 <button
3042 type="button"
3043 class="repo-home-clone-tab"
3044 role="tab"
3045 aria-selected="false"
3046 data-pane="cli"
3047 data-repo-home-clone-tab
3048 >
3049 CLI
3050 </button>
3051 </div>
3052 <div
3053 class="repo-home-clone-pane"
3054 data-pane="https"
3055 data-active="true"
3056 role="tabpanel"
3057 >
3058 <div class="repo-home-clone-body">
3059 <input
3060 class="repo-home-clone-input"
3061 type="text"
3062 value={cloneHttpsUrl}
3063 readonly
3064 aria-label="HTTPS clone URL"
3065 data-repo-home-clone-input
3066 />
3067 <button
3068 type="button"
3069 class="repo-home-clone-copy"
3070 data-repo-home-copy={cloneHttpsUrl}
3071 >
3072 Copy
3073 </button>
3074 </div>
3075 </div>
3076 <div
3077 class="repo-home-clone-pane"
3078 data-pane="ssh"
3079 data-active="false"
3080 role="tabpanel"
3081 >
3082 <div class="repo-home-clone-body">
3083 <input
3084 class="repo-home-clone-input"
3085 type="text"
3086 value={cloneSshUrl}
3087 readonly
3088 aria-label="SSH clone URL"
3089 data-repo-home-clone-input
3090 />
3091 <button
3092 type="button"
3093 class="repo-home-clone-copy"
3094 data-repo-home-copy={cloneSshUrl}
3095 >
3096 Copy
3097 </button>
3098 </div>
3099 </div>
3100 <div
3101 class="repo-home-clone-pane"
3102 data-pane="cli"
3103 data-active="false"
3104 role="tabpanel"
3105 >
3106 <div class="repo-home-clone-body">
3107 <input
3108 class="repo-home-clone-input"
3109 type="text"
3110 value={cloneCliCmd}
3111 readonly
3112 aria-label="Gluecron CLI clone command"
3113 data-repo-home-clone-input
3114 />
3115 <button
3116 type="button"
3117 class="repo-home-clone-copy"
3118 data-repo-home-copy={cloneCliCmd}
3119 >
3120 Copy
3121 </button>
3122 </div>
79136bbClaude3123 </div>
fc1817aClaude3124 </div>
544d842Claude3125 <div class="repo-home-side-card">
3126 <h3 class="repo-home-side-title">About</h3>
3127 <div class="repo-home-side-row">
3128 <span class="repo-home-side-key">Default branch</span>
3129 <span class="repo-home-side-val">{defaultBranch}</span>
3130 </div>
3131 <div class="repo-home-side-row">
3132 <span class="repo-home-side-key">Branches</span>
3133 <span class="repo-home-side-val">
3134 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
3135 {branches.length}
3136 </a>
3137 </span>
3138 </div>
3139 <div class="repo-home-side-row">
3140 <span class="repo-home-side-key">Stars</span>
3141 <span class="repo-home-side-val">{starCount}</span>
3142 </div>
3143 <div class="repo-home-side-row">
3144 <span class="repo-home-side-key">Forks</span>
3145 <span class="repo-home-side-val">{forkCount}</span>
3146 </div>
3147 {pushedAt && (
3148 <div class="repo-home-side-row">
3149 <span class="repo-home-side-key">Last push</span>
3150 <span class="repo-home-side-val">
3151 {formatRelative(pushedAt)}
3152 </span>
3153 </div>
3154 )}
3155 {createdAt && (
3156 <div class="repo-home-side-row">
3157 <span class="repo-home-side-key">Created</span>
3158 <span class="repo-home-side-val">
3159 {formatRelative(createdAt)}
3160 </span>
3161 </div>
3162 )}
3163 {(archived || isTemplate) && (
3164 <div class="repo-home-side-row">
3165 <span class="repo-home-side-key">State</span>
3166 <span class="repo-home-side-val">
3167 {archived ? "Archived" : "Template"}
3168 </span>
3169 </div>
3170 )}
3171 </div>
3172 </aside>
3173 </div>
3174 <script
3175 dangerouslySetInnerHTML={{
3176 __html: `
3177 (function(){
3178 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
3179 tabs.forEach(function(tab){
3180 tab.addEventListener('click', function(){
3181 var target = tab.getAttribute('data-pane');
3182 tabs.forEach(function(t){
3183 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
3184 });
3185 var panes = document.querySelectorAll('.repo-home-clone-pane');
3186 panes.forEach(function(p){
3187 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
3188 });
3189 });
3190 });
3191 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
3192 copyBtns.forEach(function(btn){
3193 btn.addEventListener('click', function(){
3194 var text = btn.getAttribute('data-repo-home-copy') || '';
3195 var done = function(){
3196 var prev = btn.textContent;
3197 btn.textContent = 'Copied';
3198 setTimeout(function(){ btn.textContent = prev; }, 1200);
3199 };
3200 if (navigator.clipboard && navigator.clipboard.writeText) {
3201 navigator.clipboard.writeText(text).then(done, done);
3202 } else {
3203 var ta = document.createElement('textarea');
3204 ta.value = text;
3205 document.body.appendChild(ta);
3206 ta.select();
3207 try { document.execCommand('copy'); } catch (e) {}
3208 document.body.removeChild(ta);
3209 done();
3210 }
3211 });
3212 });
3213 })();
3214 `,
3215 }}
3216 />
fc1817aClaude3217 </Layout>
3218 );
3219});
3220
3221// Browse tree at ref/path
3222web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
3223 const { owner, repo } = c.req.param();
06d5ffeClaude3224 const user = c.get("user");
fc1817aClaude3225 const refAndPath = c.req.param("ref");
3226
3227 const branches = await listBranches(owner, repo);
3228 let ref = "";
3229 let treePath = "";
3230
3231 for (const branch of branches) {
3232 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
3233 ref = branch;
3234 treePath = refAndPath.slice(branch.length + 1);
3235 break;
3236 }
3237 }
3238
3239 if (!ref) {
3240 const slashIdx = refAndPath.indexOf("/");
3241 if (slashIdx === -1) {
3242 ref = refAndPath;
3243 } else {
3244 ref = refAndPath.slice(0, slashIdx);
3245 treePath = refAndPath.slice(slashIdx + 1);
3246 }
3247 }
3248
3249 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude3250 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3251 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude3252
3253 return c.html(
06d5ffeClaude3254 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude3255 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3256 <RepoHeader owner={owner} repo={repo} />
3257 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3258 <div class="tree-header">
3259 <div class="tree-header-row">
3260 <BranchSwitcher
3261 owner={owner}
3262 repo={repo}
3263 currentRef={ref}
3264 branches={branches}
3265 pathType="tree"
3266 subPath={treePath}
3267 />
3268 <div class="tree-header-stats">
3269 <span class="tree-stat" title="Entries in this directory">
3270 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3271 {dirCount > 0 && (
3272 <>
3273 {" · "}
3274 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3275 </>
3276 )}
3277 </span>
3278 <a
3279 href={`/${owner}/${repo}/search`}
3280 class="tree-stat tree-stat-link"
3281 title="Search code in this repository"
3282 >
3283 {"⌕"} Search
3284 </a>
3285 </div>
3286 </div>
3287 <div class="tree-breadcrumb-row">
3288 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
3289 </div>
3290 </div>
fc1817aClaude3291 <FileTable
3292 entries={tree}
3293 owner={owner}
3294 repo={repo}
3295 ref={ref}
3296 path={treePath}
3297 />
3298 </Layout>
3299 );
3300});
3301
06d5ffeClaude3302// View file blob with syntax highlighting
fc1817aClaude3303web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
3304 const { owner, repo } = c.req.param();
06d5ffeClaude3305 const user = c.get("user");
fc1817aClaude3306 const refAndPath = c.req.param("ref");
3307
3308 const branches = await listBranches(owner, repo);
3309 let ref = "";
3310 let filePath = "";
3311
3312 for (const branch of branches) {
3313 if (refAndPath.startsWith(branch + "/")) {
3314 ref = branch;
3315 filePath = refAndPath.slice(branch.length + 1);
3316 break;
3317 }
3318 }
3319
3320 if (!ref) {
3321 const slashIdx = refAndPath.indexOf("/");
3322 if (slashIdx === -1) return c.text("Not found", 404);
3323 ref = refAndPath.slice(0, slashIdx);
3324 filePath = refAndPath.slice(slashIdx + 1);
3325 }
3326
3327 const blob = await getBlob(owner, repo, ref, filePath);
3328 if (!blob) {
3329 return c.html(
06d5ffeClaude3330 <Layout title="Not Found" user={user}>
fc1817aClaude3331 <div class="empty-state">
3332 <h2>File not found</h2>
3333 </div>
3334 </Layout>,
3335 404
3336 );
3337 }
3338
06d5ffeClaude3339 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3340 const lineCount = blob.isBinary
3341 ? 0
3342 : (blob.content.endsWith("\n")
3343 ? blob.content.split("\n").length - 1
3344 : blob.content.split("\n").length);
3345 const formatBytes = (n: number): string => {
3346 if (n < 1024) return `${n} B`;
3347 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3348 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3349 };
fc1817aClaude3350
3351 return c.html(
06d5ffeClaude3352 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3353 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3354 <RepoHeader owner={owner} repo={repo} />
3355 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3356 <div class="blob-toolbar">
3357 <BranchSwitcher
3358 owner={owner}
3359 repo={repo}
3360 currentRef={ref}
3361 branches={branches}
3362 pathType="blob"
3363 subPath={filePath}
3364 />
3365 <div class="blob-breadcrumb">
3366 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3367 </div>
3368 </div>
3369 <div class="blob-view blob-card">
3370 <div class="blob-header blob-header-polished">
3371 <div class="blob-header-meta">
3372 <span class="blob-header-icon" aria-hidden="true">
3373 {"📄"}
3374 </span>
3375 <span class="blob-header-name">{fileName}</span>
3376 <span class="blob-header-size">
3377 {formatBytes(blob.size)}
3378 {!blob.isBinary && (
3379 <>
3380 {" · "}
3381 {lineCount} line{lineCount === 1 ? "" : "s"}
3382 </>
3383 )}
3384 </span>
3385 </div>
3386 <div class="blob-header-actions">
3387 <a
3388 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
3389 class="blob-pill"
3390 >
79136bbClaude3391 Raw
3392 </a>
efb11c5Claude3393 <a
3394 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
3395 class="blob-pill"
3396 >
79136bbClaude3397 Blame
3398 </a>
efb11c5Claude3399 <a
3400 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
3401 class="blob-pill"
3402 >
16b325cClaude3403 History
3404 </a>
0074234Claude3405 {user && (
efb11c5Claude3406 <a
3407 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
3408 class="blob-pill blob-pill-accent"
3409 >
0074234Claude3410 Edit
3411 </a>
3412 )}
efb11c5Claude3413 </div>
fc1817aClaude3414 </div>
3415 {blob.isBinary ? (
efb11c5Claude3416 <div class="blob-binary">
fc1817aClaude3417 Binary file not shown.
3418 </div>
06d5ffeClaude3419 ) : (() => {
3420 const { html: highlighted, language } = highlightCode(
3421 blob.content,
3422 fileName
3423 );
3424 if (language) {
3425 return (
3426 <HighlightedCode
3427 highlightedHtml={highlighted}
efb11c5Claude3428 lineCount={lineCount}
06d5ffeClaude3429 />
3430 );
3431 }
3432 const lines = blob.content.split("\n");
3433 if (lines[lines.length - 1] === "") lines.pop();
3434 return <PlainCode lines={lines} />;
3435 })()}
fc1817aClaude3436 </div>
3437 </Layout>
3438 );
3439});
3440
398a10cClaude3441// ─── Branches list ────────────────────────────────────────────────────────
3442// Lightweight `git for-each-ref` enrichment so each row shows last-commit
3443// author + relative time + ahead/behind vs the default branch. No DB. All
3444// data comes from git plumbing; failures degrade gracefully (counts omitted).
3445web.get("/:owner/:repo/branches", async (c) => {
3446 const { owner, repo } = c.req.param();
3447 const user = c.get("user");
3448
3449 if (!(await repoExists(owner, repo))) return c.notFound();
3450
3451 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3452 const branches = await listBranches(owner, repo);
3453 const repoDir = getRepoPath(owner, repo);
3454
3455 type BranchRow = {
3456 name: string;
3457 isDefault: boolean;
3458 sha: string;
3459 subject: string;
3460 author: string;
3461 date: string;
3462 ahead: number;
3463 behind: number;
3464 };
3465
3466 const runGit = async (args: string[]): Promise<string> => {
3467 try {
3468 const proc = Bun.spawn(["git", ...args], {
3469 cwd: repoDir,
3470 stdout: "pipe",
3471 stderr: "pipe",
3472 });
3473 const out = await new Response(proc.stdout).text();
3474 await proc.exited;
3475 return out;
3476 } catch {
3477 return "";
3478 }
3479 };
3480
3481 const meta = await runGit([
3482 "for-each-ref",
3483 "--sort=-committerdate",
3484 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
3485 "refs/heads/",
3486 ]);
3487 const metaByName: Record<
3488 string,
3489 { sha: string; subject: string; author: string; date: string }
3490 > = {};
3491 for (const line of meta.split("\n").filter(Boolean)) {
3492 const [name, sha, subject, author, date] = line.split("\0");
3493 metaByName[name] = { sha, subject, author, date };
3494 }
3495
3496 const branchOrder = [...branches].sort((a, b) => {
3497 if (a === defaultBranch) return -1;
3498 if (b === defaultBranch) return 1;
3499 const aDate = metaByName[a]?.date || "";
3500 const bDate = metaByName[b]?.date || "";
3501 return bDate.localeCompare(aDate);
3502 });
3503
3504 const rows: BranchRow[] = [];
3505 for (const name of branchOrder) {
3506 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
3507 let ahead = 0;
3508 let behind = 0;
3509 if (name !== defaultBranch && metaByName[defaultBranch]) {
3510 const out = await runGit([
3511 "rev-list",
3512 "--left-right",
3513 "--count",
3514 `${defaultBranch}...${name}`,
3515 ]);
3516 const parts = out.trim().split(/\s+/);
3517 if (parts.length === 2) {
3518 behind = parseInt(parts[0], 10) || 0;
3519 ahead = parseInt(parts[1], 10) || 0;
3520 }
3521 }
3522 rows.push({
3523 name,
3524 isDefault: name === defaultBranch,
3525 sha: m.sha,
3526 subject: m.subject,
3527 author: m.author,
3528 date: m.date,
3529 ahead,
3530 behind,
3531 });
3532 }
3533
3534 const relative = (iso: string): string => {
3535 if (!iso) return "—";
3536 const d = new Date(iso);
3537 if (Number.isNaN(d.getTime())) return "—";
3538 const diff = Date.now() - d.getTime();
3539 const m = Math.floor(diff / 60000);
3540 if (m < 1) return "just now";
3541 if (m < 60) return `${m} min ago`;
3542 const h = Math.floor(m / 60);
3543 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
3544 const dd = Math.floor(h / 24);
3545 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
3546 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3547 };
3548
3549 const success = c.req.query("success");
3550 const error = c.req.query("error");
3551
3552 return c.html(
3553 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
3554 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
3555 <RepoHeader owner={owner} repo={repo} />
3556 <RepoNav owner={owner} repo={repo} active="code" />
3557 <div
3558 class="branches-wrap"
3559 style="max-width:1100px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
3560 >
3561 <header class="branches-head" style="margin-bottom:var(--space-5)">
3562 <div
3563 class="branches-eyebrow"
3564 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
3565 >
3566 <span
3567 class="branches-eyebrow-dot"
3568 aria-hidden="true"
3569 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
3570 />
3571 Repository · Branches
3572 </div>
3573 <h1
3574 class="branches-title"
3575 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
3576 >
3577 <span class="gradient-text">{rows.length}</span>{" "}
3578 branch{rows.length === 1 ? "" : "es"}
3579 </h1>
3580 <p
3581 class="branches-sub"
3582 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
3583 >
3584 All work-in-progress lines for{" "}
3585 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
3586 counts are relative to{" "}
3587 <code style="font-size:12.5px">{defaultBranch}</code>.
3588 </p>
3589 </header>
3590
3591 {success && (
3592 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(52,211,153,0.40);background:rgba(52,211,153,0.08);color:#bbf7d0">
3593 {decodeURIComponent(success)}
3594 </div>
3595 )}
3596 {error && (
3597 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(248,113,113,0.40);background:rgba(248,113,113,0.08);color:#fecaca">
3598 {decodeURIComponent(error)}
3599 </div>
3600 )}
3601
3602 {rows.length === 0 ? (
3603 <div class="commits-empty">
3604 <div class="commits-empty-orb" aria-hidden="true" />
3605 <div class="commits-empty-inner">
3606 <div class="commits-empty-icon" aria-hidden="true">
3607 <svg
3608 width="22"
3609 height="22"
3610 viewBox="0 0 24 24"
3611 fill="none"
3612 stroke="currentColor"
3613 stroke-width="2"
3614 stroke-linecap="round"
3615 stroke-linejoin="round"
3616 >
3617 <line x1="6" y1="3" x2="6" y2="15" />
3618 <circle cx="18" cy="6" r="3" />
3619 <circle cx="6" cy="18" r="3" />
3620 <path d="M18 9a9 9 0 0 1-9 9" />
3621 </svg>
3622 </div>
3623 <h3 class="commits-empty-title">No branches yet</h3>
3624 <p class="commits-empty-sub">
3625 Push your first commit to create the default branch.
3626 </p>
3627 </div>
3628 </div>
3629 ) : (
3630 <div class="branches-list">
3631 {rows.map((r) => (
3632 <div class="branches-row">
3633 <div class="branches-row-icon" aria-hidden="true">
3634 <svg
3635 width="14"
3636 height="14"
3637 viewBox="0 0 24 24"
3638 fill="none"
3639 stroke="currentColor"
3640 stroke-width="2"
3641 stroke-linecap="round"
3642 stroke-linejoin="round"
3643 >
3644 <line x1="6" y1="3" x2="6" y2="15" />
3645 <circle cx="18" cy="6" r="3" />
3646 <circle cx="6" cy="18" r="3" />
3647 <path d="M18 9a9 9 0 0 1-9 9" />
3648 </svg>
3649 </div>
3650 <div class="branches-row-main">
3651 <div class="branches-row-name">
3652 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
3653 {r.isDefault && (
3654 <span
3655 class="branches-row-default"
3656 title="Default branch"
3657 >
3658 Default
3659 </span>
3660 )}
3661 </div>
3662 <div class="branches-row-meta">
3663 {r.subject ? (
3664 <>
3665 <strong>{r.author || "—"}</strong>
3666 <span class="sep">·</span>
3667 <span
3668 title={r.date ? new Date(r.date).toISOString() : ""}
3669 >
3670 updated {relative(r.date)}
3671 </span>
3672 {r.sha && (
3673 <>
3674 <span class="sep">·</span>
3675 <a
3676 href={`/${owner}/${repo}/commit/${r.sha}`}
3677 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
3678 >
3679 {r.sha.slice(0, 7)}
3680 </a>
3681 </>
3682 )}
3683 </>
3684 ) : (
3685 <span>No commit metadata</span>
3686 )}
3687 </div>
3688 </div>
3689 <div class="branches-row-side">
3690 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
3691 <span
3692 class="branches-row-divergence"
3693 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
3694 >
3695 <span class="ahead">{r.ahead} ahead</span>
3696 <span style="opacity:0.4">|</span>
3697 <span class="behind">{r.behind} behind</span>
3698 </span>
3699 )}
3700 <div class="branches-row-actions">
3701 <a
3702 href={`/${owner}/${repo}/commits/${r.name}`}
3703 class="branches-btn"
3704 title="View commits on this branch"
3705 >
3706 Commits
3707 </a>
3708 {!r.isDefault &&
3709 user &&
3710 user.username === owner && (
3711 <form
3712 method="post"
3713 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
3714 style="margin:0"
3715 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
3716 >
3717 <button
3718 type="submit"
3719 class="branches-btn branches-btn-danger"
3720 >
3721 Delete
3722 </button>
3723 </form>
3724 )}
3725 </div>
3726 </div>
3727 </div>
3728 ))}
3729 </div>
3730 )}
3731 </div>
3732 </Layout>
3733 );
3734});
3735
3736// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
3737// that are not merged into the default branch — matches the explicit
3738// confirmation on the row's delete button.
3739web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
3740 const { owner, repo } = c.req.param();
3741 const branchName = decodeURIComponent(c.req.param("name"));
3742 const user = c.get("user")!;
3743
3744 // Owner-only check (mirrors collaborators.tsx pattern).
3745 const [ownerRow] = await db
3746 .select()
3747 .from(users)
3748 .where(eq(users.username, owner))
3749 .limit(1);
3750 if (!ownerRow || ownerRow.id !== user.id) {
3751 return c.redirect(
3752 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
3753 );
3754 }
3755
3756 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
3757 if (branchName === defaultBranch) {
3758 return c.redirect(
3759 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
3760 );
3761 }
3762
3763 const branches = await listBranches(owner, repo);
3764 if (!branches.includes(branchName)) {
3765 return c.redirect(
3766 `/${owner}/${repo}/branches?error=Branch+not+found`
3767 );
3768 }
3769
3770 try {
3771 const repoDir = getRepoPath(owner, repo);
3772 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
3773 cwd: repoDir,
3774 stdout: "pipe",
3775 stderr: "pipe",
3776 });
3777 await proc.exited;
3778 if (proc.exitCode !== 0) {
3779 return c.redirect(
3780 `/${owner}/${repo}/branches?error=Delete+failed`
3781 );
3782 }
3783 } catch {
3784 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
3785 }
3786
3787 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
3788});
3789
3790// ─── Tags list ────────────────────────────────────────────────────────────
3791// Pulls from `listTags` (sorted newest first). For each tag we look up an
3792// associated release row so the "View release" CTA can deep-link directly
3793// without making the user hunt for it.
3794web.get("/:owner/:repo/tags", async (c) => {
3795 const { owner, repo } = c.req.param();
3796 const user = c.get("user");
3797
3798 if (!(await repoExists(owner, repo))) return c.notFound();
3799
3800 const tags = await listTags(owner, repo);
3801
3802 // Map tags -> releases. Best-effort; releases table may not exist in
3803 // every test setup, so any error falls through with an empty set.
3804 const tagsWithReleases = new Set<string>();
3805 try {
3806 const [ownerRow] = await db
3807 .select()
3808 .from(users)
3809 .where(eq(users.username, owner))
3810 .limit(1);
3811 if (ownerRow) {
3812 const [repoRow] = await db
3813 .select()
3814 .from(repositories)
3815 .where(
3816 and(
3817 eq(repositories.ownerId, ownerRow.id),
3818 eq(repositories.name, repo)
3819 )
3820 )
3821 .limit(1);
3822 if (repoRow) {
3823 // Raw SQL so we don't need to import the releases schema here.
3824 const result = await db.execute(
3825 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
3826 );
3827 const rows: any[] = (result as any).rows || (result as any) || [];
3828 for (const row of rows) {
3829 const tag = row?.tag;
3830 if (typeof tag === "string") tagsWithReleases.add(tag);
3831 }
3832 }
3833 }
3834 } catch {
3835 // No releases table or DB error — leave set empty.
3836 }
3837
3838 const relative = (iso: string): string => {
3839 if (!iso) return "—";
3840 const d = new Date(iso);
3841 if (Number.isNaN(d.getTime())) return "—";
3842 const diff = Date.now() - d.getTime();
3843 const m = Math.floor(diff / 60000);
3844 if (m < 1) return "just now";
3845 if (m < 60) return `${m} min ago`;
3846 const h = Math.floor(m / 60);
3847 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
3848 const dd = Math.floor(h / 24);
3849 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
3850 return d.toLocaleDateString("en-US", {
3851 month: "short",
3852 day: "numeric",
3853 year: "numeric",
3854 });
3855 };
3856
3857 return c.html(
3858 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
3859 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
3860 <RepoHeader owner={owner} repo={repo} />
3861 <RepoNav owner={owner} repo={repo} active="code" />
3862 <div
3863 class="tags-wrap"
3864 style="max-width:1100px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
3865 >
3866 <header class="tags-head" style="margin-bottom:var(--space-5)">
3867 <div
3868 class="tags-eyebrow"
3869 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
3870 >
3871 <span
3872 class="tags-eyebrow-dot"
3873 aria-hidden="true"
3874 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
3875 />
3876 Repository · Tags
3877 </div>
3878 <h1
3879 class="tags-title"
3880 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
3881 >
3882 <span class="gradient-text">{tags.length}</span>{" "}
3883 tag{tags.length === 1 ? "" : "s"}
3884 </h1>
3885 <p
3886 class="tags-sub"
3887 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
3888 >
3889 Named points in the history — typically releases, milestones,
3890 or shipped versions. Click a tag to browse the tree at that
3891 revision.
3892 </p>
3893 </header>
3894
3895 {tags.length === 0 ? (
3896 <div class="commits-empty">
3897 <div class="commits-empty-orb" aria-hidden="true" />
3898 <div class="commits-empty-inner">
3899 <div class="commits-empty-icon" aria-hidden="true">
3900 <svg
3901 width="22"
3902 height="22"
3903 viewBox="0 0 24 24"
3904 fill="none"
3905 stroke="currentColor"
3906 stroke-width="2"
3907 stroke-linecap="round"
3908 stroke-linejoin="round"
3909 >
3910 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
3911 <line x1="7" y1="7" x2="7.01" y2="7" />
3912 </svg>
3913 </div>
3914 <h3 class="commits-empty-title">No tags yet</h3>
3915 <p class="commits-empty-sub">
3916 Tag a commit to mark a release or milestone. From the CLI:{" "}
3917 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
3918 </p>
3919 </div>
3920 </div>
3921 ) : (
3922 <div class="tags-list">
3923 {tags.map((t) => {
3924 const hasRelease = tagsWithReleases.has(t.name);
3925 return (
3926 <div class="tags-row">
3927 <div class="tags-row-icon" aria-hidden="true">
3928 <svg
3929 width="14"
3930 height="14"
3931 viewBox="0 0 24 24"
3932 fill="none"
3933 stroke="currentColor"
3934 stroke-width="2"
3935 stroke-linecap="round"
3936 stroke-linejoin="round"
3937 >
3938 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
3939 <line x1="7" y1="7" x2="7.01" y2="7" />
3940 </svg>
3941 </div>
3942 <div class="tags-row-main">
3943 <div class="tags-row-name">
3944 <a
3945 href={`/${owner}/${repo}/tree/${t.name}`}
3946 style="text-decoration:none"
3947 >
3948 <span class="tags-row-version">{t.name}</span>
3949 </a>
3950 </div>
3951 <div class="tags-row-meta">
3952 <span
3953 title={t.date ? new Date(t.date).toISOString() : ""}
3954 >
3955 Tagged {relative(t.date)}
3956 </span>
3957 <span class="sep">·</span>
3958 <a
3959 href={`/${owner}/${repo}/commit/${t.sha}`}
3960 class="tags-row-sha"
3961 title={t.sha}
3962 >
3963 {t.sha.slice(0, 7)}
3964 </a>
3965 </div>
3966 </div>
3967 <div class="tags-row-side">
3968 <a
3969 href={`/${owner}/${repo}/tree/${t.name}`}
3970 class="tags-row-link"
3971 >
3972 Browse files
3973 </a>
3974 {hasRelease && (
3975 <a
3976 href={`/${owner}/${repo}/releases/tag/${t.name}`}
3977 class="tags-row-link"
3978 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
3979 >
3980 View release
3981 </a>
3982 )}
3983 </div>
3984 </div>
3985 );
3986 })}
3987 </div>
3988 )}
3989 </div>
3990 </Layout>
3991 );
3992});
3993
fc1817aClaude3994// Commit log
3995web.get("/:owner/:repo/commits/:ref?", async (c) => {
3996 const { owner, repo } = c.req.param();
06d5ffeClaude3997 const user = c.get("user");
fc1817aClaude3998 const ref =
3999 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude4000 const branches = await listBranches(owner, repo);
fc1817aClaude4001
4002 const commits = await listCommits(owner, repo, ref, 50);
4003
3951454Claude4004 // Block J3 — batch-fetch cached verification results for the page.
4005 let verifications: Record<string, { verified: boolean; reason: string }> = {};
4006 try {
4007 const [ownerRow] = await db
4008 .select()
4009 .from(users)
4010 .where(eq(users.username, owner))
4011 .limit(1);
4012 if (ownerRow) {
4013 const [repoRow] = await db
4014 .select()
4015 .from(repositories)
4016 .where(
4017 and(
4018 eq(repositories.ownerId, ownerRow.id),
4019 eq(repositories.name, repo)
4020 )
4021 )
4022 .limit(1);
4023 if (repoRow && commits.length > 0) {
4024 const rows = await db
4025 .select()
4026 .from(commitVerifications)
4027 .where(
4028 and(
4029 eq(commitVerifications.repositoryId, repoRow.id),
4030 inArray(
4031 commitVerifications.commitSha,
4032 commits.map((c) => c.sha)
4033 )
4034 )
4035 );
4036 for (const r of rows) {
4037 verifications[r.commitSha] = {
4038 verified: r.verified,
4039 reason: r.reason,
4040 };
4041 }
4042 }
4043 }
4044 } catch {
4045 // DB unavailable — skip the badges gracefully.
4046 }
4047
fc1817aClaude4048 return c.html(
06d5ffeClaude4049 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude4050 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4051 <RepoHeader owner={owner} repo={repo} />
4052 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude4053 <div class="commits-hero">
4054 <div class="commits-hero-orb-wrap" aria-hidden="true">
4055 <div class="commits-hero-orb" />
fc1817aClaude4056 </div>
efb11c5Claude4057 <div class="commits-hero-inner">
4058 <div class="commits-eyebrow">
4059 <strong>History</strong> · {owner}/{repo}
4060 </div>
4061 <h1 class="commits-title">
4062 <span class="gradient-text">{commits.length}</span> recent commit
4063 {commits.length === 1 ? "" : "s"} on{" "}
4064 <span class="commits-branch">{ref}</span>
4065 </h1>
4066 <p class="commits-sub">
4067 Browse the project's history. Click any commit to see the full
4068 diff, AI review notes, and signature status.
4069 </p>
4070 </div>
4071 </div>
4072 <div class="commits-toolbar">
4073 <BranchSwitcher
3951454Claude4074 owner={owner}
4075 repo={repo}
efb11c5Claude4076 currentRef={ref}
4077 branches={branches}
4078 pathType="commits"
3951454Claude4079 />
398a10cClaude4080 <div class="commits-toolbar-actions">
4081 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
4082 {"⊢"} Branches
4083 </a>
4084 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
4085 {"#"} Tags
4086 </a>
4087 </div>
efb11c5Claude4088 </div>
4089 {commits.length === 0 ? (
4090 <div class="commits-empty">
398a10cClaude4091 <div class="commits-empty-orb" aria-hidden="true" />
4092 <div class="commits-empty-inner">
4093 <div class="commits-empty-icon" aria-hidden="true">
4094 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4095 <circle cx="12" cy="12" r="4" />
4096 <line x1="1.05" y1="12" x2="7" y2="12" />
4097 <line x1="17.01" y1="12" x2="22.96" y2="12" />
4098 </svg>
4099 </div>
4100 <h3 class="commits-empty-title">No commits yet</h3>
4101 <p class="commits-empty-sub">
4102 This branch is empty. Push your first commit, or use the
4103 web editor to create a file.
4104 </p>
4105 </div>
efb11c5Claude4106 </div>
4107 ) : (
4108 <div class="commits-list-wrap">
398a10cClaude4109 {(() => {
4110 // Group commits by day for the section headers.
4111 const dayLabel = (iso: string): string => {
4112 const d = new Date(iso);
4113 if (Number.isNaN(d.getTime())) return "Unknown";
4114 const today = new Date();
4115 const yesterday = new Date(today);
4116 yesterday.setDate(today.getDate() - 1);
4117 const sameDay = (a: Date, b: Date) =>
4118 a.getFullYear() === b.getFullYear() &&
4119 a.getMonth() === b.getMonth() &&
4120 a.getDate() === b.getDate();
4121 if (sameDay(d, today)) return "Today";
4122 if (sameDay(d, yesterday)) return "Yesterday";
4123 return d.toLocaleDateString("en-US", {
4124 month: "long",
4125 day: "numeric",
4126 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
4127 });
4128 };
4129 const relative = (iso: string): string => {
4130 const d = new Date(iso);
4131 if (Number.isNaN(d.getTime())) return "";
4132 const diff = Date.now() - d.getTime();
4133 const m = Math.floor(diff / 60000);
4134 if (m < 1) return "just now";
4135 if (m < 60) return `${m} min ago`;
4136 const h = Math.floor(m / 60);
4137 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4138 const dd = Math.floor(h / 24);
4139 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4140 return d.toLocaleDateString("en-US", {
4141 month: "short",
4142 day: "numeric",
4143 });
4144 };
4145 const initial = (name: string): string =>
4146 (name || "?").trim().charAt(0).toUpperCase() || "?";
4147 // Build groups preserving order.
4148 const groups: Array<{ label: string; items: typeof commits }> = [];
4149 let lastLabel = "";
4150 for (const cm of commits) {
4151 const label = dayLabel(cm.date);
4152 if (label !== lastLabel) {
4153 groups.push({ label, items: [] });
4154 lastLabel = label;
4155 }
4156 groups[groups.length - 1].items.push(cm);
4157 }
4158 return groups.map((g) => (
4159 <>
4160 <div class="commits-day-head">
4161 <span class="commits-day-head-dot" aria-hidden="true" />
4162 Commits on {g.label}
4163 </div>
4164 {g.items.map((cm) => {
4165 const v = verifications[cm.sha];
4166 return (
4167 <div class="commits-row">
4168 <div class="commits-avatar" aria-hidden="true">
4169 {initial(cm.author)}
4170 </div>
4171 <div class="commits-row-body">
4172 <div class="commits-row-msg">
4173 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
4174 {cm.message}
4175 </a>
4176 {v?.verified && (
4177 <span
4178 class="commits-row-verified"
4179 title="Signed with a registered key"
4180 >
4181 Verified
4182 </span>
4183 )}
4184 </div>
4185 <div class="commits-row-meta">
4186 <strong>{cm.author}</strong>
4187 <span class="sep">·</span>
4188 <span
4189 class="commits-row-time"
4190 title={new Date(cm.date).toISOString()}
4191 >
4192 committed {relative(cm.date)}
4193 </span>
4194 {cm.parentShas.length > 1 && (
4195 <>
4196 <span class="sep">·</span>
4197 <span>merge of {cm.parentShas.length} parents</span>
4198 </>
4199 )}
4200 </div>
4201 </div>
4202 <div class="commits-row-side">
4203 <a
4204 href={`/${owner}/${repo}/commit/${cm.sha}`}
4205 class="commits-row-sha"
4206 title={cm.sha}
4207 >
4208 {cm.sha.slice(0, 7)}
4209 </a>
4210 <button
4211 type="button"
4212 class="commits-row-copy"
4213 data-copy-sha={cm.sha}
4214 title="Copy full SHA"
4215 aria-label={`Copy SHA ${cm.sha}`}
4216 >
4217 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4218 <path fill="currentColor" d="M4 1.5h6A1.5 1.5 0 0 1 11.5 3v1h-1V3a.5.5 0 0 0-.5-.5H4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h1v1H4A1.5 1.5 0 0 1 2.5 11V3A1.5 1.5 0 0 1 4 1.5Z" />
4219 <path fill="currentColor" d="M6 5.5h6A1.5 1.5 0 0 1 13.5 7v6A1.5 1.5 0 0 1 12 14.5H6A1.5 1.5 0 0 1 4.5 13V7A1.5 1.5 0 0 1 6 5.5Zm0 1A.5.5 0 0 0 5.5 7v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V7a.5.5 0 0 0-.5-.5H6Z" />
4220 </svg>
4221 </button>
4222 </div>
4223 </div>
4224 );
4225 })}
4226 </>
4227 ));
4228 })()}
efb11c5Claude4229 </div>
fc1817aClaude4230 )}
398a10cClaude4231 <script
4232 dangerouslySetInnerHTML={{
4233 __html: `
4234 (function(){
4235 document.addEventListener('click', function(e){
4236 var t = e.target; if (!t) return;
4237 var btn = t.closest && t.closest('[data-copy-sha]');
4238 if (!btn) return;
4239 e.preventDefault();
4240 var sha = btn.getAttribute('data-copy-sha') || '';
4241 if (!navigator.clipboard) return;
4242 navigator.clipboard.writeText(sha).then(function(){
4243 btn.classList.add('is-copied');
4244 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
4245 }).catch(function(){});
4246 });
4247 })();
4248 `,
4249 }}
4250 />
fc1817aClaude4251 </Layout>
4252 );
4253});
4254
4255// Single commit with diff
4256web.get("/:owner/:repo/commit/:sha", async (c) => {
4257 const { owner, repo, sha } = c.req.param();
06d5ffeClaude4258 const user = c.get("user");
fc1817aClaude4259
05b973eClaude4260 // Fetch commit, full message, and diff in parallel
4261 const [commit, fullMessage, diffResult] = await Promise.all([
4262 getCommit(owner, repo, sha),
4263 getCommitFullMessage(owner, repo, sha),
4264 getDiff(owner, repo, sha),
4265 ]);
fc1817aClaude4266 if (!commit) {
4267 return c.html(
06d5ffeClaude4268 <Layout title="Not Found" user={user}>
fc1817aClaude4269 <div class="empty-state">
4270 <h2>Commit not found</h2>
4271 </div>
4272 </Layout>,
4273 404
4274 );
4275 }
4276
3951454Claude4277 // Block J3 — try to verify this commit's signature.
4278 let verification:
4279 | { verified: boolean; reason: string; signatureType: string | null }
4280 | null = null;
0cdfd89Claude4281 // Block J8 — external CI commit statuses rollup.
4282 let statusCombined:
4283 | {
4284 state: "pending" | "success" | "failure";
4285 total: number;
4286 contexts: Array<{
4287 context: string;
4288 state: string;
4289 description: string | null;
4290 targetUrl: string | null;
4291 }>;
4292 }
4293 | null = null;
3951454Claude4294 try {
4295 const [ownerRow] = await db
4296 .select()
4297 .from(users)
4298 .where(eq(users.username, owner))
4299 .limit(1);
4300 if (ownerRow) {
4301 const [repoRow] = await db
4302 .select()
4303 .from(repositories)
4304 .where(
4305 and(
4306 eq(repositories.ownerId, ownerRow.id),
4307 eq(repositories.name, repo)
4308 )
4309 )
4310 .limit(1);
4311 if (repoRow) {
4312 const { verifyCommit } = await import("../lib/signatures");
4313 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
4314 verification = {
4315 verified: v.verified,
4316 reason: v.reason,
4317 signatureType: v.signatureType,
4318 };
0cdfd89Claude4319 try {
4320 const { combinedStatus } = await import("../lib/commit-statuses");
4321 const combined = await combinedStatus(repoRow.id, commit.sha);
4322 if (combined.total > 0) {
4323 statusCombined = {
4324 state: combined.state as any,
4325 total: combined.total,
4326 contexts: combined.contexts.map((c) => ({
4327 context: c.context,
4328 state: c.state,
4329 description: c.description,
4330 targetUrl: c.targetUrl,
4331 })),
4332 };
4333 }
4334 } catch {
4335 statusCombined = null;
4336 }
3951454Claude4337 }
4338 }
4339 } catch {
4340 verification = null;
4341 }
4342
05b973eClaude4343 const { files, raw } = diffResult;
fc1817aClaude4344
efb11c5Claude4345 // Diff stats: count additions / deletions across all files for the
4346 // header summary bar. Computed here from the parsed diff so we don't
4347 // touch the DiffView component.
4348 let additions = 0;
4349 let deletions = 0;
4350 for (const f of files) {
4351 const hunks = (f as any).hunks as Array<any> | undefined;
4352 if (Array.isArray(hunks)) {
4353 for (const h of hunks) {
4354 const lines = (h?.lines || []) as Array<any>;
4355 for (const ln of lines) {
4356 const t = ln?.type || ln?.kind;
4357 if (t === "add" || t === "added" || t === "+") additions += 1;
4358 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
4359 deletions += 1;
4360 }
4361 }
4362 }
4363 }
4364 // Fall back: scan raw if file-level counting yielded zero (it's just a
4365 // header polish — never let a parsing miss break the page).
4366 if (additions === 0 && deletions === 0 && typeof raw === "string") {
4367 for (const line of raw.split("\n")) {
4368 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
4369 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
4370 }
4371 }
4372 const fileCount = files.length;
4373
fc1817aClaude4374 return c.html(
06d5ffeClaude4375 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude4376 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4377 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude4378 <div class="commit-detail-card">
4379 <div class="commit-detail-eyebrow">
4380 <strong>Commit</strong>
4381 <span class="commit-detail-sha-pill" title={commit.sha}>
4382 {commit.sha.slice(0, 7)}
4383 </span>
3951454Claude4384 {verification && verification.reason !== "unsigned" && (
4385 <span
efb11c5Claude4386 class={`commit-detail-verify ${
3951454Claude4387 verification.verified
efb11c5Claude4388 ? "commit-detail-verify-ok"
4389 : "commit-detail-verify-warn"
3951454Claude4390 }`}
4391 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
4392 >
4393 {verification.verified ? "Verified" : verification.reason}
4394 </span>
4395 )}
fc1817aClaude4396 </div>
efb11c5Claude4397 <h1 class="commit-detail-title">{commit.message}</h1>
4398 {fullMessage !== commit.message && (
4399 <pre class="commit-detail-body">{fullMessage}</pre>
4400 )}
4401 <div class="commit-detail-meta">
4402 <span class="commit-detail-author">
4403 <strong>{commit.author}</strong> committed on{" "}
4404 {new Date(commit.date).toLocaleDateString("en-US", {
4405 month: "long",
4406 day: "numeric",
4407 year: "numeric",
4408 })}
4409 </span>
fc1817aClaude4410 {commit.parentShas.length > 0 && (
efb11c5Claude4411 <span class="commit-detail-parents">
4412 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
4413 {commit.parentShas.map((p, idx) => (
4414 <>
4415 {idx > 0 && " "}
4416 <a
4417 href={`/${owner}/${repo}/commit/${p}`}
4418 class="commit-detail-sha-link"
4419 >
4420 {p.slice(0, 7)}
4421 </a>
4422 </>
fc1817aClaude4423 ))}
4424 </span>
4425 )}
4426 </div>
efb11c5Claude4427 <div class="commit-detail-stats">
4428 <span class="commit-detail-stat">
4429 <strong>{fileCount}</strong>{" "}
4430 file{fileCount === 1 ? "" : "s"} changed
4431 </span>
4432 <span class="commit-detail-stat commit-detail-stat-add">
4433 <span class="commit-detail-stat-mark">+</span>
4434 <strong>{additions}</strong>
4435 </span>
4436 <span class="commit-detail-stat commit-detail-stat-del">
4437 <span class="commit-detail-stat-mark">−</span>
4438 <strong>{deletions}</strong>
4439 </span>
4440 <span class="commit-detail-sha-full" title="Full SHA">
4441 {commit.sha}
4442 </span>
4443 </div>
0cdfd89Claude4444 {statusCombined && (
efb11c5Claude4445 <div class="commit-detail-checks">
4446 <div class="commit-detail-checks-head">
4447 <strong>Checks</strong>
4448 <span class="commit-detail-checks-summary">
4449 {statusCombined.total} total ·{" "}
4450 <span
4451 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
4452 >
4453 {statusCombined.state}
4454 </span>
0cdfd89Claude4455 </span>
efb11c5Claude4456 </div>
4457 <div class="commit-detail-check-row">
0cdfd89Claude4458 {statusCombined.contexts.map((cx) => (
4459 <span
efb11c5Claude4460 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude4461 title={cx.description || cx.context}
4462 >
4463 {cx.targetUrl ? (
efb11c5Claude4464 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude4465 {cx.context}: {cx.state}
4466 </a>
4467 ) : (
4468 <>
4469 {cx.context}: {cx.state}
4470 </>
4471 )}
4472 </span>
4473 ))}
4474 </div>
4475 </div>
4476 )}
fc1817aClaude4477 </div>
ea9ed4cClaude4478 <DiffView
4479 raw={raw}
4480 files={files}
4481 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
4482 />
fc1817aClaude4483 </Layout>
4484 );
4485});
4486
79136bbClaude4487// Raw file download
4488web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
4489 const { owner, repo } = c.req.param();
4490 const refAndPath = c.req.param("ref");
4491
4492 const branches = await listBranches(owner, repo);
4493 let ref = "";
4494 let filePath = "";
4495
4496 for (const branch of branches) {
4497 if (refAndPath.startsWith(branch + "/")) {
4498 ref = branch;
4499 filePath = refAndPath.slice(branch.length + 1);
4500 break;
4501 }
4502 }
4503
4504 if (!ref) {
4505 const slashIdx = refAndPath.indexOf("/");
4506 if (slashIdx === -1) return c.text("Not found", 404);
4507 ref = refAndPath.slice(0, slashIdx);
4508 filePath = refAndPath.slice(slashIdx + 1);
4509 }
4510
4511 const data = await getRawBlob(owner, repo, ref, filePath);
4512 if (!data) return c.text("Not found", 404);
4513
4514 const fileName = filePath.split("/").pop() || "file";
772a24fClaude4515 return new Response(data as BodyInit, {
79136bbClaude4516 headers: {
4517 "Content-Type": "application/octet-stream",
4518 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude4519 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude4520 },
4521 });
4522});
4523
4524// Blame view
4525web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
4526 const { owner, repo } = c.req.param();
4527 const user = c.get("user");
4528 const refAndPath = c.req.param("ref");
4529
4530 const branches = await listBranches(owner, repo);
4531 let ref = "";
4532 let filePath = "";
4533
4534 for (const branch of branches) {
4535 if (refAndPath.startsWith(branch + "/")) {
4536 ref = branch;
4537 filePath = refAndPath.slice(branch.length + 1);
4538 break;
4539 }
4540 }
4541
4542 if (!ref) {
4543 const slashIdx = refAndPath.indexOf("/");
4544 if (slashIdx === -1) return c.text("Not found", 404);
4545 ref = refAndPath.slice(0, slashIdx);
4546 filePath = refAndPath.slice(slashIdx + 1);
4547 }
4548
4549 const blameLines = await getBlame(owner, repo, ref, filePath);
4550 if (blameLines.length === 0) {
4551 return c.html(
4552 <Layout title="Not Found" user={user}>
4553 <div class="empty-state">
4554 <h2>File not found</h2>
4555 </div>
4556 </Layout>,
4557 404
4558 );
4559 }
4560
4561 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4562 // Unique contributors (by author) tracked once for the header chip.
4563 const blameAuthors = new Set<string>();
4564 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude4565
4566 return c.html(
4567 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4568 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude4569 <RepoHeader owner={owner} repo={repo} />
4570 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude4571 <header class="blame-head">
4572 <div class="blame-eyebrow">
4573 <span class="blame-eyebrow-dot" aria-hidden="true" />
4574 Blame · Line-by-line history
4575 </div>
4576 <h1 class="blame-title">
4577 <code>{fileName}</code>
4578 </h1>
4579 <p class="blame-sub">
4580 Each line is annotated with the commit that last touched it.
4581 Click any SHA to jump to that commit and see the surrounding
4582 change.
4583 </p>
4584 </header>
efb11c5Claude4585 <div class="blame-toolbar">
4586 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4587 </div>
398a10cClaude4588 <div class="blame-card">
4589 <div class="blame-header">
efb11c5Claude4590 <div class="blame-header-meta">
4591 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
4592 <span class="blame-header-name">{fileName}</span>
4593 <span class="blame-header-tag">Blame</span>
4594 <span class="blame-header-stats">
4595 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
4596 {blameAuthors.size} contributor
4597 {blameAuthors.size === 1 ? "" : "s"}
4598 </span>
4599 </div>
4600 <div class="blame-header-actions">
4601 <a
4602 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
4603 class="blob-pill"
4604 >
4605 Normal view
4606 </a>
4607 <a
4608 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4609 class="blob-pill"
4610 >
4611 Raw
4612 </a>
4613 </div>
79136bbClaude4614 </div>
398a10cClaude4615 <div style="overflow-x:auto">
4616 <table class="blame-table">
79136bbClaude4617 <tbody>
4618 {blameLines.map((line, i) => {
4619 const showInfo =
4620 i === 0 || blameLines[i - 1].sha !== line.sha;
4621 return (
398a10cClaude4622 <tr class={showInfo ? "blame-row-first" : ""}>
4623 <td class="blame-gutter">
79136bbClaude4624 {showInfo && (
398a10cClaude4625 <span class="blame-gutter-inner">
79136bbClaude4626 <a
4627 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude4628 class="blame-gutter-sha"
4629 title={`Commit ${line.sha}`}
79136bbClaude4630 >
4631 {line.sha.slice(0, 7)}
398a10cClaude4632 </a>
4633 <span class="blame-gutter-author" title={line.author}>
4634 {line.author}
4635 </span>
4636 </span>
79136bbClaude4637 )}
4638 </td>
398a10cClaude4639 <td class="blame-line-num">{line.lineNum}</td>
4640 <td class="blame-line-content">{line.content}</td>
79136bbClaude4641 </tr>
4642 );
4643 })}
4644 </tbody>
4645 </table>
4646 </div>
4647 </div>
4648 </Layout>
4649 );
4650});
4651
4652// Search
4653web.get("/:owner/:repo/search", async (c) => {
4654 const { owner, repo } = c.req.param();
4655 const user = c.get("user");
4656 const q = c.req.query("q") || "";
4657
4658 if (!(await repoExists(owner, repo))) return c.notFound();
4659
4660 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4661 let results: Array<{ file: string; lineNum: number; line: string }> = [];
4662
4663 if (q.trim()) {
4664 results = await searchCode(owner, repo, defaultBranch, q.trim());
4665 }
4666
4667 return c.html(
4668 <Layout title={`Search — ${owner}/${repo}`} user={user}>
efb11c5Claude4669 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude4670 <RepoHeader owner={owner} repo={repo} />
4671 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4672 <div class="search-hero">
4673 <div class="search-eyebrow">
4674 <strong>Search</strong> · {owner}/{repo}
4675 </div>
4676 <h1 class="search-title">
4677 Find any line in <span class="gradient-text">{repo}</span>
4678 </h1>
4679 <form
4680 method="get"
4681 action={`/${owner}/${repo}/search`}
4682 class="search-form"
4683 role="search"
4684 >
4685 <div class="search-input-wrap">
4686 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
4687 <input
4688 type="text"
4689 name="q"
4690 value={q}
4691 placeholder="Search code on the default branch…"
4692 aria-label="Search code"
4693 class="search-input"
4694 autocomplete="off"
4695 autofocus
4696 />
4697 </div>
4698 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude4699 Search
4700 </button>
efb11c5Claude4701 </form>
4702 </div>
79136bbClaude4703 {q && (
efb11c5Claude4704 <div class="search-results-head">
4705 <span class="search-results-count">
4706 <strong>{results.length}</strong> result
4707 {results.length !== 1 ? "s" : ""}
4708 </span>
4709 <span class="search-results-query">
4710 for <span class="search-results-q">"{q}"</span> on{" "}
4711 <code>{defaultBranch}</code>
4712 </span>
4713 </div>
79136bbClaude4714 )}
efb11c5Claude4715 {q && results.length === 0 ? (
4716 <div class="search-empty">
4717 <p>
4718 No matches for <strong>"{q}"</strong>. Try a shorter query or check
4719 you're on the right branch.
4720 </p>
4721 </div>
4722 ) : results.length > 0 ? (
79136bbClaude4723 <div class="search-results">
4724 {(() => {
4725 // Group by file
4726 const grouped: Record<
4727 string,
4728 Array<{ lineNum: number; line: string }>
4729 > = {};
4730 for (const r of results) {
4731 if (!grouped[r.file]) grouped[r.file] = [];
4732 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
4733 }
4734 return Object.entries(grouped).map(([file, matches]) => (
efb11c5Claude4735 <div class="search-file diff-file">
4736 <div class="search-file-head diff-file-header">
79136bbClaude4737 <a
4738 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
efb11c5Claude4739 class="search-file-link"
79136bbClaude4740 >
4741 {file}
4742 </a>
efb11c5Claude4743 <span class="search-file-count">
4744 {matches.length} match{matches.length === 1 ? "" : "es"}
4745 </span>
79136bbClaude4746 </div>
4747 <div class="blob-code">
4748 <table>
4749 <tbody>
4750 {matches.map((m) => (
4751 <tr>
4752 <td class="line-num">{m.lineNum}</td>
4753 <td class="line-content">{m.line}</td>
4754 </tr>
4755 ))}
4756 </tbody>
4757 </table>
4758 </div>
4759 </div>
4760 ));
4761 })()}
4762 </div>
efb11c5Claude4763 ) : null}
79136bbClaude4764 </Layout>
4765 );
4766});
4767
fc1817aClaude4768export default web;