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