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.tsxBlame2000 lines · 3 contributors
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
79136bbClaude7import { html } from "hono/html";
8e9f1d9Claude8import { eq, and, desc, inArray, sql } from "drizzle-orm";
06d5ffeClaude9import { db } from "../db";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
16} from "../db/schema";
fc1817aClaude17import { Layout } from "../views/layout";
18import {
19 RepoHeader,
20 RepoNav,
21 Breadcrumb,
22 FileTable,
23 CommitList,
24 DiffView,
06d5ffeClaude25 RepoCard,
26 BranchSwitcher,
27 HighlightedCode,
28 PlainCode,
fc1817aClaude29} from "../views/components";
30import {
31 getTree,
32 getBlob,
33 listCommits,
34 getCommit,
35 getCommitFullMessage,
36 getDiff,
37 getReadme,
38 getDefaultBranch,
39 listBranches,
40 repoExists,
06d5ffeClaude41 initBareRepo,
79136bbClaude42 getBlame,
43 getRawBlob,
44 searchCode,
fc1817aClaude45} from "../git/repository";
79136bbClaude46import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude47import { highlightCode } from "../lib/highlight";
48import { softAuth, requireAuth } from "../middleware/auth";
49import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude50import { trackByName } from "../lib/traffic";
534f04aClaude51import { LandingPage, type LandingLiveFeed } from "../views/landing";
52ad8b1Claude52import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude53import {
54 listQueuedAiBuildIssues,
55 listRecentAutoMerges,
56 listRecentAiReviews,
57 countAiReviewsSince,
58 listDemoActivityFeed,
59} from "../lib/demo-activity";
fc1817aClaude60
06d5ffeClaude61const web = new Hono<AuthEnv>();
62
63// Soft auth on all web routes — c.get("user") available but may be null
64web.use("*", softAuth);
fc1817aClaude65
66// Home page
06d5ffeClaude67web.get("/", async (c) => {
68 const user = c.get("user");
69
70 if (user) {
0316dbbClaude71 return c.redirect("/dashboard");
06d5ffeClaude72 }
73
8e9f1d9Claude74 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude75 let publicStats: PublicStats | null = null;
8e9f1d9Claude76 try {
77 const [repoRow] = await db
78 .select({ n: sql<number>`count(*)::int` })
79 .from(repositories)
80 .where(eq(repositories.isPrivate, false));
81 const [userRow] = await db
82 .select({ n: sql<number>`count(*)::int` })
83 .from(users);
84 stats = {
85 publicRepos: Number(repoRow?.n ?? 0),
86 users: Number(userRow?.n ?? 0),
87 };
88 } catch {
89 stats = undefined;
90 }
91
52ad8b1Claude92 // Block L4 — public stats counters (5-min in-memory cache; never throws).
93 try {
94 publicStats = await computePublicStats();
95 } catch {
96 publicStats = null;
97 }
98
534f04aClaude99 // Block M1 — initial SSR snapshot for the live-now demo feed.
100 // The helpers in lib/demo-activity.ts never throw, but we still wrap
101 // in try/catch so a freak module-level explosion can't take down /.
102 let liveFeed: LandingLiveFeed | null = null;
103 try {
104 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
105 listQueuedAiBuildIssues(3),
106 listRecentAutoMerges(3, 24),
107 listRecentAiReviews(3, 24),
108 countAiReviewsSince(24),
109 listDemoActivityFeed(10),
110 ]);
111 liveFeed = {
112 queued: queued.map((i) => ({
113 repo: i.repo,
114 number: i.number,
115 title: i.title,
116 createdAt: i.createdAt,
117 })),
118 merges: merges.map((m) => ({
119 repo: m.repo,
120 number: m.number,
121 title: m.title,
122 mergedAt: m.mergedAt,
123 })),
124 reviews: reviewList.map((r) => ({
125 repo: r.repo,
126 prNumber: r.prNumber,
127 commentSnippet: r.commentSnippet,
128 createdAt: r.createdAt,
129 })),
130 reviewCount,
131 feed: feed.map((e) => ({
132 kind: e.kind,
133 repo: e.repo,
134 ref: e.ref,
135 at: e.at,
136 })),
137 };
138 } catch {
139 liveFeed = null;
140 }
141
fc1817aClaude142 return c.html(
5f2e749Claude143 <Layout
144 user={null}
145 // Block L10 — SEO + Open Graph for the public landing.
146 fullTitle="Gluecron — The git host built around Claude"
147 description="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
148 ogTitle="Gluecron — The git host built around Claude"
149 ogDescription="Label an issue. Walk away. Wake up to a merged PR. Gluecron is the AI-native git host with built-in code review, auto-merge, and a Claude-first toolchain."
150 ogType="website"
151 twitterCard="summary_large_image"
152 >
534f04aClaude153 <LandingPage stats={stats} publicStats={publicStats} liveFeed={liveFeed} />
fc1817aClaude154 </Layout>
155 );
156});
157
06d5ffeClaude158// New repository form
159web.get("/new", requireAuth, (c) => {
160 const user = c.get("user")!;
161 const error = c.req.query("error");
162
163 return c.html(
164 <Layout title="New repository" user={user}>
165 <div class="new-repo-form">
166 <h2>Create a new repository</h2>
167 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude168 <form method="post" action="/new">
06d5ffeClaude169 <div class="form-group">
170 <label>Owner</label>
2c3ba6ecopilot-swe-agent[bot]171 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
06d5ffeClaude172 </div>
173 <div class="form-group">
174 <label for="name">Repository name</label>
175 <input
176 type="text"
177 id="name"
178 name="name"
179 required
180 pattern="^[a-zA-Z0-9._-]+$"
181 placeholder="my-project"
182 autocomplete="off"
183 />
184 </div>
185 <div class="form-group">
186 <label for="description">Description (optional)</label>
187 <input
188 type="text"
189 id="description"
190 name="description"
191 placeholder="A short description of your repository"
192 />
193 </div>
194 <div class="visibility-options">
195 <label class="visibility-option">
196 <input type="radio" name="visibility" value="public" checked />
197 <div class="vis-label">Public</div>
198 <div class="vis-desc">Anyone can see this repository</div>
199 </label>
200 <label class="visibility-option">
201 <input type="radio" name="visibility" value="private" />
202 <div class="vis-label">Private</div>
203 <div class="vis-desc">Only you can see this repository</div>
204 </label>
205 </div>
206 <button type="submit" class="btn btn-primary">
207 Create repository
208 </button>
209 </form>
210 </div>
211 </Layout>
212 );
213});
214
215web.post("/new", requireAuth, async (c) => {
216 const user = c.get("user")!;
217 const body = await c.req.parseBody();
218 const name = String(body.name || "").trim();
219 const description = String(body.description || "").trim();
220 const isPrivate = body.visibility === "private";
221
222 if (!name) {
223 return c.redirect("/new?error=Repository+name+is+required");
224 }
225
c63b860Claude226 // P4 — plan-quota gate. Fail-open inside the helper so a billing
227 // outage never blocks repo creation.
228 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
229 const gate = await checkRepoCreateAllowed(user.id);
230 if (!gate.ok) {
231 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
232 }
233
06d5ffeClaude234 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
235 return c.redirect("/new?error=Invalid+repository+name");
236 }
237
238 if (await repoExists(user.username, name)) {
239 return c.redirect("/new?error=Repository+already+exists");
240 }
241
242 const diskPath = await initBareRepo(user.username, name);
243
3ef4c9dClaude244 const [newRepo] = await db
245 .insert(repositories)
246 .values({
247 name,
248 ownerId: user.id,
249 description: description || null,
250 isPrivate,
251 diskPath,
252 })
253 .returning();
254
255 if (newRepo) {
256 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
257 await bootstrapRepository({
258 repositoryId: newRepo.id,
259 ownerUserId: user.id,
260 defaultBranch: "main",
261 });
262 }
06d5ffeClaude263
264 return c.redirect(`/${user.username}/${name}`);
265});
266
267// User profile
fc1817aClaude268web.get("/:owner", async (c) => {
06d5ffeClaude269 const { owner: ownerName } = c.req.param();
270 const user = c.get("user");
271
272 // Avoid clashing with fixed routes
273 if (
274 ["login", "register", "logout", "new", "settings", "api"].includes(
275 ownerName
276 )
277 ) {
278 return c.notFound();
279 }
280
281 let ownerUser;
282 try {
283 const [found] = await db
284 .select()
285 .from(users)
286 .where(eq(users.username, ownerName))
287 .limit(1);
288 ownerUser = found;
289 } catch {
290 // DB not available — check if repos exist on disk
291 ownerUser = null;
292 }
293
294 // Even without DB, show repos if they exist on disk
295 let repos: any[] = [];
296 if (ownerUser) {
297 const allRepos = await db
298 .select()
299 .from(repositories)
300 .where(eq(repositories.ownerId, ownerUser.id))
301 .orderBy(desc(repositories.updatedAt));
302
303 // Show public repos to everyone, private only to owner
304 repos =
305 user?.id === ownerUser.id
306 ? allRepos
307 : allRepos.filter((r) => !r.isPrivate);
308 }
309
7aa8b99Claude310 // Block J4 — follow counts + viewer's follow state
311 let followState = {
312 followers: 0,
313 following: 0,
314 viewerFollows: false,
315 };
316 if (ownerUser) {
317 try {
318 const { followCounts, isFollowing } = await import("../lib/follows");
319 const counts = await followCounts(ownerUser.id);
320 followState.followers = counts.followers;
321 followState.following = counts.following;
322 if (user && user.id !== ownerUser.id) {
323 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
324 }
325 } catch {
326 // DB hiccup — fall back to zeros.
327 }
328 }
329 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
330
d412586Claude331 // Block J5 — profile README. Render owner/owner repo's README on the
332 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
333 // back to "<user>/.github" for org-style profile repos.
334 let profileReadmeHtml: string | null = null;
335 try {
336 const candidates = [ownerName, ".github"];
337 for (const rname of candidates) {
338 if (await repoExists(ownerName, rname)) {
339 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
340 const md = await getReadme(ownerName, rname, ref);
341 if (md) {
342 profileReadmeHtml = renderMarkdown(md);
343 break;
344 }
345 }
346 }
347 } catch {
348 profileReadmeHtml = null;
349 }
350
fc1817aClaude351 return c.html(
06d5ffeClaude352 <Layout title={ownerName} user={user}>
353 <div class="user-profile">
354 <div class="user-avatar">
355 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
356 </div>
357 <div class="user-info">
358 <h2>{ownerUser?.displayName || ownerName}</h2>
359 <div class="username">@{ownerName}</div>
360 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude361 <div
dc26881CC LABS App362 style="margin-top:var(--space-2);display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap;font-size:13px"
7aa8b99Claude363 >
364 <a
365 href={`/${ownerName}/followers`}
366 style="color:var(--text-muted)"
367 >
368 <strong style="color:var(--text)">
369 {followState.followers}
370 </strong>{" "}
371 follower{followState.followers === 1 ? "" : "s"}
372 </a>
373 <a
374 href={`/${ownerName}/following`}
375 style="color:var(--text-muted)"
376 >
377 <strong style="color:var(--text)">
378 {followState.following}
379 </strong>{" "}
380 following
381 </a>
382 {canFollow && (
383 <form
001af43Claude384 method="post"
7aa8b99Claude385 action={`/${ownerName}/${
386 followState.viewerFollows ? "unfollow" : "follow"
387 }`}
388 >
389 <button
390 type="submit"
391 class={`btn ${
392 followState.viewerFollows ? "" : "btn-primary"
393 } btn-sm`}
394 >
395 {followState.viewerFollows ? "Unfollow" : "Follow"}
396 </button>
397 </form>
398 )}
399 </div>
06d5ffeClaude400 </div>
401 </div>
d412586Claude402 {profileReadmeHtml && (
403 <div
404 class="markdown-body"
dc26881CC LABS App405 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-5) var(--space-6);margin-bottom:var(--space-6)"
d412586Claude406 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
407 />
408 )}
06d5ffeClaude409 <h3 style="margin-bottom: 16px">Repositories</h3>
410 {repos.length === 0 ? (
411 <p style="color: var(--text-muted)">No repositories yet.</p>
412 ) : (
413 <div class="card-grid">
414 {repos.map((repo) => (
415 <RepoCard repo={repo} ownerName={ownerName} />
416 ))}
417 </div>
418 )}
fc1817aClaude419 </Layout>
420 );
421});
422
06d5ffeClaude423// Star/unstar a repo
424web.post("/:owner/:repo/star", requireAuth, async (c) => {
425 const { owner: ownerName, repo: repoName } = c.req.param();
426 const user = c.get("user")!;
427
428 try {
429 const [ownerUser] = await db
430 .select()
431 .from(users)
432 .where(eq(users.username, ownerName))
433 .limit(1);
434 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
435
436 const [repo] = await db
437 .select()
438 .from(repositories)
439 .where(
440 and(
441 eq(repositories.ownerId, ownerUser.id),
442 eq(repositories.name, repoName)
443 )
444 )
445 .limit(1);
446 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
447
448 // Toggle star
449 const [existing] = await db
450 .select()
451 .from(stars)
452 .where(
453 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
454 )
455 .limit(1);
456
457 if (existing) {
458 await db.delete(stars).where(eq(stars.id, existing.id));
459 await db
460 .update(repositories)
461 .set({ starCount: Math.max(0, repo.starCount - 1) })
462 .where(eq(repositories.id, repo.id));
463 } else {
464 await db.insert(stars).values({
465 userId: user.id,
466 repositoryId: repo.id,
467 });
468 await db
469 .update(repositories)
470 .set({ starCount: repo.starCount + 1 })
471 .where(eq(repositories.id, repo.id));
472 }
473 } catch {
474 // DB error — ignore
475 }
476
477 return c.redirect(`/${ownerName}/${repoName}`);
478});
479
fc1817aClaude480// Repository overview — file tree at HEAD
481web.get("/:owner/:repo", async (c) => {
482 const { owner, repo } = c.req.param();
06d5ffeClaude483 const user = c.get("user");
fc1817aClaude484
8f50ed0Claude485 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
486 trackByName(owner, repo, "view", {
487 userId: user?.id || null,
488 path: `/${owner}/${repo}`,
489 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
490 userAgent: c.req.header("user-agent") || null,
491 referer: c.req.header("referer") || null,
a28cedeClaude492 }).catch((err) => {
493 console.warn(
494 `[web] view tracking failed for ${owner}/${repo}:`,
495 err instanceof Error ? err.message : err
496 );
497 });
8f50ed0Claude498
fc1817aClaude499 if (!(await repoExists(owner, repo))) {
500 return c.html(
06d5ffeClaude501 <Layout title="Not Found" user={user}>
fc1817aClaude502 <div class="empty-state">
503 <h2>Repository not found</h2>
504 <p>
505 {owner}/{repo} does not exist.
506 </p>
507 </div>
508 </Layout>,
509 404
510 );
511 }
512
05b973eClaude513 // Parallelize all independent operations
514 const [defaultBranch, branches] = await Promise.all([
515 getDefaultBranch(owner, repo).then((b) => b || "main"),
516 listBranches(owner, repo),
517 ]);
518 const [tree, starInfo] = await Promise.all([
519 getTree(owner, repo, defaultBranch),
520 // Star info fetched in parallel with tree
521 (async () => {
522 try {
523 const [ownerUser] = await db
524 .select()
525 .from(users)
526 .where(eq(users.username, owner))
527 .limit(1);
71cd5ecClaude528 if (!ownerUser)
529 return {
530 starCount: 0,
531 starred: false,
532 archived: false,
533 isTemplate: false,
544d842Claude534 forkCount: 0,
535 description: null as string | null,
536 pushedAt: null as Date | null,
537 createdAt: null as Date | null,
71cd5ecClaude538 };
05b973eClaude539 const [repoRow] = await db
540 .select()
541 .from(repositories)
542 .where(
543 and(
544 eq(repositories.ownerId, ownerUser.id),
545 eq(repositories.name, repo)
546 )
06d5ffeClaude547 )
05b973eClaude548 .limit(1);
71cd5ecClaude549 if (!repoRow)
550 return {
551 starCount: 0,
552 starred: false,
553 archived: false,
554 isTemplate: false,
544d842Claude555 forkCount: 0,
556 description: null as string | null,
557 pushedAt: null as Date | null,
558 createdAt: null as Date | null,
71cd5ecClaude559 };
05b973eClaude560 let starred = false;
06d5ffeClaude561 if (user) {
562 const [star] = await db
563 .select()
564 .from(stars)
565 .where(
566 and(
567 eq(stars.userId, user.id),
568 eq(stars.repositoryId, repoRow.id)
569 )
570 )
571 .limit(1);
572 starred = !!star;
573 }
71cd5ecClaude574 return {
575 starCount: repoRow.starCount,
576 starred,
577 archived: repoRow.isArchived,
578 isTemplate: repoRow.isTemplate,
544d842Claude579 forkCount: repoRow.forkCount,
580 description: repoRow.description as string | null,
581 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
582 createdAt: (repoRow.createdAt as Date | null) ?? null,
71cd5ecClaude583 };
05b973eClaude584 } catch {
71cd5ecClaude585 return {
586 starCount: 0,
587 starred: false,
588 archived: false,
589 isTemplate: false,
544d842Claude590 forkCount: 0,
591 description: null as string | null,
592 pushedAt: null as Date | null,
593 createdAt: null as Date | null,
71cd5ecClaude594 };
06d5ffeClaude595 }
05b973eClaude596 })(),
597 ]);
544d842Claude598 const {
599 starCount,
600 starred,
601 archived,
602 isTemplate,
603 forkCount,
604 description,
605 pushedAt,
606 createdAt,
607 } = starInfo;
608
609 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
610 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
611 const repoHomeCss = `
612 .repo-home-hero {
613 position: relative;
614 margin-bottom: var(--space-5);
615 padding: var(--space-5) var(--space-6);
616 background: var(--bg-elevated);
617 border: 1px solid var(--border);
618 border-radius: 16px;
619 overflow: hidden;
620 }
621 .repo-home-hero::before {
622 content: '';
623 position: absolute;
624 top: 0; left: 0; right: 0;
625 height: 2px;
626 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
627 opacity: 0.7;
628 pointer-events: none;
629 }
630 .repo-home-hero-orb-wrap {
631 position: absolute;
632 inset: -25% -10% auto auto;
633 width: 360px;
634 height: 360px;
635 pointer-events: none;
636 z-index: 0;
637 }
638 .repo-home-hero-orb {
639 position: absolute;
640 inset: 0;
641 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
642 filter: blur(80px);
643 opacity: 0.7;
644 animation: repoHomeOrb 14s ease-in-out infinite;
645 }
646 @keyframes repoHomeOrb {
647 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
648 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
649 }
650 @media (prefers-reduced-motion: reduce) {
651 .repo-home-hero-orb { animation: none; }
652 }
653 .repo-home-hero-inner {
654 position: relative;
655 z-index: 1;
656 }
657 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
658 .repo-home-eyebrow {
659 font-size: 12px;
660 font-family: var(--font-mono);
661 color: var(--text-muted);
662 letter-spacing: 0.1em;
663 text-transform: uppercase;
664 margin-bottom: var(--space-2);
665 }
666 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
667 .repo-home-description {
668 font-size: 15px;
669 line-height: 1.55;
670 color: var(--text);
671 margin: 0;
672 max-width: 720px;
673 }
674 .repo-home-description-empty {
675 font-size: 14px;
676 color: var(--text-muted);
677 font-style: italic;
678 margin: 0;
679 }
680 .repo-home-stat-row {
681 display: flex;
682 flex-wrap: wrap;
683 gap: var(--space-4);
684 margin-top: var(--space-3);
685 font-size: 13px;
686 color: var(--text-muted);
687 }
688 .repo-home-stat {
689 display: inline-flex;
690 align-items: center;
691 gap: 6px;
692 }
693 .repo-home-stat strong {
694 color: var(--text-strong);
695 font-weight: 600;
696 font-variant-numeric: tabular-nums;
697 }
698 .repo-home-stat .repo-home-stat-icon {
699 color: var(--text-faint);
700 font-size: 14px;
701 line-height: 1;
702 }
703 .repo-home-stat a {
704 color: var(--text-muted);
705 transition: color var(--t-fast) var(--ease);
706 }
707 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
708
709 /* Two-column layout: file tree + sidebar */
710 .repo-home-grid {
711 display: grid;
712 grid-template-columns: minmax(0, 1fr) 280px;
713 gap: var(--space-5);
714 align-items: start;
715 }
716 @media (max-width: 960px) {
717 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
718 }
719 .repo-home-main { min-width: 0; }
720
721 /* Sidebar card */
722 .repo-home-side {
723 display: flex;
724 flex-direction: column;
725 gap: var(--space-4);
726 }
727 .repo-home-side-card {
728 background: var(--bg-elevated);
729 border: 1px solid var(--border);
730 border-radius: 12px;
731 padding: var(--space-4);
732 }
733 .repo-home-side-title {
734 font-size: 11px;
735 font-family: var(--font-mono);
736 letter-spacing: 0.12em;
737 text-transform: uppercase;
738 color: var(--text-muted);
739 margin: 0 0 var(--space-3);
740 font-weight: 600;
741 }
742 .repo-home-side-row {
743 display: flex;
744 justify-content: space-between;
745 align-items: center;
746 gap: var(--space-2);
747 font-size: 13px;
748 padding: 6px 0;
749 border-top: 1px solid var(--border);
750 }
751 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
752 .repo-home-side-key {
753 color: var(--text-muted);
754 display: inline-flex;
755 align-items: center;
756 gap: 6px;
757 }
758 .repo-home-side-val {
759 color: var(--text-strong);
760 font-weight: 500;
761 font-variant-numeric: tabular-nums;
762 max-width: 60%;
763 text-align: right;
764 overflow: hidden;
765 text-overflow: ellipsis;
766 white-space: nowrap;
767 }
768 .repo-home-side-val a { color: var(--text-strong); }
769 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
770
771 /* Clone / Code tabs */
772 .repo-home-clone {
773 background: var(--bg-elevated);
774 border: 1px solid var(--border);
775 border-radius: 12px;
776 overflow: hidden;
777 }
778 .repo-home-clone-tabs {
779 display: flex;
780 gap: 0;
781 background: var(--bg-secondary);
782 border-bottom: 1px solid var(--border);
783 padding: 0 var(--space-2);
784 }
785 .repo-home-clone-tab {
786 appearance: none;
787 background: transparent;
788 border: 0;
789 border-bottom: 2px solid transparent;
790 padding: 9px 12px;
791 font-size: 12px;
792 font-weight: 500;
793 color: var(--text-muted);
794 cursor: pointer;
795 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
796 font-family: inherit;
797 margin-bottom: -1px;
798 }
799 .repo-home-clone-tab:hover { color: var(--text-strong); }
800 .repo-home-clone-tab[aria-selected="true"] {
801 color: var(--text-strong);
802 border-bottom-color: var(--accent);
803 }
804 .repo-home-clone-body {
805 padding: var(--space-3);
806 display: flex;
807 align-items: center;
808 gap: var(--space-2);
809 }
810 .repo-home-clone-input {
811 flex: 1;
812 min-width: 0;
813 font-family: var(--font-mono);
814 font-size: 12px;
815 background: var(--bg);
816 border: 1px solid var(--border);
817 border-radius: 8px;
818 padding: 8px 10px;
819 color: var(--text-strong);
820 overflow: hidden;
821 text-overflow: ellipsis;
822 white-space: nowrap;
823 }
824 .repo-home-clone-copy {
825 appearance: none;
826 background: var(--bg-secondary);
827 border: 1px solid var(--border);
828 color: var(--text-strong);
829 border-radius: 8px;
830 padding: 8px 12px;
831 font-size: 12px;
832 font-weight: 600;
833 cursor: pointer;
834 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
835 font-family: inherit;
836 }
837 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
838 .repo-home-clone-pane { display: none; }
839 .repo-home-clone-pane[data-active="true"] { display: flex; }
840
841 /* README card */
842 .repo-home-readme {
843 margin-top: var(--space-5);
844 background: var(--bg-elevated);
845 border: 1px solid var(--border);
846 border-radius: 12px;
847 overflow: hidden;
848 }
849 .repo-home-readme-head {
850 display: flex;
851 align-items: center;
852 gap: 8px;
853 padding: 10px 16px;
854 background: var(--bg-secondary);
855 border-bottom: 1px solid var(--border);
856 font-size: 13px;
857 color: var(--text-muted);
858 }
859 .repo-home-readme-head .repo-home-readme-icon {
860 color: var(--accent);
861 font-size: 14px;
862 }
863 .repo-home-readme-body {
864 padding: var(--space-5) var(--space-6);
865 }
866
867 /* Empty-state CTA */
868 .repo-home-empty {
869 position: relative;
870 margin-top: var(--space-4);
871 background: var(--bg-elevated);
872 border: 1px solid var(--border);
873 border-radius: 16px;
874 padding: var(--space-6);
875 overflow: hidden;
876 }
877 .repo-home-empty::before {
878 content: '';
879 position: absolute;
880 top: 0; left: 0; right: 0;
881 height: 2px;
882 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
883 opacity: 0.7;
884 pointer-events: none;
885 }
886 .repo-home-empty-eyebrow {
887 font-size: 12px;
888 font-family: var(--font-mono);
889 color: var(--accent);
890 letter-spacing: 0.12em;
891 text-transform: uppercase;
892 margin-bottom: var(--space-2);
893 font-weight: 600;
894 }
895 .repo-home-empty-title {
896 font-family: var(--font-display);
897 font-weight: 800;
898 letter-spacing: -0.025em;
899 font-size: clamp(22px, 3vw, 30px);
900 line-height: 1.1;
901 margin: 0 0 var(--space-2);
902 color: var(--text-strong);
903 }
904 .repo-home-empty-sub {
905 color: var(--text-muted);
906 font-size: 14px;
907 line-height: 1.55;
908 max-width: 640px;
909 margin: 0 0 var(--space-4);
910 }
911 .repo-home-empty-snippet {
912 background: var(--bg);
913 border: 1px solid var(--border);
914 border-radius: 10px;
915 padding: var(--space-3) var(--space-4);
916 font-family: var(--font-mono);
917 font-size: 12.5px;
918 line-height: 1.7;
919 color: var(--text-strong);
920 overflow-x: auto;
921 white-space: pre;
922 margin: 0;
923 }
924 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
925 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
926
927 @media (max-width: 720px) {
928 .repo-home-hero { padding: var(--space-4) var(--space-4); }
929 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
930 .repo-home-clone-copy { width: 100%; }
931 }
932 `;
933 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
934 // Best-effort SSH URL. If APP_BASE_URL is a hostname, this looks right; for
935 // localhost it'll still render and just be slightly silly (which is fine for dev).
936 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
937 try {
938 const host = new URL(config.appBaseUrl).hostname;
939 if (host && host !== "localhost" && host !== "127.0.0.1") {
940 cloneSshUrl = `git@${host}:${owner}/${repo}.git`;
941 }
942 } catch {
943 // Fall through to default.
944 }
945 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
946 const formatRelative = (date: Date | null): string => {
947 if (!date) return "never";
948 const ms = Date.now() - date.getTime();
949 const s = Math.max(0, Math.round(ms / 1000));
950 if (s < 60) return "just now";
951 const m = Math.round(s / 60);
952 if (m < 60) return `${m} min ago`;
953 const h = Math.round(m / 60);
954 if (h < 24) return `${h}h ago`;
955 const d = Math.round(h / 24);
956 if (d < 30) return `${d}d ago`;
957 const mo = Math.round(d / 30);
958 if (mo < 12) return `${mo}mo ago`;
959 const y = Math.round(d / 365);
960 return `${y}y ago`;
961 };
06d5ffeClaude962
fc1817aClaude963 if (tree.length === 0) {
964 return c.html(
06d5ffeClaude965 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude966 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
967 <div class="repo-home-hero">
968 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
969 <div class="repo-home-hero-orb" />
970 </div>
971 <div class="repo-home-hero-inner">
972 <div class="repo-home-eyebrow">
973 <strong>Repository</strong> · {owner}
974 </div>
975 <RepoHeader
976 owner={owner}
977 repo={repo}
978 starCount={starCount}
979 starred={starred}
980 forkCount={forkCount}
981 currentUser={user?.username}
982 archived={archived}
983 isTemplate={isTemplate}
984 />
985 {description ? (
986 <p class="repo-home-description">{description}</p>
987 ) : (
988 <p class="repo-home-description-empty">
989 No description yet — push a README to tell the world what this
990 ships.
991 </p>
992 )}
993 </div>
994 </div>
fc1817aClaude995 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude996 <div class="repo-home-empty">
997 <div class="repo-home-empty-eyebrow">Getting started</div>
998 <h2 class="repo-home-empty-title">
999 Push your first commit to{" "}
1000 <span class="gradient-text">{repo}</span>.
1001 </h2>
1002 <p class="repo-home-empty-sub">
1003 This repository is empty. Paste the snippet below in an existing
1004 project directory to wire it up to Gluecron — your push triggers
1005 gate checks and AI review automatically.
1006 </p>
1007 <pre class="repo-home-empty-snippet">
1008 <span class="repo-home-cmt">
1009 # from an existing project directory
1010 </span>
1011 {"\n"}
1012 <span class="repo-home-cmd">git remote add</span>
1013 {` origin ${cloneHttpsUrl}`}
1014 {"\n"}
1015 <span class="repo-home-cmd">git branch</span>
1016 {` -M main`}
1017 {"\n"}
1018 <span class="repo-home-cmd">git push</span>
1019 {` -u origin main`}
1020 </pre>
fc1817aClaude1021 </div>
1022 </Layout>
1023 );
1024 }
1025
1026 const readme = await getReadme(owner, repo, defaultBranch);
1027
544d842Claude1028 // Sidebar facts — derived from data we already have.
1029 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
1030 const dirCount = tree.filter((e: any) => e.type === "tree").length;
1031
fc1817aClaude1032 return c.html(
06d5ffeClaude1033 <Layout title={`${owner}/${repo}`} user={user}>
544d842Claude1034 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
1035 <div class="repo-home-hero">
1036 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
1037 <div class="repo-home-hero-orb" />
1038 </div>
1039 <div class="repo-home-hero-inner">
1040 <div class="repo-home-eyebrow">
1041 <strong>Repository</strong> · {owner}
1042 </div>
1043 <RepoHeader
1044 owner={owner}
1045 repo={repo}
1046 starCount={starCount}
1047 starred={starred}
1048 forkCount={forkCount}
1049 currentUser={user?.username}
1050 archived={archived}
1051 isTemplate={isTemplate}
1052 />
1053 {description ? (
1054 <p class="repo-home-description">{description}</p>
1055 ) : (
1056 <p class="repo-home-description-empty">
1057 No description yet.
1058 </p>
1059 )}
1060 <div class="repo-home-stat-row" aria-label="Repository stats">
1061 <span class="repo-home-stat" title="Default branch">
1062 <span class="repo-home-stat-icon">{"⎇"}</span>
1063 <strong>{defaultBranch}</strong>
1064 </span>
1065 <a
1066 href={`/${owner}/${repo}/commits/${defaultBranch}`}
1067 class="repo-home-stat"
1068 title="Browse all branches"
1069 >
1070 <span class="repo-home-stat-icon">{"⊢"}</span>
1071 <strong>{branches.length}</strong>{" "}
1072 branch{branches.length === 1 ? "" : "es"}
1073 </a>
1074 <span class="repo-home-stat" title="Top-level entries">
1075 <span class="repo-home-stat-icon">{"■"}</span>
1076 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
1077 {dirCount > 0 && (
1078 <>
1079 {" · "}
1080 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
1081 </>
1082 )}
1083 </span>
1084 {pushedAt && (
1085 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
1086 <span class="repo-home-stat-icon">{"↻"}</span>
1087 Updated <strong>{formatRelative(pushedAt)}</strong>
1088 </span>
1089 )}
1090 </div>
1091 </div>
1092 </div>
71cd5ecClaude1093 {isTemplate && user && user.username !== owner && (
1094 <div
1095 class="panel"
dc26881CC LABS App1096 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude1097 >
1098 <div style="font-size:13px">
1099 <strong>Template repository.</strong> Create a new repository from
1100 this template's files.
1101 </div>
1102 <form
001af43Claude1103 method="post"
71cd5ecClaude1104 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App1105 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude1106 >
1107 <input
1108 type="text"
1109 name="name"
1110 placeholder="new-repo-name"
1111 required
2c3ba6ecopilot-swe-agent[bot]1112 aria-label="New repository name"
71cd5ecClaude1113 style="width:200px"
1114 />
1115 <button type="submit" class="btn btn-primary">
1116 Use this template
1117 </button>
1118 </form>
1119 </div>
1120 )}
fc1817aClaude1121 <RepoNav owner={owner} repo={repo} active="code" />
544d842Claude1122 <div class="repo-home-grid">
1123 <div class="repo-home-main">
1124 <BranchSwitcher
1125 owner={owner}
1126 repo={repo}
1127 currentRef={defaultBranch}
1128 branches={branches}
1129 pathType="tree"
1130 />
1131 <FileTable
1132 entries={tree}
1133 owner={owner}
1134 repo={repo}
1135 ref={defaultBranch}
1136 path=""
1137 />
1138 {readme && (() => {
1139 const readmeHtml = renderMarkdown(readme);
1140 return (
1141 <div class="repo-home-readme">
1142 <div class="repo-home-readme-head">
1143 <span class="repo-home-readme-icon">{"☰"}</span>
1144 <span>README.md</span>
1145 </div>
1146 <style>{markdownCss}</style>
1147 <div class="markdown-body repo-home-readme-body">
1148 {html([readmeHtml] as unknown as TemplateStringsArray)}
1149 </div>
1150 </div>
1151 );
1152 })()}
1153 </div>
1154 <aside class="repo-home-side" aria-label="Repository details">
1155 <div class="repo-home-clone">
1156 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
1157 <button
1158 type="button"
1159 class="repo-home-clone-tab"
1160 role="tab"
1161 aria-selected="true"
1162 data-pane="https"
1163 data-repo-home-clone-tab
1164 >
1165 HTTPS
1166 </button>
1167 <button
1168 type="button"
1169 class="repo-home-clone-tab"
1170 role="tab"
1171 aria-selected="false"
1172 data-pane="ssh"
1173 data-repo-home-clone-tab
1174 >
1175 SSH
1176 </button>
1177 <button
1178 type="button"
1179 class="repo-home-clone-tab"
1180 role="tab"
1181 aria-selected="false"
1182 data-pane="cli"
1183 data-repo-home-clone-tab
1184 >
1185 CLI
1186 </button>
1187 </div>
1188 <div
1189 class="repo-home-clone-pane"
1190 data-pane="https"
1191 data-active="true"
1192 role="tabpanel"
1193 >
1194 <div class="repo-home-clone-body">
1195 <input
1196 class="repo-home-clone-input"
1197 type="text"
1198 value={cloneHttpsUrl}
1199 readonly
1200 aria-label="HTTPS clone URL"
1201 data-repo-home-clone-input
1202 />
1203 <button
1204 type="button"
1205 class="repo-home-clone-copy"
1206 data-repo-home-copy={cloneHttpsUrl}
1207 >
1208 Copy
1209 </button>
1210 </div>
1211 </div>
1212 <div
1213 class="repo-home-clone-pane"
1214 data-pane="ssh"
1215 data-active="false"
1216 role="tabpanel"
1217 >
1218 <div class="repo-home-clone-body">
1219 <input
1220 class="repo-home-clone-input"
1221 type="text"
1222 value={cloneSshUrl}
1223 readonly
1224 aria-label="SSH clone URL"
1225 data-repo-home-clone-input
1226 />
1227 <button
1228 type="button"
1229 class="repo-home-clone-copy"
1230 data-repo-home-copy={cloneSshUrl}
1231 >
1232 Copy
1233 </button>
1234 </div>
1235 </div>
1236 <div
1237 class="repo-home-clone-pane"
1238 data-pane="cli"
1239 data-active="false"
1240 role="tabpanel"
1241 >
1242 <div class="repo-home-clone-body">
1243 <input
1244 class="repo-home-clone-input"
1245 type="text"
1246 value={cloneCliCmd}
1247 readonly
1248 aria-label="Gluecron CLI clone command"
1249 data-repo-home-clone-input
1250 />
1251 <button
1252 type="button"
1253 class="repo-home-clone-copy"
1254 data-repo-home-copy={cloneCliCmd}
1255 >
1256 Copy
1257 </button>
1258 </div>
79136bbClaude1259 </div>
fc1817aClaude1260 </div>
544d842Claude1261 <div class="repo-home-side-card">
1262 <h3 class="repo-home-side-title">About</h3>
1263 <div class="repo-home-side-row">
1264 <span class="repo-home-side-key">Default branch</span>
1265 <span class="repo-home-side-val">{defaultBranch}</span>
1266 </div>
1267 <div class="repo-home-side-row">
1268 <span class="repo-home-side-key">Branches</span>
1269 <span class="repo-home-side-val">
1270 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
1271 {branches.length}
1272 </a>
1273 </span>
1274 </div>
1275 <div class="repo-home-side-row">
1276 <span class="repo-home-side-key">Stars</span>
1277 <span class="repo-home-side-val">{starCount}</span>
1278 </div>
1279 <div class="repo-home-side-row">
1280 <span class="repo-home-side-key">Forks</span>
1281 <span class="repo-home-side-val">{forkCount}</span>
1282 </div>
1283 {pushedAt && (
1284 <div class="repo-home-side-row">
1285 <span class="repo-home-side-key">Last push</span>
1286 <span class="repo-home-side-val">
1287 {formatRelative(pushedAt)}
1288 </span>
1289 </div>
1290 )}
1291 {createdAt && (
1292 <div class="repo-home-side-row">
1293 <span class="repo-home-side-key">Created</span>
1294 <span class="repo-home-side-val">
1295 {formatRelative(createdAt)}
1296 </span>
1297 </div>
1298 )}
1299 {(archived || isTemplate) && (
1300 <div class="repo-home-side-row">
1301 <span class="repo-home-side-key">State</span>
1302 <span class="repo-home-side-val">
1303 {archived ? "Archived" : "Template"}
1304 </span>
1305 </div>
1306 )}
1307 </div>
1308 </aside>
1309 </div>
1310 <script
1311 dangerouslySetInnerHTML={{
1312 __html: `
1313 (function(){
1314 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
1315 tabs.forEach(function(tab){
1316 tab.addEventListener('click', function(){
1317 var target = tab.getAttribute('data-pane');
1318 tabs.forEach(function(t){
1319 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
1320 });
1321 var panes = document.querySelectorAll('.repo-home-clone-pane');
1322 panes.forEach(function(p){
1323 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
1324 });
1325 });
1326 });
1327 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
1328 copyBtns.forEach(function(btn){
1329 btn.addEventListener('click', function(){
1330 var text = btn.getAttribute('data-repo-home-copy') || '';
1331 var done = function(){
1332 var prev = btn.textContent;
1333 btn.textContent = 'Copied';
1334 setTimeout(function(){ btn.textContent = prev; }, 1200);
1335 };
1336 if (navigator.clipboard && navigator.clipboard.writeText) {
1337 navigator.clipboard.writeText(text).then(done, done);
1338 } else {
1339 var ta = document.createElement('textarea');
1340 ta.value = text;
1341 document.body.appendChild(ta);
1342 ta.select();
1343 try { document.execCommand('copy'); } catch (e) {}
1344 document.body.removeChild(ta);
1345 done();
1346 }
1347 });
1348 });
1349 })();
1350 `,
1351 }}
1352 />
fc1817aClaude1353 </Layout>
1354 );
1355});
1356
1357// Browse tree at ref/path
1358web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
1359 const { owner, repo } = c.req.param();
06d5ffeClaude1360 const user = c.get("user");
fc1817aClaude1361 const refAndPath = c.req.param("ref");
1362
1363 const branches = await listBranches(owner, repo);
1364 let ref = "";
1365 let treePath = "";
1366
1367 for (const branch of branches) {
1368 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
1369 ref = branch;
1370 treePath = refAndPath.slice(branch.length + 1);
1371 break;
1372 }
1373 }
1374
1375 if (!ref) {
1376 const slashIdx = refAndPath.indexOf("/");
1377 if (slashIdx === -1) {
1378 ref = refAndPath;
1379 } else {
1380 ref = refAndPath.slice(0, slashIdx);
1381 treePath = refAndPath.slice(slashIdx + 1);
1382 }
1383 }
1384
1385 const tree = await getTree(owner, repo, ref, treePath);
1386
1387 return c.html(
06d5ffeClaude1388 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude1389 <RepoHeader owner={owner} repo={repo} />
1390 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude1391 <BranchSwitcher
1392 owner={owner}
1393 repo={repo}
1394 currentRef={ref}
1395 branches={branches}
1396 pathType="tree"
1397 subPath={treePath}
1398 />
fc1817aClaude1399 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
1400 <FileTable
1401 entries={tree}
1402 owner={owner}
1403 repo={repo}
1404 ref={ref}
1405 path={treePath}
1406 />
1407 </Layout>
1408 );
1409});
1410
06d5ffeClaude1411// View file blob with syntax highlighting
fc1817aClaude1412web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
1413 const { owner, repo } = c.req.param();
06d5ffeClaude1414 const user = c.get("user");
fc1817aClaude1415 const refAndPath = c.req.param("ref");
1416
1417 const branches = await listBranches(owner, repo);
1418 let ref = "";
1419 let filePath = "";
1420
1421 for (const branch of branches) {
1422 if (refAndPath.startsWith(branch + "/")) {
1423 ref = branch;
1424 filePath = refAndPath.slice(branch.length + 1);
1425 break;
1426 }
1427 }
1428
1429 if (!ref) {
1430 const slashIdx = refAndPath.indexOf("/");
1431 if (slashIdx === -1) return c.text("Not found", 404);
1432 ref = refAndPath.slice(0, slashIdx);
1433 filePath = refAndPath.slice(slashIdx + 1);
1434 }
1435
1436 const blob = await getBlob(owner, repo, ref, filePath);
1437 if (!blob) {
1438 return c.html(
06d5ffeClaude1439 <Layout title="Not Found" user={user}>
fc1817aClaude1440 <div class="empty-state">
1441 <h2>File not found</h2>
1442 </div>
1443 </Layout>,
1444 404
1445 );
1446 }
1447
06d5ffeClaude1448 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude1449
1450 return c.html(
06d5ffeClaude1451 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude1452 <RepoHeader owner={owner} repo={repo} />
1453 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude1454 <BranchSwitcher
1455 owner={owner}
1456 repo={repo}
1457 currentRef={ref}
1458 branches={branches}
1459 pathType="blob"
1460 subPath={filePath}
1461 />
fc1817aClaude1462 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1463 <div class="blob-view">
1464 <div class="blob-header">
06d5ffeClaude1465 <span>{fileName} — {blob.size} bytes</span>
dc26881CC LABS App1466 <span style="display: flex; gap: var(--space-3)">
79136bbClaude1467 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
1468 Raw
1469 </a>
1470 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
1471 Blame
1472 </a>
16b325cClaude1473 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
1474 History
1475 </a>
0074234Claude1476 {user && (
1477 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
1478 Edit
1479 </a>
1480 )}
79136bbClaude1481 </span>
fc1817aClaude1482 </div>
1483 {blob.isBinary ? (
dc26881CC LABS App1484 <div style="padding: var(--space-4); color: var(--text-muted)">
fc1817aClaude1485 Binary file not shown.
1486 </div>
06d5ffeClaude1487 ) : (() => {
1488 const { html: highlighted, language } = highlightCode(
1489 blob.content,
1490 fileName
1491 );
1492 const lineCount = blob.content.split("\n").length;
1493 // Trim trailing newline from count
1494 const adjustedCount =
1495 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
1496
1497 if (language) {
1498 return (
1499 <HighlightedCode
1500 highlightedHtml={highlighted}
1501 lineCount={adjustedCount}
1502 />
1503 );
1504 }
1505 const lines = blob.content.split("\n");
1506 if (lines[lines.length - 1] === "") lines.pop();
1507 return <PlainCode lines={lines} />;
1508 })()}
fc1817aClaude1509 </div>
1510 </Layout>
1511 );
1512});
1513
1514// Commit log
1515web.get("/:owner/:repo/commits/:ref?", async (c) => {
1516 const { owner, repo } = c.req.param();
06d5ffeClaude1517 const user = c.get("user");
fc1817aClaude1518 const ref =
1519 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude1520 const branches = await listBranches(owner, repo);
fc1817aClaude1521
1522 const commits = await listCommits(owner, repo, ref, 50);
1523
3951454Claude1524 // Block J3 — batch-fetch cached verification results for the page.
1525 let verifications: Record<string, { verified: boolean; reason: string }> = {};
1526 try {
1527 const [ownerRow] = await db
1528 .select()
1529 .from(users)
1530 .where(eq(users.username, owner))
1531 .limit(1);
1532 if (ownerRow) {
1533 const [repoRow] = await db
1534 .select()
1535 .from(repositories)
1536 .where(
1537 and(
1538 eq(repositories.ownerId, ownerRow.id),
1539 eq(repositories.name, repo)
1540 )
1541 )
1542 .limit(1);
1543 if (repoRow && commits.length > 0) {
1544 const rows = await db
1545 .select()
1546 .from(commitVerifications)
1547 .where(
1548 and(
1549 eq(commitVerifications.repositoryId, repoRow.id),
1550 inArray(
1551 commitVerifications.commitSha,
1552 commits.map((c) => c.sha)
1553 )
1554 )
1555 );
1556 for (const r of rows) {
1557 verifications[r.commitSha] = {
1558 verified: r.verified,
1559 reason: r.reason,
1560 };
1561 }
1562 }
1563 }
1564 } catch {
1565 // DB unavailable — skip the badges gracefully.
1566 }
1567
fc1817aClaude1568 return c.html(
06d5ffeClaude1569 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude1570 <RepoHeader owner={owner} repo={repo} />
1571 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude1572 <BranchSwitcher
1573 owner={owner}
1574 repo={repo}
1575 currentRef={ref}
1576 branches={branches}
1577 pathType="commits"
1578 />
fc1817aClaude1579 {commits.length === 0 ? (
1580 <div class="empty-state">
1581 <p>No commits yet.</p>
1582 </div>
1583 ) : (
3951454Claude1584 <CommitList
1585 commits={commits}
1586 owner={owner}
1587 repo={repo}
1588 verifications={verifications}
1589 />
fc1817aClaude1590 )}
1591 </Layout>
1592 );
1593});
1594
1595// Single commit with diff
1596web.get("/:owner/:repo/commit/:sha", async (c) => {
1597 const { owner, repo, sha } = c.req.param();
06d5ffeClaude1598 const user = c.get("user");
fc1817aClaude1599
05b973eClaude1600 // Fetch commit, full message, and diff in parallel
1601 const [commit, fullMessage, diffResult] = await Promise.all([
1602 getCommit(owner, repo, sha),
1603 getCommitFullMessage(owner, repo, sha),
1604 getDiff(owner, repo, sha),
1605 ]);
fc1817aClaude1606 if (!commit) {
1607 return c.html(
06d5ffeClaude1608 <Layout title="Not Found" user={user}>
fc1817aClaude1609 <div class="empty-state">
1610 <h2>Commit not found</h2>
1611 </div>
1612 </Layout>,
1613 404
1614 );
1615 }
1616
3951454Claude1617 // Block J3 — try to verify this commit's signature.
1618 let verification:
1619 | { verified: boolean; reason: string; signatureType: string | null }
1620 | null = null;
0cdfd89Claude1621 // Block J8 — external CI commit statuses rollup.
1622 let statusCombined:
1623 | {
1624 state: "pending" | "success" | "failure";
1625 total: number;
1626 contexts: Array<{
1627 context: string;
1628 state: string;
1629 description: string | null;
1630 targetUrl: string | null;
1631 }>;
1632 }
1633 | null = null;
3951454Claude1634 try {
1635 const [ownerRow] = await db
1636 .select()
1637 .from(users)
1638 .where(eq(users.username, owner))
1639 .limit(1);
1640 if (ownerRow) {
1641 const [repoRow] = await db
1642 .select()
1643 .from(repositories)
1644 .where(
1645 and(
1646 eq(repositories.ownerId, ownerRow.id),
1647 eq(repositories.name, repo)
1648 )
1649 )
1650 .limit(1);
1651 if (repoRow) {
1652 const { verifyCommit } = await import("../lib/signatures");
1653 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
1654 verification = {
1655 verified: v.verified,
1656 reason: v.reason,
1657 signatureType: v.signatureType,
1658 };
0cdfd89Claude1659 try {
1660 const { combinedStatus } = await import("../lib/commit-statuses");
1661 const combined = await combinedStatus(repoRow.id, commit.sha);
1662 if (combined.total > 0) {
1663 statusCombined = {
1664 state: combined.state as any,
1665 total: combined.total,
1666 contexts: combined.contexts.map((c) => ({
1667 context: c.context,
1668 state: c.state,
1669 description: c.description,
1670 targetUrl: c.targetUrl,
1671 })),
1672 };
1673 }
1674 } catch {
1675 statusCombined = null;
1676 }
3951454Claude1677 }
1678 }
1679 } catch {
1680 verification = null;
1681 }
1682
05b973eClaude1683 const { files, raw } = diffResult;
fc1817aClaude1684
1685 return c.html(
06d5ffeClaude1686 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude1687 <RepoHeader owner={owner} repo={repo} />
1688 <div
dc26881CC LABS App1689 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); margin-bottom: var(--space-5)"
fc1817aClaude1690 >
1691 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1692 {commit.message}
1693 </div>
1694 {fullMessage !== commit.message && (
1695 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1696 {fullMessage}
1697 </div>
1698 )}
1699 <div style="font-size: 13px; color: var(--text-muted)">
1700 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1701 committed on{" "}
1702 {new Date(commit.date).toLocaleDateString("en-US", {
1703 month: "long",
1704 day: "numeric",
1705 year: "numeric",
1706 })}
3951454Claude1707 {verification && verification.reason !== "unsigned" && (
1708 <span
1709 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1710 verification.verified
1711 ? "var(--green,#2ea043)"
1712 : "var(--yellow,#d29922)"
1713 }`}
1714 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1715 >
1716 {verification.verified ? "Verified" : verification.reason}
1717 </span>
1718 )}
fc1817aClaude1719 </div>
1720 <div style="margin-top: 8px">
1721 <span class="commit-sha">{commit.sha}</span>
1722 {commit.parentShas.length > 0 && (
1723 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1724 Parent:{" "}
1725 {commit.parentShas.map((p) => (
1726 <a
1727 href={`/${owner}/${repo}/commit/${p}`}
1728 class="commit-sha"
1729 style="margin-left: 4px"
1730 >
1731 {p.slice(0, 7)}
1732 </a>
1733 ))}
1734 </span>
1735 )}
1736 </div>
0cdfd89Claude1737 {statusCombined && (
1738 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1739 <strong style="color: var(--text)">Checks</strong>
1740 <span style="margin-left: 8px; color: var(--text-muted)">
1741 {statusCombined.total} total —{" "}
1742 <span
1743 style={`color:${
1744 statusCombined.state === "success"
1745 ? "var(--green,#2ea043)"
1746 : statusCombined.state === "failure"
1747 ? "var(--red,#da3633)"
1748 : "var(--yellow,#d29922)"
1749 }`}
1750 >
1751 {statusCombined.state}
1752 </span>
1753 </span>
1754 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1755 {statusCombined.contexts.map((cx) => (
1756 <span
1757 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1758 cx.state === "success"
1759 ? "var(--green,#2ea043)"
1760 : cx.state === "pending"
1761 ? "var(--yellow,#d29922)"
1762 : "var(--red,#da3633)"
1763 }`}
1764 title={cx.description || cx.context}
1765 >
1766 {cx.targetUrl ? (
1767 <a
1768 href={cx.targetUrl}
1769 style="color: inherit; text-decoration: none"
1770 rel="noopener"
1771 >
1772 {cx.context}: {cx.state}
1773 </a>
1774 ) : (
1775 <>
1776 {cx.context}: {cx.state}
1777 </>
1778 )}
1779 </span>
1780 ))}
1781 </div>
1782 </div>
1783 )}
fc1817aClaude1784 </div>
1785 <DiffView raw={raw} files={files} />
1786 </Layout>
1787 );
1788});
1789
79136bbClaude1790// Raw file download
1791web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1792 const { owner, repo } = c.req.param();
1793 const refAndPath = c.req.param("ref");
1794
1795 const branches = await listBranches(owner, repo);
1796 let ref = "";
1797 let filePath = "";
1798
1799 for (const branch of branches) {
1800 if (refAndPath.startsWith(branch + "/")) {
1801 ref = branch;
1802 filePath = refAndPath.slice(branch.length + 1);
1803 break;
1804 }
1805 }
1806
1807 if (!ref) {
1808 const slashIdx = refAndPath.indexOf("/");
1809 if (slashIdx === -1) return c.text("Not found", 404);
1810 ref = refAndPath.slice(0, slashIdx);
1811 filePath = refAndPath.slice(slashIdx + 1);
1812 }
1813
1814 const data = await getRawBlob(owner, repo, ref, filePath);
1815 if (!data) return c.text("Not found", 404);
1816
1817 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1818 return new Response(data as BodyInit, {
79136bbClaude1819 headers: {
1820 "Content-Type": "application/octet-stream",
1821 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1822 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1823 },
1824 });
1825});
1826
1827// Blame view
1828web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1829 const { owner, repo } = c.req.param();
1830 const user = c.get("user");
1831 const refAndPath = c.req.param("ref");
1832
1833 const branches = await listBranches(owner, repo);
1834 let ref = "";
1835 let filePath = "";
1836
1837 for (const branch of branches) {
1838 if (refAndPath.startsWith(branch + "/")) {
1839 ref = branch;
1840 filePath = refAndPath.slice(branch.length + 1);
1841 break;
1842 }
1843 }
1844
1845 if (!ref) {
1846 const slashIdx = refAndPath.indexOf("/");
1847 if (slashIdx === -1) return c.text("Not found", 404);
1848 ref = refAndPath.slice(0, slashIdx);
1849 filePath = refAndPath.slice(slashIdx + 1);
1850 }
1851
1852 const blameLines = await getBlame(owner, repo, ref, filePath);
1853 if (blameLines.length === 0) {
1854 return c.html(
1855 <Layout title="Not Found" user={user}>
1856 <div class="empty-state">
1857 <h2>File not found</h2>
1858 </div>
1859 </Layout>,
1860 404
1861 );
1862 }
1863
1864 const fileName = filePath.split("/").pop() || filePath;
1865
1866 return c.html(
1867 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1868 <RepoHeader owner={owner} repo={repo} />
1869 <RepoNav owner={owner} repo={repo} active="code" />
1870 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1871 <div class="blob-view">
1872 <div class="blob-header">
1873 <span>{fileName} — blame</span>
1874 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1875 Normal view
1876 </a>
1877 </div>
1878 <div class="blob-code" style="overflow-x: auto">
1879 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1880 <tbody>
1881 {blameLines.map((line, i) => {
1882 const showInfo =
1883 i === 0 || blameLines[i - 1].sha !== line.sha;
1884 return (
1885 <tr style="border-bottom: 1px solid var(--border)">
1886 <td
1887 style={`width: 200px; padding: 0 8px; font-size: 11px; color: var(--text-muted); white-space: nowrap; vertical-align: top; ${showInfo ? "border-top: 1px solid var(--border)" : ""}`}
1888 >
1889 {showInfo && (
1890 <>
1891 <a
1892 href={`/${owner}/${repo}/commit/${line.sha}`}
1893 style="color: var(--text-link); font-family: var(--font-mono)"
1894 >
1895 {line.sha.slice(0, 7)}
1896 </a>{" "}
1897 <span>{line.author}</span>
1898 </>
1899 )}
1900 </td>
1901 <td class="line-num">{line.lineNum}</td>
1902 <td class="line-content">{line.content}</td>
1903 </tr>
1904 );
1905 })}
1906 </tbody>
1907 </table>
1908 </div>
1909 </div>
1910 </Layout>
1911 );
1912});
1913
1914// Search
1915web.get("/:owner/:repo/search", async (c) => {
1916 const { owner, repo } = c.req.param();
1917 const user = c.get("user");
1918 const q = c.req.query("q") || "";
1919
1920 if (!(await repoExists(owner, repo))) return c.notFound();
1921
1922 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1923 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1924
1925 if (q.trim()) {
1926 results = await searchCode(owner, repo, defaultBranch, q.trim());
1927 }
1928
1929 return c.html(
1930 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1931 <RepoHeader owner={owner} repo={repo} />
1932 <RepoNav owner={owner} repo={repo} active="code" />
1933 <form
001af43Claude1934 method="get"
79136bbClaude1935 action={`/${owner}/${repo}/search`}
1936 style="margin-bottom: 20px"
1937 >
dc26881CC LABS App1938 <div style="display: flex; gap: var(--space-2)">
79136bbClaude1939 <input
1940 type="text"
1941 name="q"
1942 value={q}
1943 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1944 aria-label="Search code"
dc26881CC LABS App1945 style="flex: 1; padding: var(--space-2) var(--space-3); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
79136bbClaude1946 />
1947 <button type="submit" class="btn btn-primary">
1948 Search
1949 </button>
1950 </div>
1951 </form>
1952 {q && (
1953 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1954 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1955 <strong style="color: var(--text)">"{q}"</strong>
1956 </p>
1957 )}
1958 {results.length > 0 && (
1959 <div class="search-results">
1960 {(() => {
1961 // Group by file
1962 const grouped: Record<
1963 string,
1964 Array<{ lineNum: number; line: string }>
1965 > = {};
1966 for (const r of results) {
1967 if (!grouped[r.file]) grouped[r.file] = [];
1968 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1969 }
1970 return Object.entries(grouped).map(([file, matches]) => (
1971 <div class="diff-file" style="margin-bottom: 12px">
1972 <div class="diff-file-header">
1973 <a
1974 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1975 >
1976 {file}
1977 </a>
1978 </div>
1979 <div class="blob-code">
1980 <table>
1981 <tbody>
1982 {matches.map((m) => (
1983 <tr>
1984 <td class="line-num">{m.lineNum}</td>
1985 <td class="line-content">{m.line}</td>
1986 </tr>
1987 ))}
1988 </tbody>
1989 </table>
1990 </div>
1991 </div>
1992 ));
1993 })()}
1994 </div>
1995 )}
1996 </Layout>
1997 );
1998});
1999
fc1817aClaude2000export default web;