Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1250 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(
06d5ffeClaude93 <Layout user={null}>
52ad8b1Claude94 <LandingPage stats={stats} publicStats={publicStats} />
fc1817aClaude95 </Layout>
96 );
97});
98
06d5ffeClaude99// New repository form
100web.get("/new", requireAuth, (c) => {
101 const user = c.get("user")!;
102 const error = c.req.query("error");
103
104 return c.html(
105 <Layout title="New repository" user={user}>
106 <div class="new-repo-form">
107 <h2>Create a new repository</h2>
108 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude109 <form method="post" action="/new">
06d5ffeClaude110 <div class="form-group">
111 <label>Owner</label>
2c3ba6ecopilot-swe-agent[bot]112 <input type="text" value={user.username} disabled aria-label="Owner" class="input-disabled" />
06d5ffeClaude113 </div>
114 <div class="form-group">
115 <label for="name">Repository name</label>
116 <input
117 type="text"
118 id="name"
119 name="name"
120 required
121 pattern="^[a-zA-Z0-9._-]+$"
122 placeholder="my-project"
123 autocomplete="off"
124 />
125 </div>
126 <div class="form-group">
127 <label for="description">Description (optional)</label>
128 <input
129 type="text"
130 id="description"
131 name="description"
132 placeholder="A short description of your repository"
133 />
134 </div>
135 <div class="visibility-options">
136 <label class="visibility-option">
137 <input type="radio" name="visibility" value="public" checked />
138 <div class="vis-label">Public</div>
139 <div class="vis-desc">Anyone can see this repository</div>
140 </label>
141 <label class="visibility-option">
142 <input type="radio" name="visibility" value="private" />
143 <div class="vis-label">Private</div>
144 <div class="vis-desc">Only you can see this repository</div>
145 </label>
146 </div>
147 <button type="submit" class="btn btn-primary">
148 Create repository
149 </button>
150 </form>
151 </div>
152 </Layout>
153 );
154});
155
156web.post("/new", requireAuth, async (c) => {
157 const user = c.get("user")!;
158 const body = await c.req.parseBody();
159 const name = String(body.name || "").trim();
160 const description = String(body.description || "").trim();
161 const isPrivate = body.visibility === "private";
162
163 if (!name) {
164 return c.redirect("/new?error=Repository+name+is+required");
165 }
166
167 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
168 return c.redirect("/new?error=Invalid+repository+name");
169 }
170
171 if (await repoExists(user.username, name)) {
172 return c.redirect("/new?error=Repository+already+exists");
173 }
174
175 const diskPath = await initBareRepo(user.username, name);
176
3ef4c9dClaude177 const [newRepo] = await db
178 .insert(repositories)
179 .values({
180 name,
181 ownerId: user.id,
182 description: description || null,
183 isPrivate,
184 diskPath,
185 })
186 .returning();
187
188 if (newRepo) {
189 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
190 await bootstrapRepository({
191 repositoryId: newRepo.id,
192 ownerUserId: user.id,
193 defaultBranch: "main",
194 });
195 }
06d5ffeClaude196
197 return c.redirect(`/${user.username}/${name}`);
198});
199
200// User profile
fc1817aClaude201web.get("/:owner", async (c) => {
06d5ffeClaude202 const { owner: ownerName } = c.req.param();
203 const user = c.get("user");
204
205 // Avoid clashing with fixed routes
206 if (
207 ["login", "register", "logout", "new", "settings", "api"].includes(
208 ownerName
209 )
210 ) {
211 return c.notFound();
212 }
213
214 let ownerUser;
215 try {
216 const [found] = await db
217 .select()
218 .from(users)
219 .where(eq(users.username, ownerName))
220 .limit(1);
221 ownerUser = found;
222 } catch {
223 // DB not available — check if repos exist on disk
224 ownerUser = null;
225 }
226
227 // Even without DB, show repos if they exist on disk
228 let repos: any[] = [];
229 if (ownerUser) {
230 const allRepos = await db
231 .select()
232 .from(repositories)
233 .where(eq(repositories.ownerId, ownerUser.id))
234 .orderBy(desc(repositories.updatedAt));
235
236 // Show public repos to everyone, private only to owner
237 repos =
238 user?.id === ownerUser.id
239 ? allRepos
240 : allRepos.filter((r) => !r.isPrivate);
241 }
242
7aa8b99Claude243 // Block J4 — follow counts + viewer's follow state
244 let followState = {
245 followers: 0,
246 following: 0,
247 viewerFollows: false,
248 };
249 if (ownerUser) {
250 try {
251 const { followCounts, isFollowing } = await import("../lib/follows");
252 const counts = await followCounts(ownerUser.id);
253 followState.followers = counts.followers;
254 followState.following = counts.following;
255 if (user && user.id !== ownerUser.id) {
256 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
257 }
258 } catch {
259 // DB hiccup — fall back to zeros.
260 }
261 }
262 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
263
d412586Claude264 // Block J5 — profile README. Render owner/owner repo's README on the
265 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
266 // back to "<user>/.github" for org-style profile repos.
267 let profileReadmeHtml: string | null = null;
268 try {
269 const candidates = [ownerName, ".github"];
270 for (const rname of candidates) {
271 if (await repoExists(ownerName, rname)) {
272 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
273 const md = await getReadme(ownerName, rname, ref);
274 if (md) {
275 profileReadmeHtml = renderMarkdown(md);
276 break;
277 }
278 }
279 }
280 } catch {
281 profileReadmeHtml = null;
282 }
283
fc1817aClaude284 return c.html(
06d5ffeClaude285 <Layout title={ownerName} user={user}>
286 <div class="user-profile">
287 <div class="user-avatar">
288 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
289 </div>
290 <div class="user-info">
291 <h2>{ownerUser?.displayName || ownerName}</h2>
292 <div class="username">@{ownerName}</div>
293 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude294 <div
295 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
296 >
297 <a
298 href={`/${ownerName}/followers`}
299 style="color:var(--text-muted)"
300 >
301 <strong style="color:var(--text)">
302 {followState.followers}
303 </strong>{" "}
304 follower{followState.followers === 1 ? "" : "s"}
305 </a>
306 <a
307 href={`/${ownerName}/following`}
308 style="color:var(--text-muted)"
309 >
310 <strong style="color:var(--text)">
311 {followState.following}
312 </strong>{" "}
313 following
314 </a>
315 {canFollow && (
316 <form
001af43Claude317 method="post"
7aa8b99Claude318 action={`/${ownerName}/${
319 followState.viewerFollows ? "unfollow" : "follow"
320 }`}
321 >
322 <button
323 type="submit"
324 class={`btn ${
325 followState.viewerFollows ? "" : "btn-primary"
326 } btn-sm`}
327 >
328 {followState.viewerFollows ? "Unfollow" : "Follow"}
329 </button>
330 </form>
331 )}
332 </div>
06d5ffeClaude333 </div>
334 </div>
d412586Claude335 {profileReadmeHtml && (
336 <div
337 class="markdown-body"
338 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
339 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
340 />
341 )}
06d5ffeClaude342 <h3 style="margin-bottom: 16px">Repositories</h3>
343 {repos.length === 0 ? (
344 <p style="color: var(--text-muted)">No repositories yet.</p>
345 ) : (
346 <div class="card-grid">
347 {repos.map((repo) => (
348 <RepoCard repo={repo} ownerName={ownerName} />
349 ))}
350 </div>
351 )}
fc1817aClaude352 </Layout>
353 );
354});
355
06d5ffeClaude356// Star/unstar a repo
357web.post("/:owner/:repo/star", requireAuth, async (c) => {
358 const { owner: ownerName, repo: repoName } = c.req.param();
359 const user = c.get("user")!;
360
361 try {
362 const [ownerUser] = await db
363 .select()
364 .from(users)
365 .where(eq(users.username, ownerName))
366 .limit(1);
367 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
368
369 const [repo] = await db
370 .select()
371 .from(repositories)
372 .where(
373 and(
374 eq(repositories.ownerId, ownerUser.id),
375 eq(repositories.name, repoName)
376 )
377 )
378 .limit(1);
379 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
380
381 // Toggle star
382 const [existing] = await db
383 .select()
384 .from(stars)
385 .where(
386 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
387 )
388 .limit(1);
389
390 if (existing) {
391 await db.delete(stars).where(eq(stars.id, existing.id));
392 await db
393 .update(repositories)
394 .set({ starCount: Math.max(0, repo.starCount - 1) })
395 .where(eq(repositories.id, repo.id));
396 } else {
397 await db.insert(stars).values({
398 userId: user.id,
399 repositoryId: repo.id,
400 });
401 await db
402 .update(repositories)
403 .set({ starCount: repo.starCount + 1 })
404 .where(eq(repositories.id, repo.id));
405 }
406 } catch {
407 // DB error — ignore
408 }
409
410 return c.redirect(`/${ownerName}/${repoName}`);
411});
412
fc1817aClaude413// Repository overview — file tree at HEAD
414web.get("/:owner/:repo", async (c) => {
415 const { owner, repo } = c.req.param();
06d5ffeClaude416 const user = c.get("user");
fc1817aClaude417
8f50ed0Claude418 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
419 trackByName(owner, repo, "view", {
420 userId: user?.id || null,
421 path: `/${owner}/${repo}`,
422 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
423 userAgent: c.req.header("user-agent") || null,
424 referer: c.req.header("referer") || null,
425 }).catch(() => {});
426
fc1817aClaude427 if (!(await repoExists(owner, repo))) {
428 return c.html(
06d5ffeClaude429 <Layout title="Not Found" user={user}>
fc1817aClaude430 <div class="empty-state">
431 <h2>Repository not found</h2>
432 <p>
433 {owner}/{repo} does not exist.
434 </p>
435 </div>
436 </Layout>,
437 404
438 );
439 }
440
05b973eClaude441 // Parallelize all independent operations
442 const [defaultBranch, branches] = await Promise.all([
443 getDefaultBranch(owner, repo).then((b) => b || "main"),
444 listBranches(owner, repo),
445 ]);
446 const [tree, starInfo] = await Promise.all([
447 getTree(owner, repo, defaultBranch),
448 // Star info fetched in parallel with tree
449 (async () => {
450 try {
451 const [ownerUser] = await db
452 .select()
453 .from(users)
454 .where(eq(users.username, owner))
455 .limit(1);
71cd5ecClaude456 if (!ownerUser)
457 return {
458 starCount: 0,
459 starred: false,
460 archived: false,
461 isTemplate: false,
462 };
05b973eClaude463 const [repoRow] = await db
464 .select()
465 .from(repositories)
466 .where(
467 and(
468 eq(repositories.ownerId, ownerUser.id),
469 eq(repositories.name, repo)
470 )
06d5ffeClaude471 )
05b973eClaude472 .limit(1);
71cd5ecClaude473 if (!repoRow)
474 return {
475 starCount: 0,
476 starred: false,
477 archived: false,
478 isTemplate: false,
479 };
05b973eClaude480 let starred = false;
06d5ffeClaude481 if (user) {
482 const [star] = await db
483 .select()
484 .from(stars)
485 .where(
486 and(
487 eq(stars.userId, user.id),
488 eq(stars.repositoryId, repoRow.id)
489 )
490 )
491 .limit(1);
492 starred = !!star;
493 }
71cd5ecClaude494 return {
495 starCount: repoRow.starCount,
496 starred,
497 archived: repoRow.isArchived,
498 isTemplate: repoRow.isTemplate,
499 };
05b973eClaude500 } catch {
71cd5ecClaude501 return {
502 starCount: 0,
503 starred: false,
504 archived: false,
505 isTemplate: false,
506 };
06d5ffeClaude507 }
05b973eClaude508 })(),
509 ]);
71cd5ecClaude510 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude511
fc1817aClaude512 if (tree.length === 0) {
513 return c.html(
06d5ffeClaude514 <Layout title={`${owner}/${repo}`} user={user}>
515 <RepoHeader
516 owner={owner}
517 repo={repo}
518 starCount={starCount}
519 starred={starred}
520 currentUser={user?.username}
71cd5ecClaude521 archived={archived}
522 isTemplate={isTemplate}
06d5ffeClaude523 />
fc1817aClaude524 <RepoNav owner={owner} repo={repo} active="code" />
525 <div class="empty-state">
526 <h2>Empty repository</h2>
527 <p>Get started by pushing code:</p>
ea52715copilot-swe-agent[bot]528 <pre>{`git remote add gluecron ${config.appBaseUrl}/${owner}/${repo}.git
fc1817aClaude529git push -u gluecron main`}</pre>
530 </div>
531 </Layout>
532 );
533 }
534
535 const readme = await getReadme(owner, repo, defaultBranch);
536
537 return c.html(
06d5ffeClaude538 <Layout title={`${owner}/${repo}`} user={user}>
539 <RepoHeader
540 owner={owner}
541 repo={repo}
542 starCount={starCount}
543 starred={starred}
544 currentUser={user?.username}
71cd5ecClaude545 archived={archived}
546 isTemplate={isTemplate}
06d5ffeClaude547 />
71cd5ecClaude548 {isTemplate && user && user.username !== owner && (
549 <div
550 class="panel"
551 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
552 >
553 <div style="font-size:13px">
554 <strong>Template repository.</strong> Create a new repository from
555 this template's files.
556 </div>
557 <form
001af43Claude558 method="post"
71cd5ecClaude559 action={`/${owner}/${repo}/use-template`}
560 style="display:flex;gap:8px;align-items:center"
561 >
562 <input
563 type="text"
564 name="name"
565 placeholder="new-repo-name"
566 required
2c3ba6ecopilot-swe-agent[bot]567 aria-label="New repository name"
71cd5ecClaude568 style="width:200px"
569 />
570 <button type="submit" class="btn btn-primary">
571 Use this template
572 </button>
573 </form>
574 </div>
575 )}
fc1817aClaude576 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude577 <BranchSwitcher
578 owner={owner}
579 repo={repo}
580 currentRef={defaultBranch}
581 branches={branches}
582 pathType="tree"
583 />
fc1817aClaude584 <FileTable
585 entries={tree}
586 owner={owner}
587 repo={repo}
588 ref={defaultBranch}
589 path=""
590 />
79136bbClaude591 {readme && (() => {
592 const readmeHtml = renderMarkdown(readme);
593 return (
594 <div class="blob-view" style="margin-top: 20px">
595 <div class="blob-header">README.md</div>
596 <style>{markdownCss}</style>
597 <div class="markdown-body">
598 {html([readmeHtml] as unknown as TemplateStringsArray)}
599 </div>
fc1817aClaude600 </div>
79136bbClaude601 );
602 })()}
fc1817aClaude603 </Layout>
604 );
605});
606
607// Browse tree at ref/path
608web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
609 const { owner, repo } = c.req.param();
06d5ffeClaude610 const user = c.get("user");
fc1817aClaude611 const refAndPath = c.req.param("ref");
612
613 const branches = await listBranches(owner, repo);
614 let ref = "";
615 let treePath = "";
616
617 for (const branch of branches) {
618 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
619 ref = branch;
620 treePath = refAndPath.slice(branch.length + 1);
621 break;
622 }
623 }
624
625 if (!ref) {
626 const slashIdx = refAndPath.indexOf("/");
627 if (slashIdx === -1) {
628 ref = refAndPath;
629 } else {
630 ref = refAndPath.slice(0, slashIdx);
631 treePath = refAndPath.slice(slashIdx + 1);
632 }
633 }
634
635 const tree = await getTree(owner, repo, ref, treePath);
636
637 return c.html(
06d5ffeClaude638 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude639 <RepoHeader owner={owner} repo={repo} />
640 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude641 <BranchSwitcher
642 owner={owner}
643 repo={repo}
644 currentRef={ref}
645 branches={branches}
646 pathType="tree"
647 subPath={treePath}
648 />
fc1817aClaude649 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
650 <FileTable
651 entries={tree}
652 owner={owner}
653 repo={repo}
654 ref={ref}
655 path={treePath}
656 />
657 </Layout>
658 );
659});
660
06d5ffeClaude661// View file blob with syntax highlighting
fc1817aClaude662web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
663 const { owner, repo } = c.req.param();
06d5ffeClaude664 const user = c.get("user");
fc1817aClaude665 const refAndPath = c.req.param("ref");
666
667 const branches = await listBranches(owner, repo);
668 let ref = "";
669 let filePath = "";
670
671 for (const branch of branches) {
672 if (refAndPath.startsWith(branch + "/")) {
673 ref = branch;
674 filePath = refAndPath.slice(branch.length + 1);
675 break;
676 }
677 }
678
679 if (!ref) {
680 const slashIdx = refAndPath.indexOf("/");
681 if (slashIdx === -1) return c.text("Not found", 404);
682 ref = refAndPath.slice(0, slashIdx);
683 filePath = refAndPath.slice(slashIdx + 1);
684 }
685
686 const blob = await getBlob(owner, repo, ref, filePath);
687 if (!blob) {
688 return c.html(
06d5ffeClaude689 <Layout title="Not Found" user={user}>
fc1817aClaude690 <div class="empty-state">
691 <h2>File not found</h2>
692 </div>
693 </Layout>,
694 404
695 );
696 }
697
06d5ffeClaude698 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude699
700 return c.html(
06d5ffeClaude701 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude702 <RepoHeader owner={owner} repo={repo} />
703 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude704 <BranchSwitcher
705 owner={owner}
706 repo={repo}
707 currentRef={ref}
708 branches={branches}
709 pathType="blob"
710 subPath={filePath}
711 />
fc1817aClaude712 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
713 <div class="blob-view">
714 <div class="blob-header">
06d5ffeClaude715 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude716 <span style="display: flex; gap: 12px">
717 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
718 Raw
719 </a>
720 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
721 Blame
722 </a>
16b325cClaude723 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
724 History
725 </a>
0074234Claude726 {user && (
727 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
728 Edit
729 </a>
730 )}
79136bbClaude731 </span>
fc1817aClaude732 </div>
733 {blob.isBinary ? (
734 <div style="padding: 16px; color: var(--text-muted)">
735 Binary file not shown.
736 </div>
06d5ffeClaude737 ) : (() => {
738 const { html: highlighted, language } = highlightCode(
739 blob.content,
740 fileName
741 );
742 const lineCount = blob.content.split("\n").length;
743 // Trim trailing newline from count
744 const adjustedCount =
745 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
746
747 if (language) {
748 return (
749 <HighlightedCode
750 highlightedHtml={highlighted}
751 lineCount={adjustedCount}
752 />
753 );
754 }
755 const lines = blob.content.split("\n");
756 if (lines[lines.length - 1] === "") lines.pop();
757 return <PlainCode lines={lines} />;
758 })()}
fc1817aClaude759 </div>
760 </Layout>
761 );
762});
763
764// Commit log
765web.get("/:owner/:repo/commits/:ref?", async (c) => {
766 const { owner, repo } = c.req.param();
06d5ffeClaude767 const user = c.get("user");
fc1817aClaude768 const ref =
769 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude770 const branches = await listBranches(owner, repo);
fc1817aClaude771
772 const commits = await listCommits(owner, repo, ref, 50);
773
3951454Claude774 // Block J3 — batch-fetch cached verification results for the page.
775 let verifications: Record<string, { verified: boolean; reason: string }> = {};
776 try {
777 const [ownerRow] = await db
778 .select()
779 .from(users)
780 .where(eq(users.username, owner))
781 .limit(1);
782 if (ownerRow) {
783 const [repoRow] = await db
784 .select()
785 .from(repositories)
786 .where(
787 and(
788 eq(repositories.ownerId, ownerRow.id),
789 eq(repositories.name, repo)
790 )
791 )
792 .limit(1);
793 if (repoRow && commits.length > 0) {
794 const rows = await db
795 .select()
796 .from(commitVerifications)
797 .where(
798 and(
799 eq(commitVerifications.repositoryId, repoRow.id),
800 inArray(
801 commitVerifications.commitSha,
802 commits.map((c) => c.sha)
803 )
804 )
805 );
806 for (const r of rows) {
807 verifications[r.commitSha] = {
808 verified: r.verified,
809 reason: r.reason,
810 };
811 }
812 }
813 }
814 } catch {
815 // DB unavailable — skip the badges gracefully.
816 }
817
fc1817aClaude818 return c.html(
06d5ffeClaude819 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude820 <RepoHeader owner={owner} repo={repo} />
821 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude822 <BranchSwitcher
823 owner={owner}
824 repo={repo}
825 currentRef={ref}
826 branches={branches}
827 pathType="commits"
828 />
fc1817aClaude829 {commits.length === 0 ? (
830 <div class="empty-state">
831 <p>No commits yet.</p>
832 </div>
833 ) : (
3951454Claude834 <CommitList
835 commits={commits}
836 owner={owner}
837 repo={repo}
838 verifications={verifications}
839 />
fc1817aClaude840 )}
841 </Layout>
842 );
843});
844
845// Single commit with diff
846web.get("/:owner/:repo/commit/:sha", async (c) => {
847 const { owner, repo, sha } = c.req.param();
06d5ffeClaude848 const user = c.get("user");
fc1817aClaude849
05b973eClaude850 // Fetch commit, full message, and diff in parallel
851 const [commit, fullMessage, diffResult] = await Promise.all([
852 getCommit(owner, repo, sha),
853 getCommitFullMessage(owner, repo, sha),
854 getDiff(owner, repo, sha),
855 ]);
fc1817aClaude856 if (!commit) {
857 return c.html(
06d5ffeClaude858 <Layout title="Not Found" user={user}>
fc1817aClaude859 <div class="empty-state">
860 <h2>Commit not found</h2>
861 </div>
862 </Layout>,
863 404
864 );
865 }
866
3951454Claude867 // Block J3 — try to verify this commit's signature.
868 let verification:
869 | { verified: boolean; reason: string; signatureType: string | null }
870 | null = null;
0cdfd89Claude871 // Block J8 — external CI commit statuses rollup.
872 let statusCombined:
873 | {
874 state: "pending" | "success" | "failure";
875 total: number;
876 contexts: Array<{
877 context: string;
878 state: string;
879 description: string | null;
880 targetUrl: string | null;
881 }>;
882 }
883 | null = null;
3951454Claude884 try {
885 const [ownerRow] = await db
886 .select()
887 .from(users)
888 .where(eq(users.username, owner))
889 .limit(1);
890 if (ownerRow) {
891 const [repoRow] = await db
892 .select()
893 .from(repositories)
894 .where(
895 and(
896 eq(repositories.ownerId, ownerRow.id),
897 eq(repositories.name, repo)
898 )
899 )
900 .limit(1);
901 if (repoRow) {
902 const { verifyCommit } = await import("../lib/signatures");
903 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
904 verification = {
905 verified: v.verified,
906 reason: v.reason,
907 signatureType: v.signatureType,
908 };
0cdfd89Claude909 try {
910 const { combinedStatus } = await import("../lib/commit-statuses");
911 const combined = await combinedStatus(repoRow.id, commit.sha);
912 if (combined.total > 0) {
913 statusCombined = {
914 state: combined.state as any,
915 total: combined.total,
916 contexts: combined.contexts.map((c) => ({
917 context: c.context,
918 state: c.state,
919 description: c.description,
920 targetUrl: c.targetUrl,
921 })),
922 };
923 }
924 } catch {
925 statusCombined = null;
926 }
3951454Claude927 }
928 }
929 } catch {
930 verification = null;
931 }
932
05b973eClaude933 const { files, raw } = diffResult;
fc1817aClaude934
935 return c.html(
06d5ffeClaude936 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude937 <RepoHeader owner={owner} repo={repo} />
938 <div
939 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
940 >
941 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
942 {commit.message}
943 </div>
944 {fullMessage !== commit.message && (
945 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
946 {fullMessage}
947 </div>
948 )}
949 <div style="font-size: 13px; color: var(--text-muted)">
950 <strong style="color: var(--text)">{commit.author}</strong>{" "}
951 committed on{" "}
952 {new Date(commit.date).toLocaleDateString("en-US", {
953 month: "long",
954 day: "numeric",
955 year: "numeric",
956 })}
3951454Claude957 {verification && verification.reason !== "unsigned" && (
958 <span
959 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
960 verification.verified
961 ? "var(--green,#2ea043)"
962 : "var(--yellow,#d29922)"
963 }`}
964 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
965 >
966 {verification.verified ? "Verified" : verification.reason}
967 </span>
968 )}
fc1817aClaude969 </div>
970 <div style="margin-top: 8px">
971 <span class="commit-sha">{commit.sha}</span>
972 {commit.parentShas.length > 0 && (
973 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
974 Parent:{" "}
975 {commit.parentShas.map((p) => (
976 <a
977 href={`/${owner}/${repo}/commit/${p}`}
978 class="commit-sha"
979 style="margin-left: 4px"
980 >
981 {p.slice(0, 7)}
982 </a>
983 ))}
984 </span>
985 )}
986 </div>
0cdfd89Claude987 {statusCombined && (
988 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
989 <strong style="color: var(--text)">Checks</strong>
990 <span style="margin-left: 8px; color: var(--text-muted)">
991 {statusCombined.total} total —{" "}
992 <span
993 style={`color:${
994 statusCombined.state === "success"
995 ? "var(--green,#2ea043)"
996 : statusCombined.state === "failure"
997 ? "var(--red,#da3633)"
998 : "var(--yellow,#d29922)"
999 }`}
1000 >
1001 {statusCombined.state}
1002 </span>
1003 </span>
1004 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1005 {statusCombined.contexts.map((cx) => (
1006 <span
1007 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1008 cx.state === "success"
1009 ? "var(--green,#2ea043)"
1010 : cx.state === "pending"
1011 ? "var(--yellow,#d29922)"
1012 : "var(--red,#da3633)"
1013 }`}
1014 title={cx.description || cx.context}
1015 >
1016 {cx.targetUrl ? (
1017 <a
1018 href={cx.targetUrl}
1019 style="color: inherit; text-decoration: none"
1020 rel="noopener"
1021 >
1022 {cx.context}: {cx.state}
1023 </a>
1024 ) : (
1025 <>
1026 {cx.context}: {cx.state}
1027 </>
1028 )}
1029 </span>
1030 ))}
1031 </div>
1032 </div>
1033 )}
fc1817aClaude1034 </div>
1035 <DiffView raw={raw} files={files} />
1036 </Layout>
1037 );
1038});
1039
79136bbClaude1040// Raw file download
1041web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1042 const { owner, repo } = c.req.param();
1043 const refAndPath = c.req.param("ref");
1044
1045 const branches = await listBranches(owner, repo);
1046 let ref = "";
1047 let filePath = "";
1048
1049 for (const branch of branches) {
1050 if (refAndPath.startsWith(branch + "/")) {
1051 ref = branch;
1052 filePath = refAndPath.slice(branch.length + 1);
1053 break;
1054 }
1055 }
1056
1057 if (!ref) {
1058 const slashIdx = refAndPath.indexOf("/");
1059 if (slashIdx === -1) return c.text("Not found", 404);
1060 ref = refAndPath.slice(0, slashIdx);
1061 filePath = refAndPath.slice(slashIdx + 1);
1062 }
1063
1064 const data = await getRawBlob(owner, repo, ref, filePath);
1065 if (!data) return c.text("Not found", 404);
1066
1067 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1068 return new Response(data as BodyInit, {
79136bbClaude1069 headers: {
1070 "Content-Type": "application/octet-stream",
1071 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1072 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1073 },
1074 });
1075});
1076
1077// Blame view
1078web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1079 const { owner, repo } = c.req.param();
1080 const user = c.get("user");
1081 const refAndPath = c.req.param("ref");
1082
1083 const branches = await listBranches(owner, repo);
1084 let ref = "";
1085 let filePath = "";
1086
1087 for (const branch of branches) {
1088 if (refAndPath.startsWith(branch + "/")) {
1089 ref = branch;
1090 filePath = refAndPath.slice(branch.length + 1);
1091 break;
1092 }
1093 }
1094
1095 if (!ref) {
1096 const slashIdx = refAndPath.indexOf("/");
1097 if (slashIdx === -1) return c.text("Not found", 404);
1098 ref = refAndPath.slice(0, slashIdx);
1099 filePath = refAndPath.slice(slashIdx + 1);
1100 }
1101
1102 const blameLines = await getBlame(owner, repo, ref, filePath);
1103 if (blameLines.length === 0) {
1104 return c.html(
1105 <Layout title="Not Found" user={user}>
1106 <div class="empty-state">
1107 <h2>File not found</h2>
1108 </div>
1109 </Layout>,
1110 404
1111 );
1112 }
1113
1114 const fileName = filePath.split("/").pop() || filePath;
1115
1116 return c.html(
1117 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1118 <RepoHeader owner={owner} repo={repo} />
1119 <RepoNav owner={owner} repo={repo} active="code" />
1120 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1121 <div class="blob-view">
1122 <div class="blob-header">
1123 <span>{fileName} — blame</span>
1124 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1125 Normal view
1126 </a>
1127 </div>
1128 <div class="blob-code" style="overflow-x: auto">
1129 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1130 <tbody>
1131 {blameLines.map((line, i) => {
1132 const showInfo =
1133 i === 0 || blameLines[i - 1].sha !== line.sha;
1134 return (
1135 <tr style="border-bottom: 1px solid var(--border)">
1136 <td
1137 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)" : ""}`}
1138 >
1139 {showInfo && (
1140 <>
1141 <a
1142 href={`/${owner}/${repo}/commit/${line.sha}`}
1143 style="color: var(--text-link); font-family: var(--font-mono)"
1144 >
1145 {line.sha.slice(0, 7)}
1146 </a>{" "}
1147 <span>{line.author}</span>
1148 </>
1149 )}
1150 </td>
1151 <td class="line-num">{line.lineNum}</td>
1152 <td class="line-content">{line.content}</td>
1153 </tr>
1154 );
1155 })}
1156 </tbody>
1157 </table>
1158 </div>
1159 </div>
1160 </Layout>
1161 );
1162});
1163
1164// Search
1165web.get("/:owner/:repo/search", async (c) => {
1166 const { owner, repo } = c.req.param();
1167 const user = c.get("user");
1168 const q = c.req.query("q") || "";
1169
1170 if (!(await repoExists(owner, repo))) return c.notFound();
1171
1172 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1173 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1174
1175 if (q.trim()) {
1176 results = await searchCode(owner, repo, defaultBranch, q.trim());
1177 }
1178
1179 return c.html(
1180 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1181 <RepoHeader owner={owner} repo={repo} />
1182 <RepoNav owner={owner} repo={repo} active="code" />
1183 <form
001af43Claude1184 method="get"
79136bbClaude1185 action={`/${owner}/${repo}/search`}
1186 style="margin-bottom: 20px"
1187 >
1188 <div style="display: flex; gap: 8px">
1189 <input
1190 type="text"
1191 name="q"
1192 value={q}
1193 placeholder="Search code..."
2c3ba6ecopilot-swe-agent[bot]1194 aria-label="Search code"
79136bbClaude1195 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1196 />
1197 <button type="submit" class="btn btn-primary">
1198 Search
1199 </button>
1200 </div>
1201 </form>
1202 {q && (
1203 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1204 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1205 <strong style="color: var(--text)">"{q}"</strong>
1206 </p>
1207 )}
1208 {results.length > 0 && (
1209 <div class="search-results">
1210 {(() => {
1211 // Group by file
1212 const grouped: Record<
1213 string,
1214 Array<{ lineNum: number; line: string }>
1215 > = {};
1216 for (const r of results) {
1217 if (!grouped[r.file]) grouped[r.file] = [];
1218 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1219 }
1220 return Object.entries(grouped).map(([file, matches]) => (
1221 <div class="diff-file" style="margin-bottom: 12px">
1222 <div class="diff-file-header">
1223 <a
1224 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1225 >
1226 {file}
1227 </a>
1228 </div>
1229 <div class="blob-code">
1230 <table>
1231 <tbody>
1232 {matches.map((m) => (
1233 <tr>
1234 <td class="line-num">{m.lineNum}</td>
1235 <td class="line-content">{m.line}</td>
1236 </tr>
1237 ))}
1238 </tbody>
1239 </table>
1240 </div>
1241 </div>
1242 ));
1243 })()}
1244 </div>
1245 )}
1246 </Layout>
1247 );
1248});
1249
fc1817aClaude1250export default web;