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.tsxBlame1221 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";
3951454Claude8import { eq, and, desc, inArray } 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
fc1817aClaude65 return c.html(
06d5ffeClaude66 <Layout user={null}>
2b821b7Claude67 <LandingPage />
fc1817aClaude68 </Layout>
69 );
70});
71
06d5ffeClaude72// New repository form
73web.get("/new", requireAuth, (c) => {
74 const user = c.get("user")!;
75 const error = c.req.query("error");
76
77 return c.html(
78 <Layout title="New repository" user={user}>
79 <div class="new-repo-form">
80 <h2>Create a new repository</h2>
81 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
001af43Claude82 <form method="post" action="/new">
06d5ffeClaude83 <div class="form-group">
84 <label>Owner</label>
85 <input type="text" value={user.username} disabled class="input-disabled" />
86 </div>
87 <div class="form-group">
88 <label for="name">Repository name</label>
89 <input
90 type="text"
91 id="name"
92 name="name"
93 required
94 pattern="^[a-zA-Z0-9._-]+$"
95 placeholder="my-project"
96 autocomplete="off"
97 />
98 </div>
99 <div class="form-group">
100 <label for="description">Description (optional)</label>
101 <input
102 type="text"
103 id="description"
104 name="description"
105 placeholder="A short description of your repository"
106 />
107 </div>
108 <div class="visibility-options">
109 <label class="visibility-option">
110 <input type="radio" name="visibility" value="public" checked />
111 <div class="vis-label">Public</div>
112 <div class="vis-desc">Anyone can see this repository</div>
113 </label>
114 <label class="visibility-option">
115 <input type="radio" name="visibility" value="private" />
116 <div class="vis-label">Private</div>
117 <div class="vis-desc">Only you can see this repository</div>
118 </label>
119 </div>
120 <button type="submit" class="btn btn-primary">
121 Create repository
122 </button>
123 </form>
124 </div>
125 </Layout>
126 );
127});
128
129web.post("/new", requireAuth, async (c) => {
130 const user = c.get("user")!;
131 const body = await c.req.parseBody();
132 const name = String(body.name || "").trim();
133 const description = String(body.description || "").trim();
134 const isPrivate = body.visibility === "private";
135
136 if (!name) {
137 return c.redirect("/new?error=Repository+name+is+required");
138 }
139
140 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
141 return c.redirect("/new?error=Invalid+repository+name");
142 }
143
144 if (await repoExists(user.username, name)) {
145 return c.redirect("/new?error=Repository+already+exists");
146 }
147
148 const diskPath = await initBareRepo(user.username, name);
149
3ef4c9dClaude150 const [newRepo] = await db
151 .insert(repositories)
152 .values({
153 name,
154 ownerId: user.id,
155 description: description || null,
156 isPrivate,
157 diskPath,
158 })
159 .returning();
160
161 if (newRepo) {
162 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
163 await bootstrapRepository({
164 repositoryId: newRepo.id,
165 ownerUserId: user.id,
166 defaultBranch: "main",
167 });
168 }
06d5ffeClaude169
170 return c.redirect(`/${user.username}/${name}`);
171});
172
173// User profile
fc1817aClaude174web.get("/:owner", async (c) => {
06d5ffeClaude175 const { owner: ownerName } = c.req.param();
176 const user = c.get("user");
177
178 // Avoid clashing with fixed routes
179 if (
180 ["login", "register", "logout", "new", "settings", "api"].includes(
181 ownerName
182 )
183 ) {
184 return c.notFound();
185 }
186
187 let ownerUser;
188 try {
189 const [found] = await db
190 .select()
191 .from(users)
192 .where(eq(users.username, ownerName))
193 .limit(1);
194 ownerUser = found;
195 } catch {
196 // DB not available — check if repos exist on disk
197 ownerUser = null;
198 }
199
200 // Even without DB, show repos if they exist on disk
201 let repos: any[] = [];
202 if (ownerUser) {
203 const allRepos = await db
204 .select()
205 .from(repositories)
206 .where(eq(repositories.ownerId, ownerUser.id))
207 .orderBy(desc(repositories.updatedAt));
208
209 // Show public repos to everyone, private only to owner
210 repos =
211 user?.id === ownerUser.id
212 ? allRepos
213 : allRepos.filter((r) => !r.isPrivate);
214 }
215
7aa8b99Claude216 // Block J4 — follow counts + viewer's follow state
217 let followState = {
218 followers: 0,
219 following: 0,
220 viewerFollows: false,
221 };
222 if (ownerUser) {
223 try {
224 const { followCounts, isFollowing } = await import("../lib/follows");
225 const counts = await followCounts(ownerUser.id);
226 followState.followers = counts.followers;
227 followState.following = counts.following;
228 if (user && user.id !== ownerUser.id) {
229 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
230 }
231 } catch {
232 // DB hiccup — fall back to zeros.
233 }
234 }
235 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
236
d412586Claude237 // Block J5 — profile README. Render owner/owner repo's README on the
238 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
239 // back to "<user>/.github" for org-style profile repos.
240 let profileReadmeHtml: string | null = null;
241 try {
242 const candidates = [ownerName, ".github"];
243 for (const rname of candidates) {
244 if (await repoExists(ownerName, rname)) {
245 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
246 const md = await getReadme(ownerName, rname, ref);
247 if (md) {
248 profileReadmeHtml = renderMarkdown(md);
249 break;
250 }
251 }
252 }
253 } catch {
254 profileReadmeHtml = null;
255 }
256
fc1817aClaude257 return c.html(
06d5ffeClaude258 <Layout title={ownerName} user={user}>
259 <div class="user-profile">
260 <div class="user-avatar">
261 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
262 </div>
263 <div class="user-info">
264 <h2>{ownerUser?.displayName || ownerName}</h2>
265 <div class="username">@{ownerName}</div>
266 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude267 <div
268 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
269 >
270 <a
271 href={`/${ownerName}/followers`}
272 style="color:var(--text-muted)"
273 >
274 <strong style="color:var(--text)">
275 {followState.followers}
276 </strong>{" "}
277 follower{followState.followers === 1 ? "" : "s"}
278 </a>
279 <a
280 href={`/${ownerName}/following`}
281 style="color:var(--text-muted)"
282 >
283 <strong style="color:var(--text)">
284 {followState.following}
285 </strong>{" "}
286 following
287 </a>
288 {canFollow && (
289 <form
001af43Claude290 method="post"
7aa8b99Claude291 action={`/${ownerName}/${
292 followState.viewerFollows ? "unfollow" : "follow"
293 }`}
294 >
295 <button
296 type="submit"
297 class={`btn ${
298 followState.viewerFollows ? "" : "btn-primary"
299 } btn-sm`}
300 >
301 {followState.viewerFollows ? "Unfollow" : "Follow"}
302 </button>
303 </form>
304 )}
305 </div>
06d5ffeClaude306 </div>
307 </div>
d412586Claude308 {profileReadmeHtml && (
309 <div
310 class="markdown-body"
311 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
312 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
313 />
314 )}
06d5ffeClaude315 <h3 style="margin-bottom: 16px">Repositories</h3>
316 {repos.length === 0 ? (
317 <p style="color: var(--text-muted)">No repositories yet.</p>
318 ) : (
319 <div class="card-grid">
320 {repos.map((repo) => (
321 <RepoCard repo={repo} ownerName={ownerName} />
322 ))}
323 </div>
324 )}
fc1817aClaude325 </Layout>
326 );
327});
328
06d5ffeClaude329// Star/unstar a repo
330web.post("/:owner/:repo/star", requireAuth, async (c) => {
331 const { owner: ownerName, repo: repoName } = c.req.param();
332 const user = c.get("user")!;
333
334 try {
335 const [ownerUser] = await db
336 .select()
337 .from(users)
338 .where(eq(users.username, ownerName))
339 .limit(1);
340 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
341
342 const [repo] = await db
343 .select()
344 .from(repositories)
345 .where(
346 and(
347 eq(repositories.ownerId, ownerUser.id),
348 eq(repositories.name, repoName)
349 )
350 )
351 .limit(1);
352 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
353
354 // Toggle star
355 const [existing] = await db
356 .select()
357 .from(stars)
358 .where(
359 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
360 )
361 .limit(1);
362
363 if (existing) {
364 await db.delete(stars).where(eq(stars.id, existing.id));
365 await db
366 .update(repositories)
367 .set({ starCount: Math.max(0, repo.starCount - 1) })
368 .where(eq(repositories.id, repo.id));
369 } else {
370 await db.insert(stars).values({
371 userId: user.id,
372 repositoryId: repo.id,
373 });
374 await db
375 .update(repositories)
376 .set({ starCount: repo.starCount + 1 })
377 .where(eq(repositories.id, repo.id));
378 }
379 } catch {
380 // DB error — ignore
381 }
382
383 return c.redirect(`/${ownerName}/${repoName}`);
384});
385
fc1817aClaude386// Repository overview — file tree at HEAD
387web.get("/:owner/:repo", async (c) => {
388 const { owner, repo } = c.req.param();
06d5ffeClaude389 const user = c.get("user");
fc1817aClaude390
8f50ed0Claude391 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
392 trackByName(owner, repo, "view", {
393 userId: user?.id || null,
394 path: `/${owner}/${repo}`,
395 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
396 userAgent: c.req.header("user-agent") || null,
397 referer: c.req.header("referer") || null,
398 }).catch(() => {});
399
fc1817aClaude400 if (!(await repoExists(owner, repo))) {
401 return c.html(
06d5ffeClaude402 <Layout title="Not Found" user={user}>
fc1817aClaude403 <div class="empty-state">
404 <h2>Repository not found</h2>
405 <p>
406 {owner}/{repo} does not exist.
407 </p>
408 </div>
409 </Layout>,
410 404
411 );
412 }
413
05b973eClaude414 // Parallelize all independent operations
415 const [defaultBranch, branches] = await Promise.all([
416 getDefaultBranch(owner, repo).then((b) => b || "main"),
417 listBranches(owner, repo),
418 ]);
419 const [tree, starInfo] = await Promise.all([
420 getTree(owner, repo, defaultBranch),
421 // Star info fetched in parallel with tree
422 (async () => {
423 try {
424 const [ownerUser] = await db
425 .select()
426 .from(users)
427 .where(eq(users.username, owner))
428 .limit(1);
71cd5ecClaude429 if (!ownerUser)
430 return {
431 starCount: 0,
432 starred: false,
433 archived: false,
434 isTemplate: false,
435 };
05b973eClaude436 const [repoRow] = await db
437 .select()
438 .from(repositories)
439 .where(
440 and(
441 eq(repositories.ownerId, ownerUser.id),
442 eq(repositories.name, repo)
443 )
06d5ffeClaude444 )
05b973eClaude445 .limit(1);
71cd5ecClaude446 if (!repoRow)
447 return {
448 starCount: 0,
449 starred: false,
450 archived: false,
451 isTemplate: false,
452 };
05b973eClaude453 let starred = false;
06d5ffeClaude454 if (user) {
455 const [star] = await db
456 .select()
457 .from(stars)
458 .where(
459 and(
460 eq(stars.userId, user.id),
461 eq(stars.repositoryId, repoRow.id)
462 )
463 )
464 .limit(1);
465 starred = !!star;
466 }
71cd5ecClaude467 return {
468 starCount: repoRow.starCount,
469 starred,
470 archived: repoRow.isArchived,
471 isTemplate: repoRow.isTemplate,
472 };
05b973eClaude473 } catch {
71cd5ecClaude474 return {
475 starCount: 0,
476 starred: false,
477 archived: false,
478 isTemplate: false,
479 };
06d5ffeClaude480 }
05b973eClaude481 })(),
482 ]);
71cd5ecClaude483 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude484
fc1817aClaude485 if (tree.length === 0) {
486 return c.html(
06d5ffeClaude487 <Layout title={`${owner}/${repo}`} user={user}>
488 <RepoHeader
489 owner={owner}
490 repo={repo}
491 starCount={starCount}
492 starred={starred}
493 currentUser={user?.username}
71cd5ecClaude494 archived={archived}
495 isTemplate={isTemplate}
06d5ffeClaude496 />
fc1817aClaude497 <RepoNav owner={owner} repo={repo} active="code" />
498 <div class="empty-state">
499 <h2>Empty repository</h2>
500 <p>Get started by pushing code:</p>
501 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
502git push -u gluecron main`}</pre>
503 </div>
504 </Layout>
505 );
506 }
507
508 const readme = await getReadme(owner, repo, defaultBranch);
509
510 return c.html(
06d5ffeClaude511 <Layout title={`${owner}/${repo}`} user={user}>
512 <RepoHeader
513 owner={owner}
514 repo={repo}
515 starCount={starCount}
516 starred={starred}
517 currentUser={user?.username}
71cd5ecClaude518 archived={archived}
519 isTemplate={isTemplate}
06d5ffeClaude520 />
71cd5ecClaude521 {isTemplate && user && user.username !== owner && (
522 <div
523 class="panel"
524 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
525 >
526 <div style="font-size:13px">
527 <strong>Template repository.</strong> Create a new repository from
528 this template's files.
529 </div>
530 <form
001af43Claude531 method="post"
71cd5ecClaude532 action={`/${owner}/${repo}/use-template`}
533 style="display:flex;gap:8px;align-items:center"
534 >
535 <input
536 type="text"
537 name="name"
538 placeholder="new-repo-name"
539 required
540 style="width:200px"
541 />
542 <button type="submit" class="btn btn-primary">
543 Use this template
544 </button>
545 </form>
546 </div>
547 )}
fc1817aClaude548 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude549 <BranchSwitcher
550 owner={owner}
551 repo={repo}
552 currentRef={defaultBranch}
553 branches={branches}
554 pathType="tree"
555 />
fc1817aClaude556 <FileTable
557 entries={tree}
558 owner={owner}
559 repo={repo}
560 ref={defaultBranch}
561 path=""
562 />
79136bbClaude563 {readme && (() => {
564 const readmeHtml = renderMarkdown(readme);
565 return (
566 <div class="blob-view" style="margin-top: 20px">
567 <div class="blob-header">README.md</div>
568 <style>{markdownCss}</style>
569 <div class="markdown-body">
570 {html([readmeHtml] as unknown as TemplateStringsArray)}
571 </div>
fc1817aClaude572 </div>
79136bbClaude573 );
574 })()}
fc1817aClaude575 </Layout>
576 );
577});
578
579// Browse tree at ref/path
580web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
581 const { owner, repo } = c.req.param();
06d5ffeClaude582 const user = c.get("user");
fc1817aClaude583 const refAndPath = c.req.param("ref");
584
585 const branches = await listBranches(owner, repo);
586 let ref = "";
587 let treePath = "";
588
589 for (const branch of branches) {
590 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
591 ref = branch;
592 treePath = refAndPath.slice(branch.length + 1);
593 break;
594 }
595 }
596
597 if (!ref) {
598 const slashIdx = refAndPath.indexOf("/");
599 if (slashIdx === -1) {
600 ref = refAndPath;
601 } else {
602 ref = refAndPath.slice(0, slashIdx);
603 treePath = refAndPath.slice(slashIdx + 1);
604 }
605 }
606
607 const tree = await getTree(owner, repo, ref, treePath);
608
609 return c.html(
06d5ffeClaude610 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude611 <RepoHeader owner={owner} repo={repo} />
612 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude613 <BranchSwitcher
614 owner={owner}
615 repo={repo}
616 currentRef={ref}
617 branches={branches}
618 pathType="tree"
619 subPath={treePath}
620 />
fc1817aClaude621 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
622 <FileTable
623 entries={tree}
624 owner={owner}
625 repo={repo}
626 ref={ref}
627 path={treePath}
628 />
629 </Layout>
630 );
631});
632
06d5ffeClaude633// View file blob with syntax highlighting
fc1817aClaude634web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
635 const { owner, repo } = c.req.param();
06d5ffeClaude636 const user = c.get("user");
fc1817aClaude637 const refAndPath = c.req.param("ref");
638
639 const branches = await listBranches(owner, repo);
640 let ref = "";
641 let filePath = "";
642
643 for (const branch of branches) {
644 if (refAndPath.startsWith(branch + "/")) {
645 ref = branch;
646 filePath = refAndPath.slice(branch.length + 1);
647 break;
648 }
649 }
650
651 if (!ref) {
652 const slashIdx = refAndPath.indexOf("/");
653 if (slashIdx === -1) return c.text("Not found", 404);
654 ref = refAndPath.slice(0, slashIdx);
655 filePath = refAndPath.slice(slashIdx + 1);
656 }
657
658 const blob = await getBlob(owner, repo, ref, filePath);
659 if (!blob) {
660 return c.html(
06d5ffeClaude661 <Layout title="Not Found" user={user}>
fc1817aClaude662 <div class="empty-state">
663 <h2>File not found</h2>
664 </div>
665 </Layout>,
666 404
667 );
668 }
669
06d5ffeClaude670 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude671
672 return c.html(
06d5ffeClaude673 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude674 <RepoHeader owner={owner} repo={repo} />
675 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude676 <BranchSwitcher
677 owner={owner}
678 repo={repo}
679 currentRef={ref}
680 branches={branches}
681 pathType="blob"
682 subPath={filePath}
683 />
fc1817aClaude684 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
685 <div class="blob-view">
686 <div class="blob-header">
06d5ffeClaude687 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude688 <span style="display: flex; gap: 12px">
689 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
690 Raw
691 </a>
692 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
693 Blame
694 </a>
16b325cClaude695 <a href={`/${owner}/${repo}/timeline/${ref}/${filePath}`} style="font-size: 12px">
696 History
697 </a>
0074234Claude698 {user && (
699 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
700 Edit
701 </a>
702 )}
79136bbClaude703 </span>
fc1817aClaude704 </div>
705 {blob.isBinary ? (
706 <div style="padding: 16px; color: var(--text-muted)">
707 Binary file not shown.
708 </div>
06d5ffeClaude709 ) : (() => {
710 const { html: highlighted, language } = highlightCode(
711 blob.content,
712 fileName
713 );
714 const lineCount = blob.content.split("\n").length;
715 // Trim trailing newline from count
716 const adjustedCount =
717 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
718
719 if (language) {
720 return (
721 <HighlightedCode
722 highlightedHtml={highlighted}
723 lineCount={adjustedCount}
724 />
725 );
726 }
727 const lines = blob.content.split("\n");
728 if (lines[lines.length - 1] === "") lines.pop();
729 return <PlainCode lines={lines} />;
730 })()}
fc1817aClaude731 </div>
732 </Layout>
733 );
734});
735
736// Commit log
737web.get("/:owner/:repo/commits/:ref?", async (c) => {
738 const { owner, repo } = c.req.param();
06d5ffeClaude739 const user = c.get("user");
fc1817aClaude740 const ref =
741 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude742 const branches = await listBranches(owner, repo);
fc1817aClaude743
744 const commits = await listCommits(owner, repo, ref, 50);
745
3951454Claude746 // Block J3 — batch-fetch cached verification results for the page.
747 let verifications: Record<string, { verified: boolean; reason: string }> = {};
748 try {
749 const [ownerRow] = await db
750 .select()
751 .from(users)
752 .where(eq(users.username, owner))
753 .limit(1);
754 if (ownerRow) {
755 const [repoRow] = await db
756 .select()
757 .from(repositories)
758 .where(
759 and(
760 eq(repositories.ownerId, ownerRow.id),
761 eq(repositories.name, repo)
762 )
763 )
764 .limit(1);
765 if (repoRow && commits.length > 0) {
766 const rows = await db
767 .select()
768 .from(commitVerifications)
769 .where(
770 and(
771 eq(commitVerifications.repositoryId, repoRow.id),
772 inArray(
773 commitVerifications.commitSha,
774 commits.map((c) => c.sha)
775 )
776 )
777 );
778 for (const r of rows) {
779 verifications[r.commitSha] = {
780 verified: r.verified,
781 reason: r.reason,
782 };
783 }
784 }
785 }
786 } catch {
787 // DB unavailable — skip the badges gracefully.
788 }
789
fc1817aClaude790 return c.html(
06d5ffeClaude791 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude792 <RepoHeader owner={owner} repo={repo} />
793 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude794 <BranchSwitcher
795 owner={owner}
796 repo={repo}
797 currentRef={ref}
798 branches={branches}
799 pathType="commits"
800 />
fc1817aClaude801 {commits.length === 0 ? (
802 <div class="empty-state">
803 <p>No commits yet.</p>
804 </div>
805 ) : (
3951454Claude806 <CommitList
807 commits={commits}
808 owner={owner}
809 repo={repo}
810 verifications={verifications}
811 />
fc1817aClaude812 )}
813 </Layout>
814 );
815});
816
817// Single commit with diff
818web.get("/:owner/:repo/commit/:sha", async (c) => {
819 const { owner, repo, sha } = c.req.param();
06d5ffeClaude820 const user = c.get("user");
fc1817aClaude821
05b973eClaude822 // Fetch commit, full message, and diff in parallel
823 const [commit, fullMessage, diffResult] = await Promise.all([
824 getCommit(owner, repo, sha),
825 getCommitFullMessage(owner, repo, sha),
826 getDiff(owner, repo, sha),
827 ]);
fc1817aClaude828 if (!commit) {
829 return c.html(
06d5ffeClaude830 <Layout title="Not Found" user={user}>
fc1817aClaude831 <div class="empty-state">
832 <h2>Commit not found</h2>
833 </div>
834 </Layout>,
835 404
836 );
837 }
838
3951454Claude839 // Block J3 — try to verify this commit's signature.
840 let verification:
841 | { verified: boolean; reason: string; signatureType: string | null }
842 | null = null;
0cdfd89Claude843 // Block J8 — external CI commit statuses rollup.
844 let statusCombined:
845 | {
846 state: "pending" | "success" | "failure";
847 total: number;
848 contexts: Array<{
849 context: string;
850 state: string;
851 description: string | null;
852 targetUrl: string | null;
853 }>;
854 }
855 | null = null;
3951454Claude856 try {
857 const [ownerRow] = await db
858 .select()
859 .from(users)
860 .where(eq(users.username, owner))
861 .limit(1);
862 if (ownerRow) {
863 const [repoRow] = await db
864 .select()
865 .from(repositories)
866 .where(
867 and(
868 eq(repositories.ownerId, ownerRow.id),
869 eq(repositories.name, repo)
870 )
871 )
872 .limit(1);
873 if (repoRow) {
874 const { verifyCommit } = await import("../lib/signatures");
875 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
876 verification = {
877 verified: v.verified,
878 reason: v.reason,
879 signatureType: v.signatureType,
880 };
0cdfd89Claude881 try {
882 const { combinedStatus } = await import("../lib/commit-statuses");
883 const combined = await combinedStatus(repoRow.id, commit.sha);
884 if (combined.total > 0) {
885 statusCombined = {
886 state: combined.state as any,
887 total: combined.total,
888 contexts: combined.contexts.map((c) => ({
889 context: c.context,
890 state: c.state,
891 description: c.description,
892 targetUrl: c.targetUrl,
893 })),
894 };
895 }
896 } catch {
897 statusCombined = null;
898 }
3951454Claude899 }
900 }
901 } catch {
902 verification = null;
903 }
904
05b973eClaude905 const { files, raw } = diffResult;
fc1817aClaude906
907 return c.html(
06d5ffeClaude908 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude909 <RepoHeader owner={owner} repo={repo} />
910 <div
911 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
912 >
913 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
914 {commit.message}
915 </div>
916 {fullMessage !== commit.message && (
917 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
918 {fullMessage}
919 </div>
920 )}
921 <div style="font-size: 13px; color: var(--text-muted)">
922 <strong style="color: var(--text)">{commit.author}</strong>{" "}
923 committed on{" "}
924 {new Date(commit.date).toLocaleDateString("en-US", {
925 month: "long",
926 day: "numeric",
927 year: "numeric",
928 })}
3951454Claude929 {verification && verification.reason !== "unsigned" && (
930 <span
931 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
932 verification.verified
933 ? "var(--green,#2ea043)"
934 : "var(--yellow,#d29922)"
935 }`}
936 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
937 >
938 {verification.verified ? "Verified" : verification.reason}
939 </span>
940 )}
fc1817aClaude941 </div>
942 <div style="margin-top: 8px">
943 <span class="commit-sha">{commit.sha}</span>
944 {commit.parentShas.length > 0 && (
945 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
946 Parent:{" "}
947 {commit.parentShas.map((p) => (
948 <a
949 href={`/${owner}/${repo}/commit/${p}`}
950 class="commit-sha"
951 style="margin-left: 4px"
952 >
953 {p.slice(0, 7)}
954 </a>
955 ))}
956 </span>
957 )}
958 </div>
0cdfd89Claude959 {statusCombined && (
960 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
961 <strong style="color: var(--text)">Checks</strong>
962 <span style="margin-left: 8px; color: var(--text-muted)">
963 {statusCombined.total} total —{" "}
964 <span
965 style={`color:${
966 statusCombined.state === "success"
967 ? "var(--green,#2ea043)"
968 : statusCombined.state === "failure"
969 ? "var(--red,#da3633)"
970 : "var(--yellow,#d29922)"
971 }`}
972 >
973 {statusCombined.state}
974 </span>
975 </span>
976 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
977 {statusCombined.contexts.map((cx) => (
978 <span
979 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
980 cx.state === "success"
981 ? "var(--green,#2ea043)"
982 : cx.state === "pending"
983 ? "var(--yellow,#d29922)"
984 : "var(--red,#da3633)"
985 }`}
986 title={cx.description || cx.context}
987 >
988 {cx.targetUrl ? (
989 <a
990 href={cx.targetUrl}
991 style="color: inherit; text-decoration: none"
992 rel="noopener"
993 >
994 {cx.context}: {cx.state}
995 </a>
996 ) : (
997 <>
998 {cx.context}: {cx.state}
999 </>
1000 )}
1001 </span>
1002 ))}
1003 </div>
1004 </div>
1005 )}
fc1817aClaude1006 </div>
1007 <DiffView raw={raw} files={files} />
1008 </Layout>
1009 );
1010});
1011
79136bbClaude1012// Raw file download
1013web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1014 const { owner, repo } = c.req.param();
1015 const refAndPath = c.req.param("ref");
1016
1017 const branches = await listBranches(owner, repo);
1018 let ref = "";
1019 let filePath = "";
1020
1021 for (const branch of branches) {
1022 if (refAndPath.startsWith(branch + "/")) {
1023 ref = branch;
1024 filePath = refAndPath.slice(branch.length + 1);
1025 break;
1026 }
1027 }
1028
1029 if (!ref) {
1030 const slashIdx = refAndPath.indexOf("/");
1031 if (slashIdx === -1) return c.text("Not found", 404);
1032 ref = refAndPath.slice(0, slashIdx);
1033 filePath = refAndPath.slice(slashIdx + 1);
1034 }
1035
1036 const data = await getRawBlob(owner, repo, ref, filePath);
1037 if (!data) return c.text("Not found", 404);
1038
1039 const fileName = filePath.split("/").pop() || "file";
772a24fClaude1040 return new Response(data as BodyInit, {
79136bbClaude1041 headers: {
1042 "Content-Type": "application/octet-stream",
1043 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1044 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1045 },
1046 });
1047});
1048
1049// Blame view
1050web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1051 const { owner, repo } = c.req.param();
1052 const user = c.get("user");
1053 const refAndPath = c.req.param("ref");
1054
1055 const branches = await listBranches(owner, repo);
1056 let ref = "";
1057 let filePath = "";
1058
1059 for (const branch of branches) {
1060 if (refAndPath.startsWith(branch + "/")) {
1061 ref = branch;
1062 filePath = refAndPath.slice(branch.length + 1);
1063 break;
1064 }
1065 }
1066
1067 if (!ref) {
1068 const slashIdx = refAndPath.indexOf("/");
1069 if (slashIdx === -1) return c.text("Not found", 404);
1070 ref = refAndPath.slice(0, slashIdx);
1071 filePath = refAndPath.slice(slashIdx + 1);
1072 }
1073
1074 const blameLines = await getBlame(owner, repo, ref, filePath);
1075 if (blameLines.length === 0) {
1076 return c.html(
1077 <Layout title="Not Found" user={user}>
1078 <div class="empty-state">
1079 <h2>File not found</h2>
1080 </div>
1081 </Layout>,
1082 404
1083 );
1084 }
1085
1086 const fileName = filePath.split("/").pop() || filePath;
1087
1088 return c.html(
1089 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1090 <RepoHeader owner={owner} repo={repo} />
1091 <RepoNav owner={owner} repo={repo} active="code" />
1092 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1093 <div class="blob-view">
1094 <div class="blob-header">
1095 <span>{fileName} — blame</span>
1096 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1097 Normal view
1098 </a>
1099 </div>
1100 <div class="blob-code" style="overflow-x: auto">
1101 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1102 <tbody>
1103 {blameLines.map((line, i) => {
1104 const showInfo =
1105 i === 0 || blameLines[i - 1].sha !== line.sha;
1106 return (
1107 <tr style="border-bottom: 1px solid var(--border)">
1108 <td
1109 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)" : ""}`}
1110 >
1111 {showInfo && (
1112 <>
1113 <a
1114 href={`/${owner}/${repo}/commit/${line.sha}`}
1115 style="color: var(--text-link); font-family: var(--font-mono)"
1116 >
1117 {line.sha.slice(0, 7)}
1118 </a>{" "}
1119 <span>{line.author}</span>
1120 </>
1121 )}
1122 </td>
1123 <td class="line-num">{line.lineNum}</td>
1124 <td class="line-content">{line.content}</td>
1125 </tr>
1126 );
1127 })}
1128 </tbody>
1129 </table>
1130 </div>
1131 </div>
1132 </Layout>
1133 );
1134});
1135
1136// Search
1137web.get("/:owner/:repo/search", async (c) => {
1138 const { owner, repo } = c.req.param();
1139 const user = c.get("user");
1140 const q = c.req.query("q") || "";
1141
1142 if (!(await repoExists(owner, repo))) return c.notFound();
1143
1144 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1145 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1146
1147 if (q.trim()) {
1148 results = await searchCode(owner, repo, defaultBranch, q.trim());
1149 }
1150
1151 return c.html(
1152 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1153 <RepoHeader owner={owner} repo={repo} />
1154 <RepoNav owner={owner} repo={repo} active="code" />
1155 <form
001af43Claude1156 method="get"
79136bbClaude1157 action={`/${owner}/${repo}/search`}
1158 style="margin-bottom: 20px"
1159 >
1160 <div style="display: flex; gap: 8px">
1161 <input
1162 type="text"
1163 name="q"
1164 value={q}
1165 placeholder="Search code..."
1166 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1167 />
1168 <button type="submit" class="btn btn-primary">
1169 Search
1170 </button>
1171 </div>
1172 </form>
1173 {q && (
1174 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1175 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1176 <strong style="color: var(--text)">"{q}"</strong>
1177 </p>
1178 )}
1179 {results.length > 0 && (
1180 <div class="search-results">
1181 {(() => {
1182 // Group by file
1183 const grouped: Record<
1184 string,
1185 Array<{ lineNum: number; line: string }>
1186 > = {};
1187 for (const r of results) {
1188 if (!grouped[r.file]) grouped[r.file] = [];
1189 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1190 }
1191 return Object.entries(grouped).map(([file, matches]) => (
1192 <div class="diff-file" style="margin-bottom: 12px">
1193 <div class="diff-file-header">
1194 <a
1195 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1196 >
1197 {file}
1198 </a>
1199 </div>
1200 <div class="blob-code">
1201 <table>
1202 <tbody>
1203 {matches.map((m) => (
1204 <tr>
1205 <td class="line-num">{m.lineNum}</td>
1206 <td class="line-content">{m.line}</td>
1207 </tr>
1208 ))}
1209 </tbody>
1210 </table>
1211 </div>
1212 </div>
1213 ));
1214 })()}
1215 </div>
1216 )}
1217 </Layout>
1218 );
1219});
1220
fc1817aClaude1221export default web;