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.tsxBlame1323 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,
4438e31Claude15 activityFeed,
3951454Claude16} from "../db/schema";
4438e31Claude17import { gte } from "drizzle-orm";
fc1817aClaude18import { Layout } from "../views/layout";
19import {
20 RepoHeader,
21 RepoNav,
22 Breadcrumb,
23 FileTable,
24 CommitList,
25 DiffView,
06d5ffeClaude26 RepoCard,
27 BranchSwitcher,
28 HighlightedCode,
29 PlainCode,
fc1817aClaude30} from "../views/components";
31import {
32 getTree,
33 getBlob,
34 listCommits,
35 getCommit,
36 getCommitFullMessage,
37 getDiff,
38 getReadme,
39 getDefaultBranch,
40 listBranches,
41 repoExists,
06d5ffeClaude42 initBareRepo,
79136bbClaude43 getBlame,
44 getRawBlob,
45 searchCode,
fc1817aClaude46} from "../git/repository";
79136bbClaude47import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude48import { highlightCode } from "../lib/highlight";
49import { softAuth, requireAuth } from "../middleware/auth";
50import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude51import { trackByName } from "../lib/traffic";
fc1817aClaude52
06d5ffeClaude53const web = new Hono<AuthEnv>();
54
55// Soft auth on all web routes — c.get("user") available but may be null
56web.use("*", softAuth);
fc1817aClaude57
58// Home page
06d5ffeClaude59web.get("/", async (c) => {
60 const user = c.get("user");
61
62 if (user) {
3ef4c9dClaude63 const { renderDashboard } = await import("./dashboard");
64 return renderDashboard(c);
06d5ffeClaude65 }
66
fc1817aClaude67 return c.html(
06d5ffeClaude68 <Layout user={null}>
fc1817aClaude69 <div class="empty-state">
70 <h2>gluecron</h2>
71 <p>AI-native code intelligence platform</p>
06d5ffeClaude72 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
73 <a href="/register" class="btn btn-primary">
74 Get started
75 </a>
76 <a href="/login" class="btn">
77 Sign in
78 </a>
79 </div>
80 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude81curl -X POST http://localhost:3000/api/setup \\
82 -H 'Content-Type: application/json' \\
83 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
84
85git remote add gluecron http://localhost:3000/you/hello.git
86git push gluecron main`}</pre>
87 </div>
88 </Layout>
89 );
90});
91
06d5ffeClaude92// New repository form
93web.get("/new", requireAuth, (c) => {
94 const user = c.get("user")!;
95 const error = c.req.query("error");
96
97 return c.html(
98 <Layout title="New repository" user={user}>
99 <div class="new-repo-form">
100 <h2>Create a new repository</h2>
101 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
102 <form method="POST" action="/new">
103 <div class="form-group">
104 <label>Owner</label>
105 <input type="text" value={user.username} disabled class="input-disabled" />
106 </div>
107 <div class="form-group">
108 <label for="name">Repository name</label>
109 <input
110 type="text"
111 id="name"
112 name="name"
113 required
114 pattern="^[a-zA-Z0-9._-]+$"
115 placeholder="my-project"
116 autocomplete="off"
117 />
118 </div>
119 <div class="form-group">
120 <label for="description">Description (optional)</label>
121 <input
122 type="text"
123 id="description"
124 name="description"
125 placeholder="A short description of your repository"
126 />
127 </div>
128 <div class="visibility-options">
129 <label class="visibility-option">
130 <input type="radio" name="visibility" value="public" checked />
131 <div class="vis-label">Public</div>
132 <div class="vis-desc">Anyone can see this repository</div>
133 </label>
134 <label class="visibility-option">
135 <input type="radio" name="visibility" value="private" />
136 <div class="vis-label">Private</div>
137 <div class="vis-desc">Only you can see this repository</div>
138 </label>
139 </div>
140 <button type="submit" class="btn btn-primary">
141 Create repository
142 </button>
143 </form>
144 </div>
145 </Layout>
146 );
147});
148
149web.post("/new", requireAuth, async (c) => {
150 const user = c.get("user")!;
151 const body = await c.req.parseBody();
152 const name = String(body.name || "").trim();
153 const description = String(body.description || "").trim();
154 const isPrivate = body.visibility === "private";
155
156 if (!name) {
157 return c.redirect("/new?error=Repository+name+is+required");
158 }
159
160 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
161 return c.redirect("/new?error=Invalid+repository+name");
162 }
163
164 if (await repoExists(user.username, name)) {
165 return c.redirect("/new?error=Repository+already+exists");
166 }
167
168 const diskPath = await initBareRepo(user.username, name);
169
3ef4c9dClaude170 const [newRepo] = await db
171 .insert(repositories)
172 .values({
173 name,
174 ownerId: user.id,
175 description: description || null,
176 isPrivate,
177 diskPath,
178 })
179 .returning();
180
181 if (newRepo) {
182 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
183 await bootstrapRepository({
184 repositoryId: newRepo.id,
185 ownerUserId: user.id,
186 defaultBranch: "main",
187 });
188 }
06d5ffeClaude189
190 return c.redirect(`/${user.username}/${name}`);
191});
192
193// User profile
fc1817aClaude194web.get("/:owner", async (c) => {
06d5ffeClaude195 const { owner: ownerName } = c.req.param();
196 const user = c.get("user");
197
198 // Avoid clashing with fixed routes
199 if (
200 ["login", "register", "logout", "new", "settings", "api"].includes(
201 ownerName
202 )
203 ) {
204 return c.notFound();
205 }
206
207 let ownerUser;
208 try {
209 const [found] = await db
210 .select()
211 .from(users)
212 .where(eq(users.username, ownerName))
213 .limit(1);
214 ownerUser = found;
215 } catch {
216 // DB not available — check if repos exist on disk
217 ownerUser = null;
218 }
219
220 // Even without DB, show repos if they exist on disk
221 let repos: any[] = [];
222 if (ownerUser) {
223 const allRepos = await db
224 .select()
225 .from(repositories)
226 .where(eq(repositories.ownerId, ownerUser.id))
227 .orderBy(desc(repositories.updatedAt));
228
229 // Show public repos to everyone, private only to owner
230 repos =
231 user?.id === ownerUser.id
232 ? allRepos
233 : allRepos.filter((r) => !r.isPrivate);
234 }
235
7aa8b99Claude236 // Block J4 — follow counts + viewer's follow state
237 let followState = {
238 followers: 0,
239 following: 0,
240 viewerFollows: false,
241 };
242 if (ownerUser) {
243 try {
244 const { followCounts, isFollowing } = await import("../lib/follows");
245 const counts = await followCounts(ownerUser.id);
246 followState.followers = counts.followers;
247 followState.following = counts.following;
248 if (user && user.id !== ownerUser.id) {
249 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
250 }
251 } catch {
252 // DB hiccup — fall back to zeros.
253 }
254 }
255 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
256
d412586Claude257 // Block J5 — profile README. Render owner/owner repo's README on the
258 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
259 // back to "<user>/.github" for org-style profile repos.
260 let profileReadmeHtml: string | null = null;
261 try {
262 const candidates = [ownerName, ".github"];
263 for (const rname of candidates) {
264 if (await repoExists(ownerName, rname)) {
265 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
266 const md = await getReadme(ownerName, rname, ref);
267 if (md) {
268 profileReadmeHtml = renderMarkdown(md);
269 break;
270 }
271 }
272 }
273 } catch {
274 profileReadmeHtml = null;
275 }
276
4438e31Claude277 // Block J9 — contribution heatmap. 52-week activity grid sourced from
278 // activity_feed rows authored by this user. Failures fall through silently.
279 let heatmap: Awaited<
280 ReturnType<
281 typeof import("../lib/contribution-heatmap").buildHeatmap
282 >
283 > | null = null;
284 if (ownerUser) {
285 try {
286 const since = new Date();
287 since.setUTCDate(since.getUTCDate() - 365);
288 const activities = await db
289 .select({ createdAt: activityFeed.createdAt })
290 .from(activityFeed)
291 .where(
292 and(
293 eq(activityFeed.userId, ownerUser.id),
294 gte(activityFeed.createdAt, since)
295 )
296 );
297 const { buildHeatmap } = await import("../lib/contribution-heatmap");
298 heatmap = buildHeatmap(activities, 365);
299 } catch {
300 heatmap = null;
301 }
302 }
303
fc1817aClaude304 return c.html(
06d5ffeClaude305 <Layout title={ownerName} user={user}>
306 <div class="user-profile">
307 <div class="user-avatar">
308 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
309 </div>
310 <div class="user-info">
311 <h2>{ownerUser?.displayName || ownerName}</h2>
312 <div class="username">@{ownerName}</div>
313 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude314 <div
315 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
316 >
317 <a
318 href={`/${ownerName}/followers`}
319 style="color:var(--text-muted)"
320 >
321 <strong style="color:var(--text)">
322 {followState.followers}
323 </strong>{" "}
324 follower{followState.followers === 1 ? "" : "s"}
325 </a>
326 <a
327 href={`/${ownerName}/following`}
328 style="color:var(--text-muted)"
329 >
330 <strong style="color:var(--text)">
331 {followState.following}
332 </strong>{" "}
333 following
334 </a>
335 {canFollow && (
336 <form
337 method="POST"
338 action={`/${ownerName}/${
339 followState.viewerFollows ? "unfollow" : "follow"
340 }`}
341 >
342 <button
343 type="submit"
344 class={`btn ${
345 followState.viewerFollows ? "" : "btn-primary"
346 } btn-sm`}
347 >
348 {followState.viewerFollows ? "Unfollow" : "Follow"}
349 </button>
350 </form>
351 )}
352 </div>
06d5ffeClaude353 </div>
354 </div>
4438e31Claude355 {heatmap && heatmap.totalContributions > 0 && (
356 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:20px">
357 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
358 <strong>
359 {heatmap.totalContributions} contributions in the last year
360 </strong>
361 <span style="color:var(--text-muted);font-size:12px">
362 Longest streak {heatmap.longestStreak}d · Current{" "}
363 {heatmap.currentStreak}d
364 </span>
365 </div>
366 <div style="display:flex;gap:3px;overflow-x:auto;padding-bottom:4px">
367 {heatmap.weeks.map((w) => (
368 <div style="display:flex;flex-direction:column;gap:3px">
369 {w.days.map((d) =>
370 d ? (
371 <div
372 title={`${d.date}: ${d.count} contribution${d.count === 1 ? "" : "s"}`}
373 style={`width:11px;height:11px;border-radius:2px;background:${
374 d.level === 0
375 ? "var(--border)"
376 : d.level === 1
377 ? "#0e4429"
378 : d.level === 2
379 ? "#006d32"
380 : d.level === 3
381 ? "#26a641"
382 : "#39d353"
383 }`}
384 />
385 ) : (
386 <div style="width:11px;height:11px" />
387 )
388 )}
389 </div>
390 ))}
391 </div>
392 <div style="margin-top:8px;display:flex;gap:4px;align-items:center;justify-content:flex-end;font-size:11px;color:var(--text-muted)">
393 <span>Less</span>
394 {[0, 1, 2, 3, 4].map((l) => (
395 <div
396 style={`width:11px;height:11px;border-radius:2px;background:${
397 l === 0
398 ? "var(--border)"
399 : l === 1
400 ? "#0e4429"
401 : l === 2
402 ? "#006d32"
403 : l === 3
404 ? "#26a641"
405 : "#39d353"
406 }`}
407 />
408 ))}
409 <span>More</span>
410 </div>
411 </div>
412 )}
d412586Claude413 {profileReadmeHtml && (
414 <div
415 class="markdown-body"
416 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
417 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
418 />
419 )}
06d5ffeClaude420 <h3 style="margin-bottom: 16px">Repositories</h3>
421 {repos.length === 0 ? (
422 <p style="color: var(--text-muted)">No repositories yet.</p>
423 ) : (
424 <div class="card-grid">
425 {repos.map((repo) => (
426 <RepoCard repo={repo} ownerName={ownerName} />
427 ))}
428 </div>
429 )}
fc1817aClaude430 </Layout>
431 );
432});
433
06d5ffeClaude434// Star/unstar a repo
435web.post("/:owner/:repo/star", requireAuth, async (c) => {
436 const { owner: ownerName, repo: repoName } = c.req.param();
437 const user = c.get("user")!;
438
439 try {
440 const [ownerUser] = await db
441 .select()
442 .from(users)
443 .where(eq(users.username, ownerName))
444 .limit(1);
445 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
446
447 const [repo] = await db
448 .select()
449 .from(repositories)
450 .where(
451 and(
452 eq(repositories.ownerId, ownerUser.id),
453 eq(repositories.name, repoName)
454 )
455 )
456 .limit(1);
457 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
458
459 // Toggle star
460 const [existing] = await db
461 .select()
462 .from(stars)
463 .where(
464 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
465 )
466 .limit(1);
467
468 if (existing) {
469 await db.delete(stars).where(eq(stars.id, existing.id));
470 await db
471 .update(repositories)
472 .set({ starCount: Math.max(0, repo.starCount - 1) })
473 .where(eq(repositories.id, repo.id));
474 } else {
475 await db.insert(stars).values({
476 userId: user.id,
477 repositoryId: repo.id,
478 });
479 await db
480 .update(repositories)
481 .set({ starCount: repo.starCount + 1 })
482 .where(eq(repositories.id, repo.id));
483 }
484 } catch {
485 // DB error — ignore
486 }
487
488 return c.redirect(`/${ownerName}/${repoName}`);
489});
490
fc1817aClaude491// Repository overview — file tree at HEAD
492web.get("/:owner/:repo", async (c) => {
493 const { owner, repo } = c.req.param();
06d5ffeClaude494 const user = c.get("user");
fc1817aClaude495
8f50ed0Claude496 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
497 trackByName(owner, repo, "view", {
498 userId: user?.id || null,
499 path: `/${owner}/${repo}`,
500 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
501 userAgent: c.req.header("user-agent") || null,
502 referer: c.req.header("referer") || null,
503 }).catch(() => {});
504
fc1817aClaude505 if (!(await repoExists(owner, repo))) {
506 return c.html(
06d5ffeClaude507 <Layout title="Not Found" user={user}>
fc1817aClaude508 <div class="empty-state">
509 <h2>Repository not found</h2>
510 <p>
511 {owner}/{repo} does not exist.
512 </p>
513 </div>
514 </Layout>,
515 404
516 );
517 }
518
05b973eClaude519 // Parallelize all independent operations
520 const [defaultBranch, branches] = await Promise.all([
521 getDefaultBranch(owner, repo).then((b) => b || "main"),
522 listBranches(owner, repo),
523 ]);
524 const [tree, starInfo] = await Promise.all([
525 getTree(owner, repo, defaultBranch),
526 // Star info fetched in parallel with tree
527 (async () => {
528 try {
529 const [ownerUser] = await db
530 .select()
531 .from(users)
532 .where(eq(users.username, owner))
533 .limit(1);
71cd5ecClaude534 if (!ownerUser)
535 return {
536 starCount: 0,
537 starred: false,
538 archived: false,
539 isTemplate: false,
540 };
05b973eClaude541 const [repoRow] = await db
542 .select()
543 .from(repositories)
544 .where(
545 and(
546 eq(repositories.ownerId, ownerUser.id),
547 eq(repositories.name, repo)
548 )
06d5ffeClaude549 )
05b973eClaude550 .limit(1);
71cd5ecClaude551 if (!repoRow)
552 return {
553 starCount: 0,
554 starred: false,
555 archived: false,
556 isTemplate: false,
557 };
05b973eClaude558 let starred = false;
06d5ffeClaude559 if (user) {
560 const [star] = await db
561 .select()
562 .from(stars)
563 .where(
564 and(
565 eq(stars.userId, user.id),
566 eq(stars.repositoryId, repoRow.id)
567 )
568 )
569 .limit(1);
570 starred = !!star;
571 }
71cd5ecClaude572 return {
573 starCount: repoRow.starCount,
574 starred,
575 archived: repoRow.isArchived,
576 isTemplate: repoRow.isTemplate,
577 };
05b973eClaude578 } catch {
71cd5ecClaude579 return {
580 starCount: 0,
581 starred: false,
582 archived: false,
583 isTemplate: false,
584 };
06d5ffeClaude585 }
05b973eClaude586 })(),
587 ]);
71cd5ecClaude588 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude589
fc1817aClaude590 if (tree.length === 0) {
591 return c.html(
06d5ffeClaude592 <Layout title={`${owner}/${repo}`} user={user}>
593 <RepoHeader
594 owner={owner}
595 repo={repo}
596 starCount={starCount}
597 starred={starred}
598 currentUser={user?.username}
71cd5ecClaude599 archived={archived}
600 isTemplate={isTemplate}
06d5ffeClaude601 />
fc1817aClaude602 <RepoNav owner={owner} repo={repo} active="code" />
603 <div class="empty-state">
604 <h2>Empty repository</h2>
605 <p>Get started by pushing code:</p>
606 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
607git push -u gluecron main`}</pre>
608 </div>
609 </Layout>
610 );
611 }
612
613 const readme = await getReadme(owner, repo, defaultBranch);
614
615 return c.html(
06d5ffeClaude616 <Layout title={`${owner}/${repo}`} user={user}>
617 <RepoHeader
618 owner={owner}
619 repo={repo}
620 starCount={starCount}
621 starred={starred}
622 currentUser={user?.username}
71cd5ecClaude623 archived={archived}
624 isTemplate={isTemplate}
06d5ffeClaude625 />
71cd5ecClaude626 {isTemplate && user && user.username !== owner && (
627 <div
628 class="panel"
629 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
630 >
631 <div style="font-size:13px">
632 <strong>Template repository.</strong> Create a new repository from
633 this template's files.
634 </div>
635 <form
636 method="POST"
637 action={`/${owner}/${repo}/use-template`}
638 style="display:flex;gap:8px;align-items:center"
639 >
640 <input
641 type="text"
642 name="name"
643 placeholder="new-repo-name"
644 required
645 style="width:200px"
646 />
647 <button type="submit" class="btn btn-primary">
648 Use this template
649 </button>
650 </form>
651 </div>
652 )}
fc1817aClaude653 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude654 <BranchSwitcher
655 owner={owner}
656 repo={repo}
657 currentRef={defaultBranch}
658 branches={branches}
659 pathType="tree"
660 />
fc1817aClaude661 <FileTable
662 entries={tree}
663 owner={owner}
664 repo={repo}
665 ref={defaultBranch}
666 path=""
667 />
79136bbClaude668 {readme && (() => {
669 const readmeHtml = renderMarkdown(readme);
670 return (
671 <div class="blob-view" style="margin-top: 20px">
672 <div class="blob-header">README.md</div>
673 <style>{markdownCss}</style>
674 <div class="markdown-body">
675 {html([readmeHtml] as unknown as TemplateStringsArray)}
676 </div>
fc1817aClaude677 </div>
79136bbClaude678 );
679 })()}
fc1817aClaude680 </Layout>
681 );
682});
683
684// Browse tree at ref/path
685web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
686 const { owner, repo } = c.req.param();
06d5ffeClaude687 const user = c.get("user");
fc1817aClaude688 const refAndPath = c.req.param("ref");
689
690 const branches = await listBranches(owner, repo);
691 let ref = "";
692 let treePath = "";
693
694 for (const branch of branches) {
695 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
696 ref = branch;
697 treePath = refAndPath.slice(branch.length + 1);
698 break;
699 }
700 }
701
702 if (!ref) {
703 const slashIdx = refAndPath.indexOf("/");
704 if (slashIdx === -1) {
705 ref = refAndPath;
706 } else {
707 ref = refAndPath.slice(0, slashIdx);
708 treePath = refAndPath.slice(slashIdx + 1);
709 }
710 }
711
712 const tree = await getTree(owner, repo, ref, treePath);
713
714 return c.html(
06d5ffeClaude715 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude716 <RepoHeader owner={owner} repo={repo} />
717 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude718 <BranchSwitcher
719 owner={owner}
720 repo={repo}
721 currentRef={ref}
722 branches={branches}
723 pathType="tree"
724 subPath={treePath}
725 />
fc1817aClaude726 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
727 <FileTable
728 entries={tree}
729 owner={owner}
730 repo={repo}
731 ref={ref}
732 path={treePath}
733 />
734 </Layout>
735 );
736});
737
06d5ffeClaude738// View file blob with syntax highlighting
fc1817aClaude739web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
740 const { owner, repo } = c.req.param();
06d5ffeClaude741 const user = c.get("user");
fc1817aClaude742 const refAndPath = c.req.param("ref");
743
744 const branches = await listBranches(owner, repo);
745 let ref = "";
746 let filePath = "";
747
748 for (const branch of branches) {
749 if (refAndPath.startsWith(branch + "/")) {
750 ref = branch;
751 filePath = refAndPath.slice(branch.length + 1);
752 break;
753 }
754 }
755
756 if (!ref) {
757 const slashIdx = refAndPath.indexOf("/");
758 if (slashIdx === -1) return c.text("Not found", 404);
759 ref = refAndPath.slice(0, slashIdx);
760 filePath = refAndPath.slice(slashIdx + 1);
761 }
762
763 const blob = await getBlob(owner, repo, ref, filePath);
764 if (!blob) {
765 return c.html(
06d5ffeClaude766 <Layout title="Not Found" user={user}>
fc1817aClaude767 <div class="empty-state">
768 <h2>File not found</h2>
769 </div>
770 </Layout>,
771 404
772 );
773 }
774
06d5ffeClaude775 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude776
777 return c.html(
06d5ffeClaude778 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude779 <RepoHeader owner={owner} repo={repo} />
780 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude781 <BranchSwitcher
782 owner={owner}
783 repo={repo}
784 currentRef={ref}
785 branches={branches}
786 pathType="blob"
787 subPath={filePath}
788 />
fc1817aClaude789 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
790 <div class="blob-view">
791 <div class="blob-header">
06d5ffeClaude792 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude793 <span style="display: flex; gap: 12px">
794 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
795 Raw
796 </a>
797 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
798 Blame
799 </a>
0074234Claude800 {user && (
801 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
802 Edit
803 </a>
804 )}
79136bbClaude805 </span>
fc1817aClaude806 </div>
807 {blob.isBinary ? (
808 <div style="padding: 16px; color: var(--text-muted)">
809 Binary file not shown.
810 </div>
06d5ffeClaude811 ) : (() => {
812 const { html: highlighted, language } = highlightCode(
813 blob.content,
814 fileName
815 );
816 const lineCount = blob.content.split("\n").length;
817 // Trim trailing newline from count
818 const adjustedCount =
819 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
820
821 if (language) {
822 return (
823 <HighlightedCode
824 highlightedHtml={highlighted}
825 lineCount={adjustedCount}
826 />
827 );
828 }
829 const lines = blob.content.split("\n");
830 if (lines[lines.length - 1] === "") lines.pop();
831 return <PlainCode lines={lines} />;
832 })()}
fc1817aClaude833 </div>
834 </Layout>
835 );
836});
837
838// Commit log
839web.get("/:owner/:repo/commits/:ref?", async (c) => {
840 const { owner, repo } = c.req.param();
06d5ffeClaude841 const user = c.get("user");
fc1817aClaude842 const ref =
843 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude844 const branches = await listBranches(owner, repo);
fc1817aClaude845
846 const commits = await listCommits(owner, repo, ref, 50);
847
3951454Claude848 // Block J3 — batch-fetch cached verification results for the page.
849 let verifications: Record<string, { verified: boolean; reason: string }> = {};
850 try {
851 const [ownerRow] = await db
852 .select()
853 .from(users)
854 .where(eq(users.username, owner))
855 .limit(1);
856 if (ownerRow) {
857 const [repoRow] = await db
858 .select()
859 .from(repositories)
860 .where(
861 and(
862 eq(repositories.ownerId, ownerRow.id),
863 eq(repositories.name, repo)
864 )
865 )
866 .limit(1);
867 if (repoRow && commits.length > 0) {
868 const rows = await db
869 .select()
870 .from(commitVerifications)
871 .where(
872 and(
873 eq(commitVerifications.repositoryId, repoRow.id),
874 inArray(
875 commitVerifications.commitSha,
876 commits.map((c) => c.sha)
877 )
878 )
879 );
880 for (const r of rows) {
881 verifications[r.commitSha] = {
882 verified: r.verified,
883 reason: r.reason,
884 };
885 }
886 }
887 }
888 } catch {
889 // DB unavailable — skip the badges gracefully.
890 }
891
fc1817aClaude892 return c.html(
06d5ffeClaude893 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude894 <RepoHeader owner={owner} repo={repo} />
895 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude896 <BranchSwitcher
897 owner={owner}
898 repo={repo}
899 currentRef={ref}
900 branches={branches}
901 pathType="commits"
902 />
fc1817aClaude903 {commits.length === 0 ? (
904 <div class="empty-state">
905 <p>No commits yet.</p>
906 </div>
907 ) : (
3951454Claude908 <CommitList
909 commits={commits}
910 owner={owner}
911 repo={repo}
912 verifications={verifications}
913 />
fc1817aClaude914 )}
915 </Layout>
916 );
917});
918
919// Single commit with diff
920web.get("/:owner/:repo/commit/:sha", async (c) => {
921 const { owner, repo, sha } = c.req.param();
06d5ffeClaude922 const user = c.get("user");
fc1817aClaude923
05b973eClaude924 // Fetch commit, full message, and diff in parallel
925 const [commit, fullMessage, diffResult] = await Promise.all([
926 getCommit(owner, repo, sha),
927 getCommitFullMessage(owner, repo, sha),
928 getDiff(owner, repo, sha),
929 ]);
fc1817aClaude930 if (!commit) {
931 return c.html(
06d5ffeClaude932 <Layout title="Not Found" user={user}>
fc1817aClaude933 <div class="empty-state">
934 <h2>Commit not found</h2>
935 </div>
936 </Layout>,
937 404
938 );
939 }
940
3951454Claude941 // Block J3 — try to verify this commit's signature.
942 let verification:
943 | { verified: boolean; reason: string; signatureType: string | null }
944 | null = null;
0cdfd89Claude945 // Block J8 — external CI commit statuses rollup.
946 let statusCombined:
947 | {
948 state: "pending" | "success" | "failure";
949 total: number;
950 contexts: Array<{
951 context: string;
952 state: string;
953 description: string | null;
954 targetUrl: string | null;
955 }>;
956 }
957 | null = null;
3951454Claude958 try {
959 const [ownerRow] = await db
960 .select()
961 .from(users)
962 .where(eq(users.username, owner))
963 .limit(1);
964 if (ownerRow) {
965 const [repoRow] = await db
966 .select()
967 .from(repositories)
968 .where(
969 and(
970 eq(repositories.ownerId, ownerRow.id),
971 eq(repositories.name, repo)
972 )
973 )
974 .limit(1);
975 if (repoRow) {
976 const { verifyCommit } = await import("../lib/signatures");
977 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
978 verification = {
979 verified: v.verified,
980 reason: v.reason,
981 signatureType: v.signatureType,
982 };
0cdfd89Claude983 try {
984 const { combinedStatus } = await import("../lib/commit-statuses");
985 const combined = await combinedStatus(repoRow.id, commit.sha);
986 if (combined.total > 0) {
987 statusCombined = {
988 state: combined.state as any,
989 total: combined.total,
990 contexts: combined.contexts.map((c) => ({
991 context: c.context,
992 state: c.state,
993 description: c.description,
994 targetUrl: c.targetUrl,
995 })),
996 };
997 }
998 } catch {
999 statusCombined = null;
1000 }
3951454Claude1001 }
1002 }
1003 } catch {
1004 verification = null;
1005 }
1006
05b973eClaude1007 const { files, raw } = diffResult;
fc1817aClaude1008
1009 return c.html(
06d5ffeClaude1010 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude1011 <RepoHeader owner={owner} repo={repo} />
1012 <div
1013 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
1014 >
1015 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1016 {commit.message}
1017 </div>
1018 {fullMessage !== commit.message && (
1019 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1020 {fullMessage}
1021 </div>
1022 )}
1023 <div style="font-size: 13px; color: var(--text-muted)">
1024 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1025 committed on{" "}
1026 {new Date(commit.date).toLocaleDateString("en-US", {
1027 month: "long",
1028 day: "numeric",
1029 year: "numeric",
1030 })}
3951454Claude1031 {verification && verification.reason !== "unsigned" && (
1032 <span
1033 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1034 verification.verified
1035 ? "var(--green,#2ea043)"
1036 : "var(--yellow,#d29922)"
1037 }`}
1038 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1039 >
1040 {verification.verified ? "Verified" : verification.reason}
1041 </span>
1042 )}
fc1817aClaude1043 </div>
1044 <div style="margin-top: 8px">
1045 <span class="commit-sha">{commit.sha}</span>
1046 {commit.parentShas.length > 0 && (
1047 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1048 Parent:{" "}
1049 {commit.parentShas.map((p) => (
1050 <a
1051 href={`/${owner}/${repo}/commit/${p}`}
1052 class="commit-sha"
1053 style="margin-left: 4px"
1054 >
1055 {p.slice(0, 7)}
1056 </a>
1057 ))}
1058 </span>
1059 )}
1060 </div>
0cdfd89Claude1061 {statusCombined && (
1062 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1063 <strong style="color: var(--text)">Checks</strong>
1064 <span style="margin-left: 8px; color: var(--text-muted)">
1065 {statusCombined.total} total —{" "}
1066 <span
1067 style={`color:${
1068 statusCombined.state === "success"
1069 ? "var(--green,#2ea043)"
1070 : statusCombined.state === "failure"
1071 ? "var(--red,#da3633)"
1072 : "var(--yellow,#d29922)"
1073 }`}
1074 >
1075 {statusCombined.state}
1076 </span>
1077 </span>
1078 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1079 {statusCombined.contexts.map((cx) => (
1080 <span
1081 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1082 cx.state === "success"
1083 ? "var(--green,#2ea043)"
1084 : cx.state === "pending"
1085 ? "var(--yellow,#d29922)"
1086 : "var(--red,#da3633)"
1087 }`}
1088 title={cx.description || cx.context}
1089 >
1090 {cx.targetUrl ? (
1091 <a
1092 href={cx.targetUrl}
1093 style="color: inherit; text-decoration: none"
1094 rel="noopener"
1095 >
1096 {cx.context}: {cx.state}
1097 </a>
1098 ) : (
1099 <>
1100 {cx.context}: {cx.state}
1101 </>
1102 )}
1103 </span>
1104 ))}
1105 </div>
1106 </div>
1107 )}
fc1817aClaude1108 </div>
1109 <DiffView raw={raw} files={files} />
1110 </Layout>
1111 );
1112});
1113
79136bbClaude1114// Raw file download
1115web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1116 const { owner, repo } = c.req.param();
1117 const refAndPath = c.req.param("ref");
1118
1119 const branches = await listBranches(owner, repo);
1120 let ref = "";
1121 let filePath = "";
1122
1123 for (const branch of branches) {
1124 if (refAndPath.startsWith(branch + "/")) {
1125 ref = branch;
1126 filePath = refAndPath.slice(branch.length + 1);
1127 break;
1128 }
1129 }
1130
1131 if (!ref) {
1132 const slashIdx = refAndPath.indexOf("/");
1133 if (slashIdx === -1) return c.text("Not found", 404);
1134 ref = refAndPath.slice(0, slashIdx);
1135 filePath = refAndPath.slice(slashIdx + 1);
1136 }
1137
1138 const data = await getRawBlob(owner, repo, ref, filePath);
1139 if (!data) return c.text("Not found", 404);
1140
1141 const fileName = filePath.split("/").pop() || "file";
1142 return new Response(data, {
1143 headers: {
1144 "Content-Type": "application/octet-stream",
1145 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1146 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1147 },
1148 });
1149});
1150
1151// Blame view
1152web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1153 const { owner, repo } = c.req.param();
1154 const user = c.get("user");
1155 const refAndPath = c.req.param("ref");
1156
1157 const branches = await listBranches(owner, repo);
1158 let ref = "";
1159 let filePath = "";
1160
1161 for (const branch of branches) {
1162 if (refAndPath.startsWith(branch + "/")) {
1163 ref = branch;
1164 filePath = refAndPath.slice(branch.length + 1);
1165 break;
1166 }
1167 }
1168
1169 if (!ref) {
1170 const slashIdx = refAndPath.indexOf("/");
1171 if (slashIdx === -1) return c.text("Not found", 404);
1172 ref = refAndPath.slice(0, slashIdx);
1173 filePath = refAndPath.slice(slashIdx + 1);
1174 }
1175
1176 const blameLines = await getBlame(owner, repo, ref, filePath);
1177 if (blameLines.length === 0) {
1178 return c.html(
1179 <Layout title="Not Found" user={user}>
1180 <div class="empty-state">
1181 <h2>File not found</h2>
1182 </div>
1183 </Layout>,
1184 404
1185 );
1186 }
1187
1188 const fileName = filePath.split("/").pop() || filePath;
1189
1190 return c.html(
1191 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1192 <RepoHeader owner={owner} repo={repo} />
1193 <RepoNav owner={owner} repo={repo} active="code" />
1194 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1195 <div class="blob-view">
1196 <div class="blob-header">
1197 <span>{fileName} — blame</span>
1198 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1199 Normal view
1200 </a>
1201 </div>
1202 <div class="blob-code" style="overflow-x: auto">
1203 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1204 <tbody>
1205 {blameLines.map((line, i) => {
1206 const showInfo =
1207 i === 0 || blameLines[i - 1].sha !== line.sha;
1208 return (
1209 <tr style="border-bottom: 1px solid var(--border)">
1210 <td
1211 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)" : ""}`}
1212 >
1213 {showInfo && (
1214 <>
1215 <a
1216 href={`/${owner}/${repo}/commit/${line.sha}`}
1217 style="color: var(--text-link); font-family: var(--font-mono)"
1218 >
1219 {line.sha.slice(0, 7)}
1220 </a>{" "}
1221 <span>{line.author}</span>
1222 </>
1223 )}
1224 </td>
1225 <td class="line-num">{line.lineNum}</td>
1226 <td class="line-content">{line.content}</td>
1227 </tr>
1228 );
1229 })}
1230 </tbody>
1231 </table>
1232 </div>
1233 </div>
1234 </Layout>
1235 );
1236});
1237
1238// Search
1239web.get("/:owner/:repo/search", async (c) => {
1240 const { owner, repo } = c.req.param();
1241 const user = c.get("user");
1242 const q = c.req.query("q") || "";
1243
1244 if (!(await repoExists(owner, repo))) return c.notFound();
1245
1246 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1247 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1248
1249 if (q.trim()) {
1250 results = await searchCode(owner, repo, defaultBranch, q.trim());
1251 }
1252
1253 return c.html(
1254 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1255 <RepoHeader owner={owner} repo={repo} />
1256 <RepoNav owner={owner} repo={repo} active="code" />
1257 <form
1258 method="GET"
1259 action={`/${owner}/${repo}/search`}
1260 style="margin-bottom: 20px"
1261 >
1262 <div style="display: flex; gap: 8px">
1263 <input
1264 type="text"
1265 name="q"
1266 value={q}
1267 placeholder="Search code..."
1268 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1269 />
1270 <button type="submit" class="btn btn-primary">
1271 Search
1272 </button>
1273 </div>
1274 </form>
1275 {q && (
1276 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1277 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1278 <strong style="color: var(--text)">"{q}"</strong>
1279 </p>
1280 )}
1281 {results.length > 0 && (
1282 <div class="search-results">
1283 {(() => {
1284 // Group by file
1285 const grouped: Record<
1286 string,
1287 Array<{ lineNum: number; line: string }>
1288 > = {};
1289 for (const r of results) {
1290 if (!grouped[r.file]) grouped[r.file] = [];
1291 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1292 }
1293 return Object.entries(grouped).map(([file, matches]) => (
1294 <div class="diff-file" style="margin-bottom: 12px">
1295 <div class="diff-file-header">
1296 <a
1297 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1298 >
1299 {file}
1300 </a>
1301 </div>
1302 <div class="blob-code">
1303 <table>
1304 <tbody>
1305 {matches.map((m) => (
1306 <tr>
1307 <td class="line-num">{m.lineNum}</td>
1308 <td class="line-content">{m.line}</td>
1309 </tr>
1310 ))}
1311 </tbody>
1312 </table>
1313 </div>
1314 </div>
1315 ));
1316 })()}
1317 </div>
1318 )}
1319 </Layout>
1320 );
1321});
1322
fc1817aClaude1323export default web;