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