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.tsxBlame1259 lines · 2 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";
2b821b7Claude51import { LandingPage } from "../views/landing";
52ad8b1Claude52import { computePublicStats, type PublicStats } from "../lib/public-stats";
fc1817aClaude53
06d5ffeClaude54const web = new Hono<AuthEnv>();
55
56// Soft auth on all web routes — c.get("user") available but may be null
57web.use("*", softAuth);
fc1817aClaude58
59// Home page
06d5ffeClaude60web.get("/", async (c) => {
61 const user = c.get("user");
62
63 if (user) {
0316dbbClaude64 return c.redirect("/dashboard");
06d5ffeClaude65 }
66
8e9f1d9Claude67 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude68 let publicStats: PublicStats | null = null;
8e9f1d9Claude69 try {
70 const [repoRow] = await db
71 .select({ n: sql<number>`count(*)::int` })
72 .from(repositories)
73 .where(eq(repositories.isPrivate, false));
74 const [userRow] = await db
75 .select({ n: sql<number>`count(*)::int` })
76 .from(users);
77 stats = {
78 publicRepos: Number(repoRow?.n ?? 0),
79 users: Number(userRow?.n ?? 0),
80 };
81 } catch {
82 stats = undefined;
83 }
84
52ad8b1Claude85 // Block L4 — public stats counters (5-min in-memory cache; never throws).
86 try {
87 publicStats = await computePublicStats();
88 } catch {
89 publicStats = null;
90 }
91
fc1817aClaude92 return c.html(
5f2e749Claude93 <Layout
94 user={null}
95 // Block L10 — SEO + Open Graph for the public landing.
96 fullTitle="Gluecron — The git host built around Claude"
97 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."
98 ogTitle="Gluecron — The git host built around Claude"
99 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."
100 ogType="website"
101 twitterCard="summary_large_image"
102 >
52ad8b1Claude103 <LandingPage stats={stats} publicStats={publicStats} />
fc1817aClaude104 </Layout>
105 );
106});
107
06d5ffeClaude108// New repository form
109web.get("/new", requireAuth, (c) => {
110 const user = c.get("user")!;
111 const error = c.req.query("error");
112
113 return c.html(
114 <Layout title="New repository" user={user}>
115 <div class="new-repo-form">
116 <h2>Create a new repository</h2>
117 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude118 <form method="post" action="/new">
06d5ffeClaude119 <div class="form-group">
120 <label>Owner</label>
2c3ba6ecopilot-swe-agent[bot]121 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
06d5ffeClaude122 </div>
123 <div class="form-group">
124 <label for="name">Repository name</label>
125 <input
126 type="text"
127 id="name"
128 name="name"
129 required
130 pattern="^[a-zA-Z0-9._-]+$"
131 placeholder="my-project"
132 autocomplete="off"
133 />
134 </div>
135 <div class="form-group">
136 <label for="description">Description (optional)</label>
137 <input
138 type="text"
139 id="description"
140 name="description"
141 placeholder="A short description of your repository"
142 />
143 </div>
144 <div class="visibility-options">
145 <label class="visibility-option">
146 <input type="radio" name="visibility" value="public" checked />
147 <div class="vis-label">Public</div>
148 <div class="vis-desc">Anyone can see this repository</div>
149 </label>
150 <label class="visibility-option">
151 <input type="radio" name="visibility" value="private" />
152 <div class="vis-label">Private</div>
153 <div class="vis-desc">Only you can see this repository</div>
154 </label>
155 </div>
156 <button type="submit" class="btn btn-primary">
157 Create repository
158 </button>
159 </form>
160 </div>
161 </Layout>
162 );
163});
164
165web.post("/new", requireAuth, async (c) => {
166 const user = c.get("user")!;
167 const body = await c.req.parseBody();
168 const name = String(body.name || "").trim();
169 const description = String(body.description || "").trim();
170 const isPrivate = body.visibility === "private";
171
172 if (!name) {
173 return c.redirect("/new?error=Repository+name+is+required");
174 }
175
176 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
177 return c.redirect("/new?error=Invalid+repository+name");
178 }
179
180 if (await repoExists(user.username, name)) {
181 return c.redirect("/new?error=Repository+already+exists");
182 }
183
184 const diskPath = await initBareRepo(user.username, name);
185
3ef4c9dClaude186 const [newRepo] = await db
187 .insert(repositories)
188 .values({
189 name,
190 ownerId: user.id,
191 description: description || null,
192 isPrivate,
193 diskPath,
194 })
195 .returning();
196
197 if (newRepo) {
198 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
199 await bootstrapRepository({
200 repositoryId: newRepo.id,
201 ownerUserId: user.id,
202 defaultBranch: "main",
203 });
204 }
06d5ffeClaude205
206 return c.redirect(`/${user.username}/${name}`);
207});
208
209// User profile
fc1817aClaude210web.get("/:owner", async (c) => {
06d5ffeClaude211 const { owner: ownerName } = c.req.param();
212 const user = c.get("user");
213
214 // Avoid clashing with fixed routes
215 if (
216 ["login", "register", "logout", "new", "settings", "api"].includes(
217 ownerName
218 )
219 ) {
220 return c.notFound();
221 }
222
223 let ownerUser;
224 try {
225 const [found] = await db
226 .select()
227 .from(users)
228 .where(eq(users.username, ownerName))
229 .limit(1);
230 ownerUser = found;
231 } catch {
232 // DB not available — check if repos exist on disk
233 ownerUser = null;
234 }
235
236 // Even without DB, show repos if they exist on disk
237 let repos: any[] = [];
238 if (ownerUser) {
239 const allRepos = await db
240 .select()
241 .from(repositories)
242 .where(eq(repositories.ownerId, ownerUser.id))
243 .orderBy(desc(repositories.updatedAt));
244
245 // Show public repos to everyone, private only to owner
246 repos =
247 user?.id === ownerUser.id
248 ? allRepos
249 : allRepos.filter((r) => !r.isPrivate);
250 }
251
7aa8b99Claude252 // Block J4 — follow counts + viewer's follow state
253 let followState = {
254 followers: 0,
255 following: 0,
256 viewerFollows: false,
257 };
258 if (ownerUser) {
259 try {
260 const { followCounts, isFollowing } = await import("../lib/follows");
261 const counts = await followCounts(ownerUser.id);
262 followState.followers = counts.followers;
263 followState.following = counts.following;
264 if (user && user.id !== ownerUser.id) {
265 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
266 }
267 } catch {
268 // DB hiccup — fall back to zeros.
269 }
270 }
271 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
272
d412586Claude273 // Block J5 — profile README. Render owner/owner repo's README on the
274 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
275 // back to "<user>/.github" for org-style profile repos.
276 let profileReadmeHtml: string | null = null;
277 try {
278 const candidates = [ownerName, ".github"];
279 for (const rname of candidates) {
280 if (await repoExists(ownerName, rname)) {
281 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
282 const md = await getReadme(ownerName, rname, ref);
283 if (md) {
284 profileReadmeHtml = renderMarkdown(md);
285 break;
286 }
287 }
288 }
289 } catch {
290 profileReadmeHtml = null;
291 }
292
fc1817aClaude293 return c.html(
06d5ffeClaude294 <Layout title={ownerName} user={user}>
295 <div class="user-profile">
296 <div class="user-avatar">
297 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
298 </div>
299 <div class="user-info">
300 <h2>{ownerUser?.displayName || ownerName}</h2>
301 <div class="username">@{ownerName}</div>
302 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude303 <div
304 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
305 >
306 <a
307 href={`/${ownerName}/followers`}
308 style="color:var(--text-muted)"
309 >
310 <strong style="color:var(--text)">
311 {followState.followers}
312 </strong>{" "}
313 follower{followState.followers === 1 ? "" : "s"}
314 </a>
315 <a
316 href={`/${ownerName}/following`}
317 style="color:var(--text-muted)"
318 >
319 <strong style="color:var(--text)">
320 {followState.following}
321 </strong>{" "}
322 following
323 </a>
324 {canFollow && (
325 <form
001af43Claude326 method="post"
7aa8b99Claude327 action={`/${ownerName}/${
328 followState.viewerFollows ? "unfollow" : "follow"
329 }`}
330 >
331 <button
332 type="submit"
333 class={`btn ${
334 followState.viewerFollows ? "" : "btn-primary"
335 } btn-sm`}
336 >
337 {followState.viewerFollows ? "Unfollow" : "Follow"}
338 </button>
339 </form>
340 )}
341 </div>
06d5ffeClaude342 </div>
343 </div>
d412586Claude344 {profileReadmeHtml && (
345 <div
346 class="markdown-body"
347 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
348 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
349 />
350 )}
06d5ffeClaude351 <h3 style="margin-bottom: 16px">Repositories</h3>
352 {repos.length === 0 ? (
353 <p style="color: var(--text-muted)">No repositories yet.</p>
354 ) : (
355 <div class="card-grid">
356 {repos.map((repo) => (
357 <RepoCard repo={repo} ownerName={ownerName} />
358 ))}
359 </div>
360 )}
fc1817aClaude361 </Layout>
362 );
363});
364
06d5ffeClaude365// Star/unstar a repo
366web.post("/:owner/:repo/star", requireAuth, async (c) => {
367 const { owner: ownerName, repo: repoName } = c.req.param();
368 const user = c.get("user")!;
369
370 try {
371 const [ownerUser] = await db
372 .select()
373 .from(users)
374 .where(eq(users.username, ownerName))
375 .limit(1);
376 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
377
378 const [repo] = await db
379 .select()
380 .from(repositories)
381 .where(
382 and(
383 eq(repositories.ownerId, ownerUser.id),
384 eq(repositories.name, repoName)
385 )
386 )
387 .limit(1);
388 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
389
390 // Toggle star
391 const [existing] = await db
392 .select()
393 .from(stars)
394 .where(
395 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
396 )
397 .limit(1);
398
399 if (existing) {
400 await db.delete(stars).where(eq(stars.id, existing.id));
401 await db
402 .update(repositories)
403 .set({ starCount: Math.max(0, repo.starCount - 1) })
404 .where(eq(repositories.id, repo.id));
405 } else {
406 await db.insert(stars).values({
407 userId: user.id,
408 repositoryId: repo.id,
409 });
410 await db
411 .update(repositories)
412 .set({ starCount: repo.starCount + 1 })
413 .where(eq(repositories.id, repo.id));
414 }
415 } catch {
416 // DB error — ignore
417 }
418
419 return c.redirect(`/${ownerName}/${repoName}`);
420});
421
fc1817aClaude422// Repository overview — file tree at HEAD
423web.get("/:owner/:repo", async (c) => {
424 const { owner, repo } = c.req.param();
06d5ffeClaude425 const user = c.get("user");
fc1817aClaude426
8f50ed0Claude427 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
428 trackByName(owner, repo, "view", {
429 userId: user?.id || null,
430 path: `/${owner}/${repo}`,
431 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
432 userAgent: c.req.header("user-agent") || null,
433 referer: c.req.header("referer") || null,
434 }).catch(() => {});
435
fc1817aClaude436 if (!(await repoExists(owner, repo))) {
437 return c.html(
06d5ffeClaude438 <Layout title="Not Found" user={user}>
fc1817aClaude439 <div class="empty-state">
440 <h2>Repository not found</h2>
441 <p>
442 {owner}/{repo} does not exist.
443 </p>
444 </div>
445 </Layout>,
446 404
447 );
448 }
449
05b973eClaude450 // Parallelize all independent operations
451 const [defaultBranch, branches] = await Promise.all([
452 getDefaultBranch(owner, repo).then((b) => b || "main"),
453 listBranches(owner, repo),
454 ]);
455 const [tree, starInfo] = await Promise.all([
456 getTree(owner, repo, defaultBranch),
457 // Star info fetched in parallel with tree
458 (async () => {
459 try {
460 const [ownerUser] = await db
461 .select()
462 .from(users)
463 .where(eq(users.username, owner))
464 .limit(1);
71cd5ecClaude465 if (!ownerUser)
466 return {
467 starCount: 0,
468 starred: false,
469 archived: false,
470 isTemplate: false,
471 };
05b973eClaude472 const [repoRow] = await db
473 .select()
474 .from(repositories)
475 .where(
476 and(
477 eq(repositories.ownerId, ownerUser.id),
478 eq(repositories.name, repo)
479 )
06d5ffeClaude480 )
05b973eClaude481 .limit(1);
71cd5ecClaude482 if (!repoRow)
483 return {
484 starCount: 0,
485 starred: false,
486 archived: false,
487 isTemplate: false,
488 };
05b973eClaude489 let starred = false;
06d5ffeClaude490 if (user) {
491 const [star] = await db
492 .select()
493 .from(stars)
494 .where(
495 and(
496 eq(stars.userId, user.id),
497 eq(stars.repositoryId, repoRow.id)
498 )
499 )
500 .limit(1);
501 starred = !!star;
502 }
71cd5ecClaude503 return {
504 starCount: repoRow.starCount,
505 starred,
506 archived: repoRow.isArchived,
507 isTemplate: repoRow.isTemplate,
508 };
05b973eClaude509 } catch {
71cd5ecClaude510 return {
511 starCount: 0,
512 starred: false,
513 archived: false,
514 isTemplate: false,
515 };
06d5ffeClaude516 }
05b973eClaude517 })(),
518 ]);
71cd5ecClaude519 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude520
fc1817aClaude521 if (tree.length === 0) {
522 return c.html(
06d5ffeClaude523 <Layout title={`${owner}/${repo}`} user={user}>
524 <RepoHeader
525 owner={owner}
526 repo={repo}
527 starCount={starCount}
528 starred={starred}
529 currentUser={user?.username}
71cd5ecClaude530 archived={archived}
531 isTemplate={isTemplate}
06d5ffeClaude532 />
fc1817aClaude533 <RepoNav owner={owner} repo={repo} active="code" />
534 <div class="empty-state">
535 <h2>Empty repository</h2>
536 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]537 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude538git push -u gluecron main`}</pre>
539 </div>
540 </Layout>
541 );
542 }
543
544 const readme = await getReadme(owner, repo, defaultBranch);
545
546 return c.html(
06d5ffeClaude547 <Layout title={`${owner}/${repo}`} user={user}>
548 <RepoHeader
549 owner={owner}
550 repo={repo}
551 starCount={starCount}
552 starred={starred}
553 currentUser={user?.username}
71cd5ecClaude554 archived={archived}
555 isTemplate={isTemplate}
06d5ffeClaude556 />
71cd5ecClaude557 {isTemplate && user && user.username !== owner && (
558 <div
559 class="panel"
560 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
561 >
562 <div style="font-size:13px">
563 <strong>Template repository.</strong> Create a new repository from
564 this template's files.
565 </div>
566 <form
001af43Claude567 method="post"
71cd5ecClaude568 action={`/${owner}/${repo}/use-template`}
569 style="display:flex;gap:8px;align-items:center"
570 >
571 <input
572 type="text"
573 name="name"
574 placeholder="new-repo-name"
575 required
2c3ba6ecopilot-swe-agent[bot]576 aria-label="New repository name"
71cd5ecClaude577 style="width:200px"
578 />
579 <button type="submit" class="btn btn-primary">
580 Use this template
581 </button>
582 </form>
583 </div>
584 )}
fc1817aClaude585 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude586 <BranchSwitcher
587 owner={owner}
588 repo={repo}
589 currentRef={defaultBranch}
590 branches={branches}
591 pathType="tree"
592 />
fc1817aClaude593 <FileTable
594 entries={tree}
595 owner={owner}
596 repo={repo}
597 ref={defaultBranch}
598 path=""
599 />
79136bbClaude600 {readme && (() => {
601 const readmeHtml = renderMarkdown(readme);
602 return (
603 <div class="blob-view" style="margin-top: 20px">
604 <div class="blob-header">README.md</div>
605 <style>{markdownCss}</style>
606 <div class="markdown-body">
607 {html([readmeHtml] as unknown as TemplateStringsArray)}
608 </div>
fc1817aClaude609 </div>
79136bbClaude610 );
611 })()}
fc1817aClaude612 </Layout>
613 );
614});
615
616// Browse tree at ref/path
617web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
618 const { owner, repo } = c.req.param();
06d5ffeClaude619 const user = c.get("user");
fc1817aClaude620 const refAndPath = c.req.param("ref");
621
622 const branches = await listBranches(owner, repo);
623 let ref = "";
624 let treePath = "";
625
626 for (const branch of branches) {
627 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
628 ref = branch;
629 treePath = refAndPath.slice(branch.length + 1);
630 break;
631 }
632 }
633
634 if (!ref) {
635 const slashIdx = refAndPath.indexOf("/");
636 if (slashIdx === -1) {
637 ref = refAndPath;
638 } else {
639 ref = refAndPath.slice(0, slashIdx);
640 treePath = refAndPath.slice(slashIdx + 1);
641 }
642 }
643
644 const tree = await getTree(owner, repo, ref, treePath);
645
646 return c.html(
06d5ffeClaude647 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude648 <RepoHeader owner={owner} repo={repo} />
649 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude650 <BranchSwitcher
651 owner={owner}
652 repo={repo}
653 currentRef={ref}
654 branches={branches}
655 pathType="tree"
656 subPath={treePath}
657 />
fc1817aClaude658 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
659 <FileTable
660 entries={tree}
661 owner={owner}
662 repo={repo}
663 ref={ref}
664 path={treePath}
665 />
666 </Layout>
667 );
668});
669
06d5ffeClaude670// View file blob with syntax highlighting
fc1817aClaude671web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
672 const { owner, repo } = c.req.param();
06d5ffeClaude673 const user = c.get("user");
fc1817aClaude674 const refAndPath = c.req.param("ref");
675
676 const branches = await listBranches(owner, repo);
677 let ref = "";
678 let filePath = "";
679
680 for (const branch of branches) {
681 if (refAndPath.startsWith(branch + "/")) {
682 ref = branch;
683 filePath = refAndPath.slice(branch.length + 1);
684 break;
685 }
686 }
687
688 if (!ref) {
689 const slashIdx = refAndPath.indexOf("/");
690 if (slashIdx === -1) return c.text("Not found", 404);
691 ref = refAndPath.slice(0, slashIdx);
692 filePath = refAndPath.slice(slashIdx + 1);
693 }
694
695 const blob = await getBlob(owner, repo, ref, filePath);
696 if (!blob) {
697 return c.html(
06d5ffeClaude698 <Layout title="Not Found" user={user}>
fc1817aClaude699 <div class="empty-state">
700 <h2>File not found</h2>
701 </div>
702 </Layout>,
703 404
704 );
705 }
706
06d5ffeClaude707 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude708
709 return c.html(
06d5ffeClaude710 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude711 <RepoHeader owner={owner} repo={repo} />
712 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude713 <BranchSwitcher
714 owner={owner}
715 repo={repo}
716 currentRef={ref}
717 branches={branches}
718 pathType="blob"
719 subPath={filePath}
720 />
fc1817aClaude721 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
722 <div class="blob-view">
723 <div class="blob-header">
06d5ffeClaude724 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude725 <span style="display: flex; gap: 12px">
726 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
727 Raw
728 </a>
729 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
730 Blame
731 </a>
16b325cClaude732 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
733 History
734 </a>
0074234Claude735 {user && (
736 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
737 Edit
738 </a>
739 )}
79136bbClaude740 </span>
fc1817aClaude741 </div>
742 {blob.isBinary ? (
743 <div style="padding: 16px; color: var(--text-muted)">
744 Binary file not shown.
745 </div>
06d5ffeClaude746 ) : (() => {
747 const { html: highlighted, language } = highlightCode(
748 blob.content,
749 fileName
750 );
751 const lineCount = blob.content.split("\n").length;
752 // Trim trailing newline from count
753 const adjustedCount =
754 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
755
756 if (language) {
757 return (
758 <HighlightedCode
759 highlightedHtml={highlighted}
760 lineCount={adjustedCount}
761 />
762 );
763 }
764 const lines = blob.content.split("\n");
765 if (lines[lines.length - 1] === "") lines.pop();
766 return <PlainCode lines={lines} />;
767 })()}
fc1817aClaude768 </div>
769 </Layout>
770 );
771});
772
773// Commit log
774web.get("/:owner/:repo/commits/:ref?", async (c) => {
775 const { owner, repo } = c.req.param();
06d5ffeClaude776 const user = c.get("user");
fc1817aClaude777 const ref =
778 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude779 const branches = await listBranches(owner, repo);
fc1817aClaude780
781 const commits = await listCommits(owner, repo, ref, 50);
782
3951454Claude783 // Block J3 — batch-fetch cached verification results for the page.
784 let verifications: Record<string, { verified: boolean; reason: string }> = {};
785 try {
786 const [ownerRow] = await db
787 .select()
788 .from(users)
789 .where(eq(users.username, owner))
790 .limit(1);
791 if (ownerRow) {
792 const [repoRow] = await db
793 .select()
794 .from(repositories)
795 .where(
796 and(
797 eq(repositories.ownerId, ownerRow.id),
798 eq(repositories.name, repo)
799 )
800 )
801 .limit(1);
802 if (repoRow && commits.length > 0) {
803 const rows = await db
804 .select()
805 .from(commitVerifications)
806 .where(
807 and(
808 eq(commitVerifications.repositoryId, repoRow.id),
809 inArray(
810 commitVerifications.commitSha,
811 commits.map((c) => c.sha)
812 )
813 )
814 );
815 for (const r of rows) {
816 verifications[r.commitSha] = {
817 verified: r.verified,
818 reason: r.reason,
819 };
820 }
821 }
822 }
823 } catch {
824 // DB unavailable — skip the badges gracefully.
825 }
826
fc1817aClaude827 return c.html(
06d5ffeClaude828 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude829 <RepoHeader owner={owner} repo={repo} />
830 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude831 <BranchSwitcher
832 owner={owner}
833 repo={repo}
834 currentRef={ref}
835 branches={branches}
836 pathType="commits"
837 />
fc1817aClaude838 {commits.length === 0 ? (
839 <div class="empty-state">
840 <p>No commits yet.</p>
841 </div>
842 ) : (
3951454Claude843 <CommitList
844 commits={commits}
845 owner={owner}
846 repo={repo}
847 verifications={verifications}
848 />
fc1817aClaude849 )}
850 </Layout>
851 );
852});
853
854// Single commit with diff
855web.get("/:owner/:repo/commit/:sha", async (c) => {
856 const { owner, repo, sha } = c.req.param();
06d5ffeClaude857 const user = c.get("user");
fc1817aClaude858
05b973eClaude859 // Fetch commit, full message, and diff in parallel
860 const [commit, fullMessage, diffResult] = await Promise.all([
861 getCommit(owner, repo, sha),
862 getCommitFullMessage(owner, repo, sha),
863 getDiff(owner, repo, sha),
864 ]);
fc1817aClaude865 if (!commit) {
866 return c.html(
06d5ffeClaude867 <Layout title="Not Found" user={user}>
fc1817aClaude868 <div class="empty-state">
869 <h2>Commit not found</h2>
870 </div>
871 </Layout>,
872 404
873 );
874 }
875
3951454Claude876 // Block J3 — try to verify this commit's signature.
877 let verification:
878 | { verified: boolean; reason: string; signatureType: string | null }
879 | null = null;
0cdfd89Claude880 // Block J8 — external CI commit statuses rollup.
881 let statusCombined:
882 | {
883 state: "pending" | "success" | "failure";
884 total: number;
885 contexts: Array<{
886 context: string;
887 state: string;
888 description: string | null;
889 targetUrl: string | null;
890 }>;
891 }
892 | null = null;
3951454Claude893 try {
894 const [ownerRow] = await db
895 .select()
896 .from(users)
897 .where(eq(users.username, owner))
898 .limit(1);
899 if (ownerRow) {
900 const [repoRow] = await db
901 .select()
902 .from(repositories)
903 .where(
904 and(
905 eq(repositories.ownerId, ownerRow.id),
906 eq(repositories.name, repo)
907 )
908 )
909 .limit(1);
910 if (repoRow) {
911 const { verifyCommit } = await import("../lib/signatures");
912 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
913 verification = {
914 verified: v.verified,
915 reason: v.reason,
916 signatureType: v.signatureType,
917 };
0cdfd89Claude918 try {
919 const { combinedStatus } = await import("../lib/commit-statuses");
920 const combined = await combinedStatus(repoRow.id, commit.sha);
921 if (combined.total > 0) {
922 statusCombined = {
923 state: combined.state as any,
924 total: combined.total,
925 contexts: combined.contexts.map((c) => ({
926 context: c.context,
927 state: c.state,
928 description: c.description,
929 targetUrl: c.targetUrl,
930 })),
931 };
932 }
933 } catch {
934 statusCombined = null;
935 }
3951454Claude936 }
937 }
938 } catch {
939 verification = null;
940 }
941
05b973eClaude942 const { files, raw } = diffResult;
fc1817aClaude943
944 return c.html(
06d5ffeClaude945 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude946 <RepoHeader owner={owner} repo={repo} />
947 <div
948 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
949 >
950 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
951 {commit.message}
952 </div>
953 {fullMessage !== commit.message && (
954 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
955 {fullMessage}
956 </div>
957 )}
958 <div style="font-size: 13px; color: var(--text-muted)">
959 <strong style="color: var(--text)">{commit.author}</strong>{" "}
960 committed on{" "}
961 {new Date(commit.date).toLocaleDateString("en-US", {
962 month: "long",
963 day: "numeric",
964 year: "numeric",
965 })}
3951454Claude966 {verification && verification.reason !== "unsigned" && (
967 <span
968 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
969 verification.verified
970 ? "var(--green,#2ea043)"
971 : "var(--yellow,#d29922)"
972 }`}
973 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
974 >
975 {verification.verified ? "Verified" : verification.reason}
976 </span>
977 )}
fc1817aClaude978 </div>
979 <div style="margin-top: 8px">
980 <span class="commit-sha">{commit.sha}</span>
981 {commit.parentShas.length > 0 && (
982 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
983 Parent:{" "}
984 {commit.parentShas.map((p) => (
985 <a
986 href={`/${owner}/${repo}/commit/${p}`}
987 class="commit-sha"
988 style="margin-left: 4px"
989 >
990 {p.slice(0, 7)}
991 </a>
992 ))}
993 </span>
994 )}
995 </div>
0cdfd89Claude996 {statusCombined && (
997 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
998 <strong style="color: var(--text)">Checks</strong>
999 <span style="margin-left: 8px; color: var(--text-muted)">
1000 {statusCombined.total} total —{" "}
1001 <span
1002 style={`color:${
1003 statusCombined.state === "success"
1004 ? "var(--green,#2ea043)"
1005 : statusCombined.state === "failure"
1006 ? "var(--red,#da3633)"
1007 : "var(--yellow,#d29922)"
1008 }`}
1009 >
1010 {statusCombined.state}
1011 </span>
1012 </span>
1013 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1014 {statusCombined.contexts.map((cx) => (
1015 <span
1016 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1017 cx.state === "success"
1018 ? "var(--green,#2ea043)"
1019 : cx.state === "pending"
1020 ? "var(--yellow,#d29922)"
1021 : "var(--red,#da3633)"
1022 }`}
1023 title={cx.description || cx.context}
1024 >
1025 {cx.targetUrl ? (
1026 <a
1027 href={cx.targetUrl}
1028 style="color: inherit; text-decoration: none"
1029 rel="noopener"
1030 >
1031 {cx.context}: {cx.state}
1032 </a>
1033 ) : (
1034 <>
1035 {cx.context}: {cx.state}
1036 </>
1037 )}
1038 </span>
1039 ))}
1040 </div>
1041 </div>
1042 )}
fc1817aClaude1043 </div>
1044 <DiffView raw={raw} files={files} />
1045 </Layout>
1046 );
1047});
1048
79136bbClaude1049// Raw file download
1050web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1051 const { owner, repo } = c.req.param();
1052 const refAndPath = c.req.param("ref");
1053
1054 const branches = await listBranches(owner, repo);
1055 let ref = "";
1056 let filePath = "";
1057
1058 for (const branch of branches) {
1059 if (refAndPath.startsWith(branch + "/")) {
1060 ref = branch;
1061 filePath = refAndPath.slice(branch.length + 1);
1062 break;
1063 }
1064 }
1065
1066 if (!ref) {
1067 const slashIdx = refAndPath.indexOf("/");
1068 if (slashIdx === -1) return c.text("Not found", 404);
1069 ref = refAndPath.slice(0, slashIdx);
1070 filePath = refAndPath.slice(slashIdx + 1);
1071 }
1072
1073 const data = await getRawBlob(owner, repo, ref, filePath);
1074 if (!data) return c.text("Not found", 404);
1075
1076 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1077 return new Response(data as BodyInit, {
79136bbClaude1078 headers: {
1079 "Content-Type": "application/octet-stream",
1080 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1081 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1082 },
1083 });
1084});
1085
1086// Blame view
1087web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1088 const { owner, repo } = c.req.param();
1089 const user = c.get("user");
1090 const refAndPath = c.req.param("ref");
1091
1092 const branches = await listBranches(owner, repo);
1093 let ref = "";
1094 let filePath = "";
1095
1096 for (const branch of branches) {
1097 if (refAndPath.startsWith(branch + "/")) {
1098 ref = branch;
1099 filePath = refAndPath.slice(branch.length + 1);
1100 break;
1101 }
1102 }
1103
1104 if (!ref) {
1105 const slashIdx = refAndPath.indexOf("/");
1106 if (slashIdx === -1) return c.text("Not found", 404);
1107 ref = refAndPath.slice(0, slashIdx);
1108 filePath = refAndPath.slice(slashIdx + 1);
1109 }
1110
1111 const blameLines = await getBlame(owner, repo, ref, filePath);
1112 if (blameLines.length === 0) {
1113 return c.html(
1114 <Layout title="Not Found" user={user}>
1115 <div class="empty-state">
1116 <h2>File not found</h2>
1117 </div>
1118 </Layout>,
1119 404
1120 );
1121 }
1122
1123 const fileName = filePath.split("/").pop() || filePath;
1124
1125 return c.html(
1126 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1127 <RepoHeader owner={owner} repo={repo} />
1128 <RepoNav owner={owner} repo={repo} active="code" />
1129 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1130 <div class="blob-view">
1131 <div class="blob-header">
1132 <span>{fileName} — blame</span>
1133 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1134 Normal view
1135 </a>
1136 </div>
1137 <div class="blob-code" style="overflow-x: auto">
1138 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1139 <tbody>
1140 {blameLines.map((line, i) => {
1141 const showInfo =
1142 i === 0 || blameLines[i - 1].sha !== line.sha;
1143 return (
1144 <tr style="border-bottom: 1px solid var(--border)">
1145 <td
1146 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)" : ""}`}
1147 >
1148 {showInfo && (
1149 <>
1150 <a
1151 href={`/${owner}/${repo}/commit/${line.sha}`}
1152 style="color: var(--text-link); font-family: var(--font-mono)"
1153 >
1154 {line.sha.slice(0, 7)}
1155 </a>{" "}
1156 <span>{line.author}</span>
1157 </>
1158 )}
1159 </td>
1160 <td class="line-num">{line.lineNum}</td>
1161 <td class="line-content">{line.content}</td>
1162 </tr>
1163 );
1164 })}
1165 </tbody>
1166 </table>
1167 </div>
1168 </div>
1169 </Layout>
1170 );
1171});
1172
1173// Search
1174web.get("/:owner/:repo/search", async (c) => {
1175 const { owner, repo } = c.req.param();
1176 const user = c.get("user");
1177 const q = c.req.query("q") || "";
1178
1179 if (!(await repoExists(owner, repo))) return c.notFound();
1180
1181 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1182 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1183
1184 if (q.trim()) {
1185 results = await searchCode(owner, repo, defaultBranch, q.trim());
1186 }
1187
1188 return c.html(
1189 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1190 <RepoHeader owner={owner} repo={repo} />
1191 <RepoNav owner={owner} repo={repo} active="code" />
1192 <form
001af43Claude1193 method="get"
79136bbClaude1194 action={`/${owner}/${repo}/search`}
1195 style="margin-bottom: 20px"
1196 >
1197 <div style="display: flex; gap: 8px">
1198 <input
1199 type="text"
1200 name="q"
1201 value={q}
1202 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1203 aria-label="Search code"
79136bbClaude1204 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1205 />
1206 <button type="submit" class="btn btn-primary">
1207 Search
1208 </button>
1209 </div>
1210 </form>
1211 {q && (
1212 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1213 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1214 <strong style="color: var(--text)">"{q}"</strong>
1215 </p>
1216 )}
1217 {results.length > 0 && (
1218 <div class="search-results">
1219 {(() => {
1220 // Group by file
1221 const grouped: Record<
1222 string,
1223 Array<{ lineNum: number; line: string }>
1224 > = {};
1225 for (const r of results) {
1226 if (!grouped[r.file]) grouped[r.file] = [];
1227 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1228 }
1229 return Object.entries(grouped).map(([file, matches]) => (
1230 <div class="diff-file" style="margin-bottom: 12px">
1231 <div class="diff-file-header">
1232 <a
1233 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1234 >
1235 {file}
1236 </a>
1237 </div>
1238 <div class="blob-code">
1239 <table>
1240 <tbody>
1241 {matches.map((m) => (
1242 <tr>
1243 <td class="line-num">{m.lineNum}</td>
1244 <td class="line-content">{m.line}</td>
1245 </tr>
1246 ))}
1247 </tbody>
1248 </table>
1249 </div>
1250 </div>
1251 ));
1252 })()}
1253 </div>
1254 )}
1255 </Layout>
1256 );
1257});
1258
fc1817aClaude1259export default web;