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.tsxBlame1238 lines · 1 contributor
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";
3951454Claude10import {
11 users,
12 repositories,
13 stars,
14 commitVerifications,
15} from "../db/schema";
fc1817aClaude16import { Layout } from "../views/layout";
17import {
18 RepoHeader,
19 RepoNav,
20 Breadcrumb,
21 FileTable,
22 CommitList,
23 DiffView,
06d5ffeClaude24 RepoCard,
25 BranchSwitcher,
26 HighlightedCode,
27 PlainCode,
fc1817aClaude28} from "../views/components";
29import {
30 getTree,
31 getBlob,
32 listCommits,
33 getCommit,
34 getCommitFullMessage,
35 getDiff,
36 getReadme,
37 getDefaultBranch,
38 listBranches,
39 repoExists,
06d5ffeClaude40 initBareRepo,
79136bbClaude41 getBlame,
42 getRawBlob,
43 searchCode,
fc1817aClaude44} from "../git/repository";
79136bbClaude45import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude46import { highlightCode } from "../lib/highlight";
47import { softAuth, requireAuth } from "../middleware/auth";
48import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude49import { trackByName } from "../lib/traffic";
2b821b7Claude50import { LandingPage } from "../views/landing";
fc1817aClaude51
06d5ffeClaude52const web = new Hono<AuthEnv>();
53
54// Soft auth on all web routes — c.get("user") available but may be null
55web.use("*", softAuth);
fc1817aClaude56
57// Home page
06d5ffeClaude58web.get("/", async (c) => {
59 const user = c.get("user");
60
61 if (user) {
0316dbbClaude62 return c.redirect("/dashboard");
06d5ffeClaude63 }
64
8e9f1d9Claude65 let stats: { publicRepos?: number; users?: number } | undefined;
66 try {
67 const [repoRow] = await db
68 .select({ n: sql<number>`count(*)::int` })
69 .from(repositories)
70 .where(eq(repositories.isPrivate, false));
71 const [userRow] = await db
72 .select({ n: sql<number>`count(*)::int` })
73 .from(users);
74 stats = {
75 publicRepos: Number(repoRow?.n ?? 0),
76 users: Number(userRow?.n ?? 0),
77 };
78 } catch {
79 stats = undefined;
80 }
81
fc1817aClaude82 return c.html(
06d5ffeClaude83 <Layout user={null}>
8e9f1d9Claude84 <LandingPage stats={stats} />
fc1817aClaude85 </Layout>
86 );
87});
88
06d5ffeClaude89// New repository form
90web.get("/new", requireAuth, (c) => {
91 const user = c.get("user")!;
92 const error = c.req.query("error");
93
94 return c.html(
95 <Layout title="New repository" user={user}>
96 <div class="new-repo-form">
97 <h2>Create a new repository</h2>
98 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude99 <form method="post" action="/new">
06d5ffeClaude100 <div class="form-group">
101 <label>Owner</label>
102 <input type="text" value={user.username} disabled class="input-disabled" />
103 </div>
104 <div class="form-group">
105 <label for="name">Repository name</label>
106 <input
107 type="text"
108 id="name"
109 name="name"
110 required
111 pattern="^[a-zA-Z0-9._-]+$"
112 placeholder="my-project"
113 autocomplete="off"
114 />
115 </div>
116 <div class="form-group">
117 <label for="description">Description (optional)</label>
118 <input
119 type="text"
120 id="description"
121 name="description"
122 placeholder="A short description of your repository"
123 />
124 </div>
125 <div class="visibility-options">
126 <label class="visibility-option">
127 <input type="radio" name="visibility" value="public" checked />
128 <div class="vis-label">Public</div>
129 <div class="vis-desc">Anyone can see this repository</div>
130 </label>
131 <label class="visibility-option">
132 <input type="radio" name="visibility" value="private" />
133 <div class="vis-label">Private</div>
134 <div class="vis-desc">Only you can see this repository</div>
135 </label>
136 </div>
137 <button type="submit" class="btn btn-primary">
138 Create repository
139 </button>
140 </form>
141 </div>
142 </Layout>
143 );
144});
145
146web.post("/new", requireAuth, async (c) => {
147 const user = c.get("user")!;
148 const body = await c.req.parseBody();
149 const name = String(body.name || "").trim();
150 const description = String(body.description || "").trim();
151 const isPrivate = body.visibility === "private";
152
153 if (!name) {
154 return c.redirect("/new?error=Repository+name+is+required");
155 }
156
157 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
158 return c.redirect("/new?error=Invalid+repository+name");
159 }
160
161 if (await repoExists(user.username, name)) {
162 return c.redirect("/new?error=Repository+already+exists");
163 }
164
165 const diskPath = await initBareRepo(user.username, name);
166
3ef4c9dClaude167 const [newRepo] = await db
168 .insert(repositories)
169 .values({
170 name,
171 ownerId: user.id,
172 description: description || null,
173 isPrivate,
174 diskPath,
175 })
176 .returning();
177
178 if (newRepo) {
179 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
180 await bootstrapRepository({
181 repositoryId: newRepo.id,
182 ownerUserId: user.id,
183 defaultBranch: "main",
184 });
185 }
06d5ffeClaude186
187 return c.redirect(`/${user.username}/${name}`);
188});
189
190// User profile
fc1817aClaude191web.get("/:owner", async (c) => {
06d5ffeClaude192 const { owner: ownerName } = c.req.param();
193 const user = c.get("user");
194
195 // Avoid clashing with fixed routes
196 if (
197 ["login", "register", "logout", "new", "settings", "api"].includes(
198 ownerName
199 )
200 ) {
201 return c.notFound();
202 }
203
204 let ownerUser;
205 try {
206 const [found] = await db
207 .select()
208 .from(users)
209 .where(eq(users.username, ownerName))
210 .limit(1);
211 ownerUser = found;
212 } catch {
213 // DB not available — check if repos exist on disk
214 ownerUser = null;
215 }
216
217 // Even without DB, show repos if they exist on disk
218 let repos: any[] = [];
219 if (ownerUser) {
220 const allRepos = await db
221 .select()
222 .from(repositories)
223 .where(eq(repositories.ownerId, ownerUser.id))
224 .orderBy(desc(repositories.updatedAt));
225
226 // Show public repos to everyone, private only to owner
227 repos =
228 user?.id === ownerUser.id
229 ? allRepos
230 : allRepos.filter((r) => !r.isPrivate);
231 }
232
7aa8b99Claude233 // Block J4 — follow counts + viewer's follow state
234 let followState = {
235 followers: 0,
236 following: 0,
237 viewerFollows: false,
238 };
239 if (ownerUser) {
240 try {
241 const { followCounts, isFollowing } = await import("../lib/follows");
242 const counts = await followCounts(ownerUser.id);
243 followState.followers = counts.followers;
244 followState.following = counts.following;
245 if (user && user.id !== ownerUser.id) {
246 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
247 }
248 } catch {
249 // DB hiccup — fall back to zeros.
250 }
251 }
252 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
253
d412586Claude254 // Block J5 — profile README. Render owner/owner repo's README on the
255 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
256 // back to "<user>/.github" for org-style profile repos.
257 let profileReadmeHtml: string | null = null;
258 try {
259 const candidates = [ownerName, ".github"];
260 for (const rname of candidates) {
261 if (await repoExists(ownerName, rname)) {
262 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
263 const md = await getReadme(ownerName, rname, ref);
264 if (md) {
265 profileReadmeHtml = renderMarkdown(md);
266 break;
267 }
268 }
269 }
270 } catch {
271 profileReadmeHtml = null;
272 }
273
fc1817aClaude274 return c.html(
06d5ffeClaude275 <Layout title={ownerName} user={user}>
276 <div class="user-profile">
277 <div class="user-avatar">
278 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
279 </div>
280 <div class="user-info">
281 <h2>{ownerUser?.displayName || ownerName}</h2>
282 <div class="username">@{ownerName}</div>
283 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude284 <div
285 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
286 >
287 <a
288 href={`/${ownerName}/followers`}
289 style="color:var(--text-muted)"
290 >
291 <strong style="color:var(--text)">
292 {followState.followers}
293 </strong>{" "}
294 follower{followState.followers === 1 ? "" : "s"}
295 </a>
296 <a
297 href={`/${ownerName}/following`}
298 style="color:var(--text-muted)"
299 >
300 <strong style="color:var(--text)">
301 {followState.following}
302 </strong>{" "}
303 following
304 </a>
305 {canFollow && (
306 <form
001af43Claude307 method="post"
7aa8b99Claude308 action={`/${ownerName}/${
309 followState.viewerFollows ? "unfollow" : "follow"
310 }`}
311 >
312 <button
313 type="submit"
314 class={`btn ${
315 followState.viewerFollows ? "" : "btn-primary"
316 } btn-sm`}
317 >
318 {followState.viewerFollows ? "Unfollow" : "Follow"}
319 </button>
320 </form>
321 )}
322 </div>
06d5ffeClaude323 </div>
324 </div>
d412586Claude325 {profileReadmeHtml && (
326 <div
327 class="markdown-body"
328 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
329 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
330 />
331 )}
06d5ffeClaude332 <h3 style="margin-bottom: 16px">Repositories</h3>
333 {repos.length === 0 ? (
334 <p style="color: var(--text-muted)">No repositories yet.</p>
335 ) : (
336 <div class="card-grid">
337 {repos.map((repo) => (
338 <RepoCard repo={repo} ownerName={ownerName} />
339 ))}
340 </div>
341 )}
fc1817aClaude342 </Layout>
343 );
344});
345
06d5ffeClaude346// Star/unstar a repo
347web.post("/:owner/:repo/star", requireAuth, async (c) => {
348 const { owner: ownerName, repo: repoName } = c.req.param();
349 const user = c.get("user")!;
350
351 try {
352 const [ownerUser] = await db
353 .select()
354 .from(users)
355 .where(eq(users.username, ownerName))
356 .limit(1);
357 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
358
359 const [repo] = await db
360 .select()
361 .from(repositories)
362 .where(
363 and(
364 eq(repositories.ownerId, ownerUser.id),
365 eq(repositories.name, repoName)
366 )
367 )
368 .limit(1);
369 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
370
371 // Toggle star
372 const [existing] = await db
373 .select()
374 .from(stars)
375 .where(
376 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
377 )
378 .limit(1);
379
380 if (existing) {
381 await db.delete(stars).where(eq(stars.id, existing.id));
382 await db
383 .update(repositories)
384 .set({ starCount: Math.max(0, repo.starCount - 1) })
385 .where(eq(repositories.id, repo.id));
386 } else {
387 await db.insert(stars).values({
388 userId: user.id,
389 repositoryId: repo.id,
390 });
391 await db
392 .update(repositories)
393 .set({ starCount: repo.starCount + 1 })
394 .where(eq(repositories.id, repo.id));
395 }
396 } catch {
397 // DB error — ignore
398 }
399
400 return c.redirect(`/${ownerName}/${repoName}`);
401});
402
fc1817aClaude403// Repository overview — file tree at HEAD
404web.get("/:owner/:repo", async (c) => {
405 const { owner, repo } = c.req.param();
06d5ffeClaude406 const user = c.get("user");
fc1817aClaude407
8f50ed0Claude408 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
409 trackByName(owner, repo, "view", {
410 userId: user?.id || null,
411 path: `/${owner}/${repo}`,
412 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
413 userAgent: c.req.header("user-agent") || null,
414 referer: c.req.header("referer") || null,
415 }).catch(() => {});
416
fc1817aClaude417 if (!(await repoExists(owner, repo))) {
418 return c.html(
06d5ffeClaude419 <Layout title="Not Found" user={user}>
fc1817aClaude420 <div class="empty-state">
421 <h2>Repository not found</h2>
422 <p>
423 {owner}/{repo} does not exist.
424 </p>
425 </div>
426 </Layout>,
427 404
428 );
429 }
430
05b973eClaude431 // Parallelize all independent operations
432 const [defaultBranch, branches] = await Promise.all([
433 getDefaultBranch(owner, repo).then((b) => b || "main"),
434 listBranches(owner, repo),
435 ]);
436 const [tree, starInfo] = await Promise.all([
437 getTree(owner, repo, defaultBranch),
438 // Star info fetched in parallel with tree
439 (async () => {
440 try {
441 const [ownerUser] = await db
442 .select()
443 .from(users)
444 .where(eq(users.username, owner))
445 .limit(1);
71cd5ecClaude446 if (!ownerUser)
447 return {
448 starCount: 0,
449 starred: false,
450 archived: false,
451 isTemplate: false,
452 };
05b973eClaude453 const [repoRow] = await db
454 .select()
455 .from(repositories)
456 .where(
457 and(
458 eq(repositories.ownerId, ownerUser.id),
459 eq(repositories.name, repo)
460 )
06d5ffeClaude461 )
05b973eClaude462 .limit(1);
71cd5ecClaude463 if (!repoRow)
464 return {
465 starCount: 0,
466 starred: false,
467 archived: false,
468 isTemplate: false,
469 };
05b973eClaude470 let starred = false;
06d5ffeClaude471 if (user) {
472 const [star] = await db
473 .select()
474 .from(stars)
475 .where(
476 and(
477 eq(stars.userId, user.id),
478 eq(stars.repositoryId, repoRow.id)
479 )
480 )
481 .limit(1);
482 starred = !!star;
483 }
71cd5ecClaude484 return {
485 starCount: repoRow.starCount,
486 starred,
487 archived: repoRow.isArchived,
488 isTemplate: repoRow.isTemplate,
489 };
05b973eClaude490 } catch {
71cd5ecClaude491 return {
492 starCount: 0,
493 starred: false,
494 archived: false,
495 isTemplate: false,
496 };
06d5ffeClaude497 }
05b973eClaude498 })(),
499 ]);
71cd5ecClaude500 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude501
fc1817aClaude502 if (tree.length === 0) {
503 return c.html(
06d5ffeClaude504 <Layout title={`${owner}/${repo}`} user={user}>
505 <RepoHeader
506 owner={owner}
507 repo={repo}
508 starCount={starCount}
509 starred={starred}
510 currentUser={user?.username}
71cd5ecClaude511 archived={archived}
512 isTemplate={isTemplate}
06d5ffeClaude513 />
fc1817aClaude514 <RepoNav owner={owner} repo={repo} active="code" />
515 <div class="empty-state">
516 <h2>Empty repository</h2>
517 <p>Get started by pushing code:</p>
518 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
519git push -u gluecron main`}</pre>
520 </div>
521 </Layout>
522 );
523 }
524
525 const readme = await getReadme(owner, repo, defaultBranch);
526
527 return c.html(
06d5ffeClaude528 <Layout title={`${owner}/${repo}`} user={user}>
529 <RepoHeader
530 owner={owner}
531 repo={repo}
532 starCount={starCount}
533 starred={starred}
534 currentUser={user?.username}
71cd5ecClaude535 archived={archived}
536 isTemplate={isTemplate}
06d5ffeClaude537 />
71cd5ecClaude538 {isTemplate && user && user.username !== owner && (
539 <div
540 class="panel"
541 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
542 >
543 <div style="font-size:13px">
544 <strong>Template repository.</strong> Create a new repository from
545 this template's files.
546 </div>
547 <form
001af43Claude548 method="post"
71cd5ecClaude549 action={`/${owner}/${repo}/use-template`}
550 style="display:flex;gap:8px;align-items:center"
551 >
552 <input
553 type="text"
554 name="name"
555 placeholder="new-repo-name"
556 required
557 style="width:200px"
558 />
559 <button type="submit" class="btn btn-primary">
560 Use this template
561 </button>
562 </form>
563 </div>
564 )}
fc1817aClaude565 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude566 <BranchSwitcher
567 owner={owner}
568 repo={repo}
569 currentRef={defaultBranch}
570 branches={branches}
571 pathType="tree"
572 />
fc1817aClaude573 <FileTable
574 entries={tree}
575 owner={owner}
576 repo={repo}
577 ref={defaultBranch}
578 path=""
579 />
79136bbClaude580 {readme && (() => {
581 const readmeHtml = renderMarkdown(readme);
582 return (
583 <div class="blob-view" style="margin-top: 20px">
584 <div class="blob-header">README.md</div>
585 <style>{markdownCss}</style>
586 <div class="markdown-body">
587 {html([readmeHtml] as unknown as TemplateStringsArray)}
588 </div>
fc1817aClaude589 </div>
79136bbClaude590 );
591 })()}
fc1817aClaude592 </Layout>
593 );
594});
595
596// Browse tree at ref/path
597web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
598 const { owner, repo } = c.req.param();
06d5ffeClaude599 const user = c.get("user");
fc1817aClaude600 const refAndPath = c.req.param("ref");
601
602 const branches = await listBranches(owner, repo);
603 let ref = "";
604 let treePath = "";
605
606 for (const branch of branches) {
607 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
608 ref = branch;
609 treePath = refAndPath.slice(branch.length + 1);
610 break;
611 }
612 }
613
614 if (!ref) {
615 const slashIdx = refAndPath.indexOf("/");
616 if (slashIdx === -1) {
617 ref = refAndPath;
618 } else {
619 ref = refAndPath.slice(0, slashIdx);
620 treePath = refAndPath.slice(slashIdx + 1);
621 }
622 }
623
624 const tree = await getTree(owner, repo, ref, treePath);
625
626 return c.html(
06d5ffeClaude627 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude628 <RepoHeader owner={owner} repo={repo} />
629 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude630 <BranchSwitcher
631 owner={owner}
632 repo={repo}
633 currentRef={ref}
634 branches={branches}
635 pathType="tree"
636 subPath={treePath}
637 />
fc1817aClaude638 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
639 <FileTable
640 entries={tree}
641 owner={owner}
642 repo={repo}
643 ref={ref}
644 path={treePath}
645 />
646 </Layout>
647 );
648});
649
06d5ffeClaude650// View file blob with syntax highlighting
fc1817aClaude651web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
652 const { owner, repo } = c.req.param();
06d5ffeClaude653 const user = c.get("user");
fc1817aClaude654 const refAndPath = c.req.param("ref");
655
656 const branches = await listBranches(owner, repo);
657 let ref = "";
658 let filePath = "";
659
660 for (const branch of branches) {
661 if (refAndPath.startsWith(branch + "/")) {
662 ref = branch;
663 filePath = refAndPath.slice(branch.length + 1);
664 break;
665 }
666 }
667
668 if (!ref) {
669 const slashIdx = refAndPath.indexOf("/");
670 if (slashIdx === -1) return c.text("Not found", 404);
671 ref = refAndPath.slice(0, slashIdx);
672 filePath = refAndPath.slice(slashIdx + 1);
673 }
674
675 const blob = await getBlob(owner, repo, ref, filePath);
676 if (!blob) {
677 return c.html(
06d5ffeClaude678 <Layout title="Not Found" user={user}>
fc1817aClaude679 <div class="empty-state">
680 <h2>File not found</h2>
681 </div>
682 </Layout>,
683 404
684 );
685 }
686
06d5ffeClaude687 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude688
689 return c.html(
06d5ffeClaude690 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude691 <RepoHeader owner={owner} repo={repo} />
692 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude693 <BranchSwitcher
694 owner={owner}
695 repo={repo}
696 currentRef={ref}
697 branches={branches}
698 pathType="blob"
699 subPath={filePath}
700 />
fc1817aClaude701 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
702 <div class="blob-view">
703 <div class="blob-header">
06d5ffeClaude704 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude705 <span style="display: flex; gap: 12px">
706 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
707 Raw
708 </a>
709 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
710 Blame
711 </a>
16b325cClaude712 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
713 History
714 </a>
0074234Claude715 {user && (
716 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
717 Edit
718 </a>
719 )}
79136bbClaude720 </span>
fc1817aClaude721 </div>
722 {blob.isBinary ? (
723 <div style="padding: 16px; color: var(--text-muted)">
724 Binary file not shown.
725 </div>
06d5ffeClaude726 ) : (() => {
727 const { html: highlighted, language } = highlightCode(
728 blob.content,
729 fileName
730 );
731 const lineCount = blob.content.split("\n").length;
732 // Trim trailing newline from count
733 const adjustedCount =
734 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
735
736 if (language) {
737 return (
738 <HighlightedCode
739 highlightedHtml={highlighted}
740 lineCount={adjustedCount}
741 />
742 );
743 }
744 const lines = blob.content.split("\n");
745 if (lines[lines.length - 1] === "") lines.pop();
746 return <PlainCode lines={lines} />;
747 })()}
fc1817aClaude748 </div>
749 </Layout>
750 );
751});
752
753// Commit log
754web.get("/:owner/:repo/commits/:ref?", async (c) => {
755 const { owner, repo } = c.req.param();
06d5ffeClaude756 const user = c.get("user");
fc1817aClaude757 const ref =
758 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude759 const branches = await listBranches(owner, repo);
fc1817aClaude760
761 const commits = await listCommits(owner, repo, ref, 50);
762
3951454Claude763 // Block J3 — batch-fetch cached verification results for the page.
764 let verifications: Record<string, { verified: boolean; reason: string }> = {};
765 try {
766 const [ownerRow] = await db
767 .select()
768 .from(users)
769 .where(eq(users.username, owner))
770 .limit(1);
771 if (ownerRow) {
772 const [repoRow] = await db
773 .select()
774 .from(repositories)
775 .where(
776 and(
777 eq(repositories.ownerId, ownerRow.id),
778 eq(repositories.name, repo)
779 )
780 )
781 .limit(1);
782 if (repoRow && commits.length > 0) {
783 const rows = await db
784 .select()
785 .from(commitVerifications)
786 .where(
787 and(
788 eq(commitVerifications.repositoryId, repoRow.id),
789 inArray(
790 commitVerifications.commitSha,
791 commits.map((c) => c.sha)
792 )
793 )
794 );
795 for (const r of rows) {
796 verifications[r.commitSha] = {
797 verified: r.verified,
798 reason: r.reason,
799 };
800 }
801 }
802 }
803 } catch {
804 // DB unavailable — skip the badges gracefully.
805 }
806
fc1817aClaude807 return c.html(
06d5ffeClaude808 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude809 <RepoHeader owner={owner} repo={repo} />
810 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude811 <BranchSwitcher
812 owner={owner}
813 repo={repo}
814 currentRef={ref}
815 branches={branches}
816 pathType="commits"
817 />
fc1817aClaude818 {commits.length === 0 ? (
819 <div class="empty-state">
820 <p>No commits yet.</p>
821 </div>
822 ) : (
3951454Claude823 <CommitList
824 commits={commits}
825 owner={owner}
826 repo={repo}
827 verifications={verifications}
828 />
fc1817aClaude829 )}
830 </Layout>
831 );
832});
833
834// Single commit with diff
835web.get("/:owner/:repo/commit/:sha", async (c) => {
836 const { owner, repo, sha } = c.req.param();
06d5ffeClaude837 const user = c.get("user");
fc1817aClaude838
05b973eClaude839 // Fetch commit, full message, and diff in parallel
840 const [commit, fullMessage, diffResult] = await Promise.all([
841 getCommit(owner, repo, sha),
842 getCommitFullMessage(owner, repo, sha),
843 getDiff(owner, repo, sha),
844 ]);
fc1817aClaude845 if (!commit) {
846 return c.html(
06d5ffeClaude847 <Layout title="Not Found" user={user}>
fc1817aClaude848 <div class="empty-state">
849 <h2>Commit not found</h2>
850 </div>
851 </Layout>,
852 404
853 );
854 }
855
3951454Claude856 // Block J3 — try to verify this commit's signature.
857 let verification:
858 | { verified: boolean; reason: string; signatureType: string | null }
859 | null = null;
0cdfd89Claude860 // Block J8 — external CI commit statuses rollup.
861 let statusCombined:
862 | {
863 state: "pending" | "success" | "failure";
864 total: number;
865 contexts: Array<{
866 context: string;
867 state: string;
868 description: string | null;
869 targetUrl: string | null;
870 }>;
871 }
872 | null = null;
3951454Claude873 try {
874 const [ownerRow] = await db
875 .select()
876 .from(users)
877 .where(eq(users.username, owner))
878 .limit(1);
879 if (ownerRow) {
880 const [repoRow] = await db
881 .select()
882 .from(repositories)
883 .where(
884 and(
885 eq(repositories.ownerId, ownerRow.id),
886 eq(repositories.name, repo)
887 )
888 )
889 .limit(1);
890 if (repoRow) {
891 const { verifyCommit } = await import("../lib/signatures");
892 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
893 verification = {
894 verified: v.verified,
895 reason: v.reason,
896 signatureType: v.signatureType,
897 };
0cdfd89Claude898 try {
899 const { combinedStatus } = await import("../lib/commit-statuses");
900 const combined = await combinedStatus(repoRow.id, commit.sha);
901 if (combined.total > 0) {
902 statusCombined = {
903 state: combined.state as any,
904 total: combined.total,
905 contexts: combined.contexts.map((c) => ({
906 context: c.context,
907 state: c.state,
908 description: c.description,
909 targetUrl: c.targetUrl,
910 })),
911 };
912 }
913 } catch {
914 statusCombined = null;
915 }
3951454Claude916 }
917 }
918 } catch {
919 verification = null;
920 }
921
05b973eClaude922 const { files, raw } = diffResult;
fc1817aClaude923
924 return c.html(
06d5ffeClaude925 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude926 <RepoHeader owner={owner} repo={repo} />
927 <div
928 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
929 >
930 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
931 {commit.message}
932 </div>
933 {fullMessage !== commit.message && (
934 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
935 {fullMessage}
936 </div>
937 )}
938 <div style="font-size: 13px; color: var(--text-muted)">
939 <strong style="color: var(--text)">{commit.author}</strong>{" "}
940 committed on{" "}
941 {new Date(commit.date).toLocaleDateString("en-US", {
942 month: "long",
943 day: "numeric",
944 year: "numeric",
945 })}
3951454Claude946 {verification && verification.reason !== "unsigned" && (
947 <span
948 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
949 verification.verified
950 ? "var(--green,#2ea043)"
951 : "var(--yellow,#d29922)"
952 }`}
953 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
954 >
955 {verification.verified ? "Verified" : verification.reason}
956 </span>
957 )}
fc1817aClaude958 </div>
959 <div style="margin-top: 8px">
960 <span class="commit-sha">{commit.sha}</span>
961 {commit.parentShas.length > 0 && (
962 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
963 Parent:{" "}
964 {commit.parentShas.map((p) => (
965 <a
966 href={`/${owner}/${repo}/commit/${p}`}
967 class="commit-sha"
968 style="margin-left: 4px"
969 >
970 {p.slice(0, 7)}
971 </a>
972 ))}
973 </span>
974 )}
975 </div>
0cdfd89Claude976 {statusCombined && (
977 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
978 <strong style="color: var(--text)">Checks</strong>
979 <span style="margin-left: 8px; color: var(--text-muted)">
980 {statusCombined.total} total —{" "}
981 <span
982 style={`color:${
983 statusCombined.state === "success"
984 ? "var(--green,#2ea043)"
985 : statusCombined.state === "failure"
986 ? "var(--red,#da3633)"
987 : "var(--yellow,#d29922)"
988 }`}
989 >
990 {statusCombined.state}
991 </span>
992 </span>
993 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
994 {statusCombined.contexts.map((cx) => (
995 <span
996 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
997 cx.state === "success"
998 ? "var(--green,#2ea043)"
999 : cx.state === "pending"
1000 ? "var(--yellow,#d29922)"
1001 : "var(--red,#da3633)"
1002 }`}
1003 title={cx.description || cx.context}
1004 >
1005 {cx.targetUrl ? (
1006 <a
1007 href={cx.targetUrl}
1008 style="color: inherit; text-decoration: none"
1009 rel="noopener"
1010 >
1011 {cx.context}: {cx.state}
1012 </a>
1013 ) : (
1014 <>
1015 {cx.context}: {cx.state}
1016 </>
1017 )}
1018 </span>
1019 ))}
1020 </div>
1021 </div>
1022 )}
fc1817aClaude1023 </div>
1024 <DiffView raw={raw} files={files} />
1025 </Layout>
1026 );
1027});
1028
79136bbClaude1029// Raw file download
1030web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1031 const { owner, repo } = c.req.param();
1032 const refAndPath = c.req.param("ref");
1033
1034 const branches = await listBranches(owner, repo);
1035 let ref = "";
1036 let filePath = "";
1037
1038 for (const branch of branches) {
1039 if (refAndPath.startsWith(branch + "/")) {
1040 ref = branch;
1041 filePath = refAndPath.slice(branch.length + 1);
1042 break;
1043 }
1044 }
1045
1046 if (!ref) {
1047 const slashIdx = refAndPath.indexOf("/");
1048 if (slashIdx === -1) return c.text("Not found", 404);
1049 ref = refAndPath.slice(0, slashIdx);
1050 filePath = refAndPath.slice(slashIdx + 1);
1051 }
1052
1053 const data = await getRawBlob(owner, repo, ref, filePath);
1054 if (!data) return c.text("Not found", 404);
1055
1056 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1057 return new Response(data as BodyInit, {
79136bbClaude1058 headers: {
1059 "Content-Type": "application/octet-stream",
1060 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1061 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1062 },
1063 });
1064});
1065
1066// Blame view
1067web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1068 const { owner, repo } = c.req.param();
1069 const user = c.get("user");
1070 const refAndPath = c.req.param("ref");
1071
1072 const branches = await listBranches(owner, repo);
1073 let ref = "";
1074 let filePath = "";
1075
1076 for (const branch of branches) {
1077 if (refAndPath.startsWith(branch + "/")) {
1078 ref = branch;
1079 filePath = refAndPath.slice(branch.length + 1);
1080 break;
1081 }
1082 }
1083
1084 if (!ref) {
1085 const slashIdx = refAndPath.indexOf("/");
1086 if (slashIdx === -1) return c.text("Not found", 404);
1087 ref = refAndPath.slice(0, slashIdx);
1088 filePath = refAndPath.slice(slashIdx + 1);
1089 }
1090
1091 const blameLines = await getBlame(owner, repo, ref, filePath);
1092 if (blameLines.length === 0) {
1093 return c.html(
1094 <Layout title="Not Found" user={user}>
1095 <div class="empty-state">
1096 <h2>File not found</h2>
1097 </div>
1098 </Layout>,
1099 404
1100 );
1101 }
1102
1103 const fileName = filePath.split("/").pop() || filePath;
1104
1105 return c.html(
1106 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1107 <RepoHeader owner={owner} repo={repo} />
1108 <RepoNav owner={owner} repo={repo} active="code" />
1109 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1110 <div class="blob-view">
1111 <div class="blob-header">
1112 <span>{fileName} — blame</span>
1113 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1114 Normal view
1115 </a>
1116 </div>
1117 <div class="blob-code" style="overflow-x: auto">
1118 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1119 <tbody>
1120 {blameLines.map((line, i) => {
1121 const showInfo =
1122 i === 0 || blameLines[i - 1].sha !== line.sha;
1123 return (
1124 <tr style="border-bottom: 1px solid var(--border)">
1125 <td
1126 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)" : ""}`}
1127 >
1128 {showInfo && (
1129 <>
1130 <a
1131 href={`/${owner}/${repo}/commit/${line.sha}`}
1132 style="color: var(--text-link); font-family: var(--font-mono)"
1133 >
1134 {line.sha.slice(0, 7)}
1135 </a>{" "}
1136 <span>{line.author}</span>
1137 </>
1138 )}
1139 </td>
1140 <td class="line-num">{line.lineNum}</td>
1141 <td class="line-content">{line.content}</td>
1142 </tr>
1143 );
1144 })}
1145 </tbody>
1146 </table>
1147 </div>
1148 </div>
1149 </Layout>
1150 );
1151});
1152
1153// Search
1154web.get("/:owner/:repo/search", async (c) => {
1155 const { owner, repo } = c.req.param();
1156 const user = c.get("user");
1157 const q = c.req.query("q") || "";
1158
1159 if (!(await repoExists(owner, repo))) return c.notFound();
1160
1161 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1162 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1163
1164 if (q.trim()) {
1165 results = await searchCode(owner, repo, defaultBranch, q.trim());
1166 }
1167
1168 return c.html(
1169 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1170 <RepoHeader owner={owner} repo={repo} />
1171 <RepoNav owner={owner} repo={repo} active="code" />
1172 <form
001af43Claude1173 method="get"
79136bbClaude1174 action={`/${owner}/${repo}/search`}
1175 style="margin-bottom: 20px"
1176 >
1177 <div style="display: flex; gap: 8px">
1178 <input
1179 type="text"
1180 name="q"
1181 value={q}
1182 placeholder="Search code..."
1183 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1184 />
1185 <button type="submit" class="btn btn-primary">
1186 Search
1187 </button>
1188 </div>
1189 </form>
1190 {q && (
1191 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1192 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1193 <strong style="color: var(--text)">"{q}"</strong>
1194 </p>
1195 )}
1196 {results.length > 0 && (
1197 <div class="search-results">
1198 {(() => {
1199 // Group by file
1200 const grouped: Record<
1201 string,
1202 Array<{ lineNum: number; line: string }>
1203 > = {};
1204 for (const r of results) {
1205 if (!grouped[r.file]) grouped[r.file] = [];
1206 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1207 }
1208 return Object.entries(grouped).map(([file, matches]) => (
1209 <div class="diff-file" style="margin-bottom: 12px">
1210 <div class="diff-file-header">
1211 <a
1212 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1213 >
1214 {file}
1215 </a>
1216 </div>
1217 <div class="blob-code">
1218 <table>
1219 <tbody>
1220 {matches.map((m) => (
1221 <tr>
1222 <td class="line-num">{m.lineNum}</td>
1223 <td class="line-content">{m.line}</td>
1224 </tr>
1225 ))}
1226 </tbody>
1227 </table>
1228 </div>
1229 </div>
1230 ));
1231 })()}
1232 </div>
1233 )}
1234 </Layout>
1235 );
1236});
1237
fc1817aClaude1238export default web;