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.tsxBlame1131 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";
fc1817aClaude50
06d5ffeClaude51const web = new Hono<AuthEnv>();
52
53// Soft auth on all web routes — c.get("user") available but may be null
54web.use("*", softAuth);
fc1817aClaude55
56// Home page
06d5ffeClaude57web.get("/", async (c) => {
58 const user = c.get("user");
59
60 if (user) {
3ef4c9dClaude61 const { renderDashboard } = await import("./dashboard");
62 return renderDashboard(c);
06d5ffeClaude63 }
64
fc1817aClaude65 return c.html(
06d5ffeClaude66 <Layout user={null}>
fc1817aClaude67 <div class="empty-state">
68 <h2>gluecron</h2>
69 <p>AI-native code intelligence platform</p>
06d5ffeClaude70 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
71 <a href="/register" class="btn btn-primary">
72 Get started
73 </a>
74 <a href="/login" class="btn">
75 Sign in
76 </a>
77 </div>
78 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude79curl -X POST http://localhost:3000/api/setup \\
80 -H 'Content-Type: application/json' \\
81 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
82
83git remote add gluecron http://localhost:3000/you/hello.git
84git push gluecron main`}</pre>
85 </div>
86 </Layout>
87 );
88});
89
06d5ffeClaude90// New repository form
91web.get("/new", requireAuth, (c) => {
92 const user = c.get("user")!;
93 const error = c.req.query("error");
94
95 return c.html(
96 <Layout title="New repository" user={user}>
97 <div class="new-repo-form">
98 <h2>Create a new repository</h2>
99 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
100 <form method="POST" action="/new">
101 <div class="form-group">
102 <label>Owner</label>
103 <input type="text" value={user.username} disabled class="input-disabled" />
104 </div>
105 <div class="form-group">
106 <label for="name">Repository name</label>
107 <input
108 type="text"
109 id="name"
110 name="name"
111 required
112 pattern="^[a-zA-Z0-9._-]+$"
113 placeholder="my-project"
114 autocomplete="off"
115 />
116 </div>
117 <div class="form-group">
118 <label for="description">Description (optional)</label>
119 <input
120 type="text"
121 id="description"
122 name="description"
123 placeholder="A short description of your repository"
124 />
125 </div>
126 <div class="visibility-options">
127 <label class="visibility-option">
128 <input type="radio" name="visibility" value="public" checked />
129 <div class="vis-label">Public</div>
130 <div class="vis-desc">Anyone can see this repository</div>
131 </label>
132 <label class="visibility-option">
133 <input type="radio" name="visibility" value="private" />
134 <div class="vis-label">Private</div>
135 <div class="vis-desc">Only you can see this repository</div>
136 </label>
137 </div>
138 <button type="submit" class="btn btn-primary">
139 Create repository
140 </button>
141 </form>
142 </div>
143 </Layout>
144 );
145});
146
147web.post("/new", requireAuth, async (c) => {
148 const user = c.get("user")!;
149 const body = await c.req.parseBody();
150 const name = String(body.name || "").trim();
151 const description = String(body.description || "").trim();
152 const isPrivate = body.visibility === "private";
153
154 if (!name) {
155 return c.redirect("/new?error=Repository+name+is+required");
156 }
157
158 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
159 return c.redirect("/new?error=Invalid+repository+name");
160 }
161
162 if (await repoExists(user.username, name)) {
163 return c.redirect("/new?error=Repository+already+exists");
164 }
165
166 const diskPath = await initBareRepo(user.username, name);
167
3ef4c9dClaude168 const [newRepo] = await db
169 .insert(repositories)
170 .values({
171 name,
172 ownerId: user.id,
173 description: description || null,
174 isPrivate,
175 diskPath,
176 })
177 .returning();
178
179 if (newRepo) {
180 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
181 await bootstrapRepository({
182 repositoryId: newRepo.id,
183 ownerUserId: user.id,
184 defaultBranch: "main",
185 });
186 }
06d5ffeClaude187
188 return c.redirect(`/${user.username}/${name}`);
189});
190
191// User profile
fc1817aClaude192web.get("/:owner", async (c) => {
06d5ffeClaude193 const { owner: ownerName } = c.req.param();
194 const user = c.get("user");
195
196 // Avoid clashing with fixed routes
197 if (
198 ["login", "register", "logout", "new", "settings", "api"].includes(
199 ownerName
200 )
201 ) {
202 return c.notFound();
203 }
204
205 let ownerUser;
206 try {
207 const [found] = await db
208 .select()
209 .from(users)
210 .where(eq(users.username, ownerName))
211 .limit(1);
212 ownerUser = found;
213 } catch {
214 // DB not available — check if repos exist on disk
215 ownerUser = null;
216 }
217
218 // Even without DB, show repos if they exist on disk
219 let repos: any[] = [];
220 if (ownerUser) {
221 const allRepos = await db
222 .select()
223 .from(repositories)
224 .where(eq(repositories.ownerId, ownerUser.id))
225 .orderBy(desc(repositories.updatedAt));
226
227 // Show public repos to everyone, private only to owner
228 repos =
229 user?.id === ownerUser.id
230 ? allRepos
231 : allRepos.filter((r) => !r.isPrivate);
232 }
233
7aa8b99Claude234 // Block J4 — follow counts + viewer's follow state
235 let followState = {
236 followers: 0,
237 following: 0,
238 viewerFollows: false,
239 };
240 if (ownerUser) {
241 try {
242 const { followCounts, isFollowing } = await import("../lib/follows");
243 const counts = await followCounts(ownerUser.id);
244 followState.followers = counts.followers;
245 followState.following = counts.following;
246 if (user && user.id !== ownerUser.id) {
247 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
248 }
249 } catch {
250 // DB hiccup — fall back to zeros.
251 }
252 }
253 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
254
fc1817aClaude255 return c.html(
06d5ffeClaude256 <Layout title={ownerName} user={user}>
257 <div class="user-profile">
258 <div class="user-avatar">
259 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
260 </div>
261 <div class="user-info">
262 <h2>{ownerUser?.displayName || ownerName}</h2>
263 <div class="username">@{ownerName}</div>
264 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude265 <div
266 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
267 >
268 <a
269 href={`/${ownerName}/followers`}
270 style="color:var(--text-muted)"
271 >
272 <strong style="color:var(--text)">
273 {followState.followers}
274 </strong>{" "}
275 follower{followState.followers === 1 ? "" : "s"}
276 </a>
277 <a
278 href={`/${ownerName}/following`}
279 style="color:var(--text-muted)"
280 >
281 <strong style="color:var(--text)">
282 {followState.following}
283 </strong>{" "}
284 following
285 </a>
286 {canFollow && (
287 <form
288 method="POST"
289 action={`/${ownerName}/${
290 followState.viewerFollows ? "unfollow" : "follow"
291 }`}
292 >
293 <button
294 type="submit"
295 class={`btn ${
296 followState.viewerFollows ? "" : "btn-primary"
297 } btn-sm`}
298 >
299 {followState.viewerFollows ? "Unfollow" : "Follow"}
300 </button>
301 </form>
302 )}
303 </div>
06d5ffeClaude304 </div>
305 </div>
306 <h3 style="margin-bottom: 16px">Repositories</h3>
307 {repos.length === 0 ? (
308 <p style="color: var(--text-muted)">No repositories yet.</p>
309 ) : (
310 <div class="card-grid">
311 {repos.map((repo) => (
312 <RepoCard repo={repo} ownerName={ownerName} />
313 ))}
314 </div>
315 )}
fc1817aClaude316 </Layout>
317 );
318});
319
06d5ffeClaude320// Star/unstar a repo
321web.post("/:owner/:repo/star", requireAuth, async (c) => {
322 const { owner: ownerName, repo: repoName } = c.req.param();
323 const user = c.get("user")!;
324
325 try {
326 const [ownerUser] = await db
327 .select()
328 .from(users)
329 .where(eq(users.username, ownerName))
330 .limit(1);
331 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
332
333 const [repo] = await db
334 .select()
335 .from(repositories)
336 .where(
337 and(
338 eq(repositories.ownerId, ownerUser.id),
339 eq(repositories.name, repoName)
340 )
341 )
342 .limit(1);
343 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
344
345 // Toggle star
346 const [existing] = await db
347 .select()
348 .from(stars)
349 .where(
350 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
351 )
352 .limit(1);
353
354 if (existing) {
355 await db.delete(stars).where(eq(stars.id, existing.id));
356 await db
357 .update(repositories)
358 .set({ starCount: Math.max(0, repo.starCount - 1) })
359 .where(eq(repositories.id, repo.id));
360 } else {
361 await db.insert(stars).values({
362 userId: user.id,
363 repositoryId: repo.id,
364 });
365 await db
366 .update(repositories)
367 .set({ starCount: repo.starCount + 1 })
368 .where(eq(repositories.id, repo.id));
369 }
370 } catch {
371 // DB error — ignore
372 }
373
374 return c.redirect(`/${ownerName}/${repoName}`);
375});
376
fc1817aClaude377// Repository overview — file tree at HEAD
378web.get("/:owner/:repo", async (c) => {
379 const { owner, repo } = c.req.param();
06d5ffeClaude380 const user = c.get("user");
fc1817aClaude381
8f50ed0Claude382 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
383 trackByName(owner, repo, "view", {
384 userId: user?.id || null,
385 path: `/${owner}/${repo}`,
386 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
387 userAgent: c.req.header("user-agent") || null,
388 referer: c.req.header("referer") || null,
389 }).catch(() => {});
390
fc1817aClaude391 if (!(await repoExists(owner, repo))) {
392 return c.html(
06d5ffeClaude393 <Layout title="Not Found" user={user}>
fc1817aClaude394 <div class="empty-state">
395 <h2>Repository not found</h2>
396 <p>
397 {owner}/{repo} does not exist.
398 </p>
399 </div>
400 </Layout>,
401 404
402 );
403 }
404
05b973eClaude405 // Parallelize all independent operations
406 const [defaultBranch, branches] = await Promise.all([
407 getDefaultBranch(owner, repo).then((b) => b || "main"),
408 listBranches(owner, repo),
409 ]);
410 const [tree, starInfo] = await Promise.all([
411 getTree(owner, repo, defaultBranch),
412 // Star info fetched in parallel with tree
413 (async () => {
414 try {
415 const [ownerUser] = await db
416 .select()
417 .from(users)
418 .where(eq(users.username, owner))
419 .limit(1);
71cd5ecClaude420 if (!ownerUser)
421 return {
422 starCount: 0,
423 starred: false,
424 archived: false,
425 isTemplate: false,
426 };
05b973eClaude427 const [repoRow] = await db
428 .select()
429 .from(repositories)
430 .where(
431 and(
432 eq(repositories.ownerId, ownerUser.id),
433 eq(repositories.name, repo)
434 )
06d5ffeClaude435 )
05b973eClaude436 .limit(1);
71cd5ecClaude437 if (!repoRow)
438 return {
439 starCount: 0,
440 starred: false,
441 archived: false,
442 isTemplate: false,
443 };
05b973eClaude444 let starred = false;
06d5ffeClaude445 if (user) {
446 const [star] = await db
447 .select()
448 .from(stars)
449 .where(
450 and(
451 eq(stars.userId, user.id),
452 eq(stars.repositoryId, repoRow.id)
453 )
454 )
455 .limit(1);
456 starred = !!star;
457 }
71cd5ecClaude458 return {
459 starCount: repoRow.starCount,
460 starred,
461 archived: repoRow.isArchived,
462 isTemplate: repoRow.isTemplate,
463 };
05b973eClaude464 } catch {
71cd5ecClaude465 return {
466 starCount: 0,
467 starred: false,
468 archived: false,
469 isTemplate: false,
470 };
06d5ffeClaude471 }
05b973eClaude472 })(),
473 ]);
71cd5ecClaude474 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude475
fc1817aClaude476 if (tree.length === 0) {
477 return c.html(
06d5ffeClaude478 <Layout title={`${owner}/${repo}`} user={user}>
479 <RepoHeader
480 owner={owner}
481 repo={repo}
482 starCount={starCount}
483 starred={starred}
484 currentUser={user?.username}
71cd5ecClaude485 archived={archived}
486 isTemplate={isTemplate}
06d5ffeClaude487 />
fc1817aClaude488 <RepoNav owner={owner} repo={repo} active="code" />
489 <div class="empty-state">
490 <h2>Empty repository</h2>
491 <p>Get started by pushing code:</p>
492 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
493git push -u gluecron main`}</pre>
494 </div>
495 </Layout>
496 );
497 }
498
499 const readme = await getReadme(owner, repo, defaultBranch);
500
501 return c.html(
06d5ffeClaude502 <Layout title={`${owner}/${repo}`} user={user}>
503 <RepoHeader
504 owner={owner}
505 repo={repo}
506 starCount={starCount}
507 starred={starred}
508 currentUser={user?.username}
71cd5ecClaude509 archived={archived}
510 isTemplate={isTemplate}
06d5ffeClaude511 />
71cd5ecClaude512 {isTemplate && user && user.username !== owner && (
513 <div
514 class="panel"
515 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
516 >
517 <div style="font-size:13px">
518 <strong>Template repository.</strong> Create a new repository from
519 this template's files.
520 </div>
521 <form
522 method="POST"
523 action={`/${owner}/${repo}/use-template`}
524 style="display:flex;gap:8px;align-items:center"
525 >
526 <input
527 type="text"
528 name="name"
529 placeholder="new-repo-name"
530 required
531 style="width:200px"
532 />
533 <button type="submit" class="btn btn-primary">
534 Use this template
535 </button>
536 </form>
537 </div>
538 )}
fc1817aClaude539 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude540 <BranchSwitcher
541 owner={owner}
542 repo={repo}
543 currentRef={defaultBranch}
544 branches={branches}
545 pathType="tree"
546 />
fc1817aClaude547 <FileTable
548 entries={tree}
549 owner={owner}
550 repo={repo}
551 ref={defaultBranch}
552 path=""
553 />
79136bbClaude554 {readme && (() => {
555 const readmeHtml = renderMarkdown(readme);
556 return (
557 <div class="blob-view" style="margin-top: 20px">
558 <div class="blob-header">README.md</div>
559 <style>{markdownCss}</style>
560 <div class="markdown-body">
561 {html([readmeHtml] as unknown as TemplateStringsArray)}
562 </div>
fc1817aClaude563 </div>
79136bbClaude564 );
565 })()}
fc1817aClaude566 </Layout>
567 );
568});
569
570// Browse tree at ref/path
571web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
572 const { owner, repo } = c.req.param();
06d5ffeClaude573 const user = c.get("user");
fc1817aClaude574 const refAndPath = c.req.param("ref");
575
576 const branches = await listBranches(owner, repo);
577 let ref = "";
578 let treePath = "";
579
580 for (const branch of branches) {
581 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
582 ref = branch;
583 treePath = refAndPath.slice(branch.length + 1);
584 break;
585 }
586 }
587
588 if (!ref) {
589 const slashIdx = refAndPath.indexOf("/");
590 if (slashIdx === -1) {
591 ref = refAndPath;
592 } else {
593 ref = refAndPath.slice(0, slashIdx);
594 treePath = refAndPath.slice(slashIdx + 1);
595 }
596 }
597
598 const tree = await getTree(owner, repo, ref, treePath);
599
600 return c.html(
06d5ffeClaude601 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude602 <RepoHeader owner={owner} repo={repo} />
603 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude604 <BranchSwitcher
605 owner={owner}
606 repo={repo}
607 currentRef={ref}
608 branches={branches}
609 pathType="tree"
610 subPath={treePath}
611 />
fc1817aClaude612 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
613 <FileTable
614 entries={tree}
615 owner={owner}
616 repo={repo}
617 ref={ref}
618 path={treePath}
619 />
620 </Layout>
621 );
622});
623
06d5ffeClaude624// View file blob with syntax highlighting
fc1817aClaude625web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
626 const { owner, repo } = c.req.param();
06d5ffeClaude627 const user = c.get("user");
fc1817aClaude628 const refAndPath = c.req.param("ref");
629
630 const branches = await listBranches(owner, repo);
631 let ref = "";
632 let filePath = "";
633
634 for (const branch of branches) {
635 if (refAndPath.startsWith(branch + "/")) {
636 ref = branch;
637 filePath = refAndPath.slice(branch.length + 1);
638 break;
639 }
640 }
641
642 if (!ref) {
643 const slashIdx = refAndPath.indexOf("/");
644 if (slashIdx === -1) return c.text("Not found", 404);
645 ref = refAndPath.slice(0, slashIdx);
646 filePath = refAndPath.slice(slashIdx + 1);
647 }
648
649 const blob = await getBlob(owner, repo, ref, filePath);
650 if (!blob) {
651 return c.html(
06d5ffeClaude652 <Layout title="Not Found" user={user}>
fc1817aClaude653 <div class="empty-state">
654 <h2>File not found</h2>
655 </div>
656 </Layout>,
657 404
658 );
659 }
660
06d5ffeClaude661 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude662
663 return c.html(
06d5ffeClaude664 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude665 <RepoHeader owner={owner} repo={repo} />
666 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude667 <BranchSwitcher
668 owner={owner}
669 repo={repo}
670 currentRef={ref}
671 branches={branches}
672 pathType="blob"
673 subPath={filePath}
674 />
fc1817aClaude675 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
676 <div class="blob-view">
677 <div class="blob-header">
06d5ffeClaude678 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude679 <span style="display: flex; gap: 12px">
680 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
681 Raw
682 </a>
683 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
684 Blame
685 </a>
0074234Claude686 {user && (
687 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
688 Edit
689 </a>
690 )}
79136bbClaude691 </span>
fc1817aClaude692 </div>
693 {blob.isBinary ? (
694 <div style="padding: 16px; color: var(--text-muted)">
695 Binary file not shown.
696 </div>
06d5ffeClaude697 ) : (() => {
698 const { html: highlighted, language } = highlightCode(
699 blob.content,
700 fileName
701 );
702 const lineCount = blob.content.split("\n").length;
703 // Trim trailing newline from count
704 const adjustedCount =
705 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
706
707 if (language) {
708 return (
709 <HighlightedCode
710 highlightedHtml={highlighted}
711 lineCount={adjustedCount}
712 />
713 );
714 }
715 const lines = blob.content.split("\n");
716 if (lines[lines.length - 1] === "") lines.pop();
717 return <PlainCode lines={lines} />;
718 })()}
fc1817aClaude719 </div>
720 </Layout>
721 );
722});
723
724// Commit log
725web.get("/:owner/:repo/commits/:ref?", async (c) => {
726 const { owner, repo } = c.req.param();
06d5ffeClaude727 const user = c.get("user");
fc1817aClaude728 const ref =
729 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude730 const branches = await listBranches(owner, repo);
fc1817aClaude731
732 const commits = await listCommits(owner, repo, ref, 50);
733
3951454Claude734 // Block J3 — batch-fetch cached verification results for the page.
735 let verifications: Record<string, { verified: boolean; reason: string }> = {};
736 try {
737 const [ownerRow] = await db
738 .select()
739 .from(users)
740 .where(eq(users.username, owner))
741 .limit(1);
742 if (ownerRow) {
743 const [repoRow] = await db
744 .select()
745 .from(repositories)
746 .where(
747 and(
748 eq(repositories.ownerId, ownerRow.id),
749 eq(repositories.name, repo)
750 )
751 )
752 .limit(1);
753 if (repoRow && commits.length > 0) {
754 const rows = await db
755 .select()
756 .from(commitVerifications)
757 .where(
758 and(
759 eq(commitVerifications.repositoryId, repoRow.id),
760 inArray(
761 commitVerifications.commitSha,
762 commits.map((c) => c.sha)
763 )
764 )
765 );
766 for (const r of rows) {
767 verifications[r.commitSha] = {
768 verified: r.verified,
769 reason: r.reason,
770 };
771 }
772 }
773 }
774 } catch {
775 // DB unavailable — skip the badges gracefully.
776 }
777
fc1817aClaude778 return c.html(
06d5ffeClaude779 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude780 <RepoHeader owner={owner} repo={repo} />
781 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude782 <BranchSwitcher
783 owner={owner}
784 repo={repo}
785 currentRef={ref}
786 branches={branches}
787 pathType="commits"
788 />
fc1817aClaude789 {commits.length === 0 ? (
790 <div class="empty-state">
791 <p>No commits yet.</p>
792 </div>
793 ) : (
3951454Claude794 <CommitList
795 commits={commits}
796 owner={owner}
797 repo={repo}
798 verifications={verifications}
799 />
fc1817aClaude800 )}
801 </Layout>
802 );
803});
804
805// Single commit with diff
806web.get("/:owner/:repo/commit/:sha", async (c) => {
807 const { owner, repo, sha } = c.req.param();
06d5ffeClaude808 const user = c.get("user");
fc1817aClaude809
05b973eClaude810 // Fetch commit, full message, and diff in parallel
811 const [commit, fullMessage, diffResult] = await Promise.all([
812 getCommit(owner, repo, sha),
813 getCommitFullMessage(owner, repo, sha),
814 getDiff(owner, repo, sha),
815 ]);
fc1817aClaude816 if (!commit) {
817 return c.html(
06d5ffeClaude818 <Layout title="Not Found" user={user}>
fc1817aClaude819 <div class="empty-state">
820 <h2>Commit not found</h2>
821 </div>
822 </Layout>,
823 404
824 );
825 }
826
3951454Claude827 // Block J3 — try to verify this commit's signature.
828 let verification:
829 | { verified: boolean; reason: string; signatureType: string | null }
830 | null = null;
831 try {
832 const [ownerRow] = await db
833 .select()
834 .from(users)
835 .where(eq(users.username, owner))
836 .limit(1);
837 if (ownerRow) {
838 const [repoRow] = await db
839 .select()
840 .from(repositories)
841 .where(
842 and(
843 eq(repositories.ownerId, ownerRow.id),
844 eq(repositories.name, repo)
845 )
846 )
847 .limit(1);
848 if (repoRow) {
849 const { verifyCommit } = await import("../lib/signatures");
850 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
851 verification = {
852 verified: v.verified,
853 reason: v.reason,
854 signatureType: v.signatureType,
855 };
856 }
857 }
858 } catch {
859 verification = null;
860 }
861
05b973eClaude862 const { files, raw } = diffResult;
fc1817aClaude863
864 return c.html(
06d5ffeClaude865 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude866 <RepoHeader owner={owner} repo={repo} />
867 <div
868 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
869 >
870 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
871 {commit.message}
872 </div>
873 {fullMessage !== commit.message && (
874 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
875 {fullMessage}
876 </div>
877 )}
878 <div style="font-size: 13px; color: var(--text-muted)">
879 <strong style="color: var(--text)">{commit.author}</strong>{" "}
880 committed on{" "}
881 {new Date(commit.date).toLocaleDateString("en-US", {
882 month: "long",
883 day: "numeric",
884 year: "numeric",
885 })}
3951454Claude886 {verification && verification.reason !== "unsigned" && (
887 <span
888 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
889 verification.verified
890 ? "var(--green,#2ea043)"
891 : "var(--yellow,#d29922)"
892 }`}
893 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
894 >
895 {verification.verified ? "Verified" : verification.reason}
896 </span>
897 )}
fc1817aClaude898 </div>
899 <div style="margin-top: 8px">
900 <span class="commit-sha">{commit.sha}</span>
901 {commit.parentShas.length > 0 && (
902 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
903 Parent:{" "}
904 {commit.parentShas.map((p) => (
905 <a
906 href={`/${owner}/${repo}/commit/${p}`}
907 class="commit-sha"
908 style="margin-left: 4px"
909 >
910 {p.slice(0, 7)}
911 </a>
912 ))}
913 </span>
914 )}
915 </div>
916 </div>
917 <DiffView raw={raw} files={files} />
918 </Layout>
919 );
920});
921
79136bbClaude922// Raw file download
923web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
924 const { owner, repo } = c.req.param();
925 const refAndPath = c.req.param("ref");
926
927 const branches = await listBranches(owner, repo);
928 let ref = "";
929 let filePath = "";
930
931 for (const branch of branches) {
932 if (refAndPath.startsWith(branch + "/")) {
933 ref = branch;
934 filePath = refAndPath.slice(branch.length + 1);
935 break;
936 }
937 }
938
939 if (!ref) {
940 const slashIdx = refAndPath.indexOf("/");
941 if (slashIdx === -1) return c.text("Not found", 404);
942 ref = refAndPath.slice(0, slashIdx);
943 filePath = refAndPath.slice(slashIdx + 1);
944 }
945
946 const data = await getRawBlob(owner, repo, ref, filePath);
947 if (!data) return c.text("Not found", 404);
948
949 const fileName = filePath.split("/").pop() || "file";
950 return new Response(data, {
951 headers: {
952 "Content-Type": "application/octet-stream",
953 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude954 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude955 },
956 });
957});
958
959// Blame view
960web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
961 const { owner, repo } = c.req.param();
962 const user = c.get("user");
963 const refAndPath = c.req.param("ref");
964
965 const branches = await listBranches(owner, repo);
966 let ref = "";
967 let filePath = "";
968
969 for (const branch of branches) {
970 if (refAndPath.startsWith(branch + "/")) {
971 ref = branch;
972 filePath = refAndPath.slice(branch.length + 1);
973 break;
974 }
975 }
976
977 if (!ref) {
978 const slashIdx = refAndPath.indexOf("/");
979 if (slashIdx === -1) return c.text("Not found", 404);
980 ref = refAndPath.slice(0, slashIdx);
981 filePath = refAndPath.slice(slashIdx + 1);
982 }
983
984 const blameLines = await getBlame(owner, repo, ref, filePath);
985 if (blameLines.length === 0) {
986 return c.html(
987 <Layout title="Not Found" user={user}>
988 <div class="empty-state">
989 <h2>File not found</h2>
990 </div>
991 </Layout>,
992 404
993 );
994 }
995
996 const fileName = filePath.split("/").pop() || filePath;
997
998 return c.html(
999 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1000 <RepoHeader owner={owner} repo={repo} />
1001 <RepoNav owner={owner} repo={repo} active="code" />
1002 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1003 <div class="blob-view">
1004 <div class="blob-header">
1005 <span>{fileName} — blame</span>
1006 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1007 Normal view
1008 </a>
1009 </div>
1010 <div class="blob-code" style="overflow-x: auto">
1011 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1012 <tbody>
1013 {blameLines.map((line, i) => {
1014 const showInfo =
1015 i === 0 || blameLines[i - 1].sha !== line.sha;
1016 return (
1017 <tr style="border-bottom: 1px solid var(--border)">
1018 <td
1019 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)" : ""}`}
1020 >
1021 {showInfo && (
1022 <>
1023 <a
1024 href={`/${owner}/${repo}/commit/${line.sha}`}
1025 style="color: var(--text-link); font-family: var(--font-mono)"
1026 >
1027 {line.sha.slice(0, 7)}
1028 </a>{" "}
1029 <span>{line.author}</span>
1030 </>
1031 )}
1032 </td>
1033 <td class="line-num">{line.lineNum}</td>
1034 <td class="line-content">{line.content}</td>
1035 </tr>
1036 );
1037 })}
1038 </tbody>
1039 </table>
1040 </div>
1041 </div>
1042 </Layout>
1043 );
1044});
1045
1046// Search
1047web.get("/:owner/:repo/search", async (c) => {
1048 const { owner, repo } = c.req.param();
1049 const user = c.get("user");
1050 const q = c.req.query("q") || "";
1051
1052 if (!(await repoExists(owner, repo))) return c.notFound();
1053
1054 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1055 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1056
1057 if (q.trim()) {
1058 results = await searchCode(owner, repo, defaultBranch, q.trim());
1059 }
1060
1061 return c.html(
1062 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1063 <RepoHeader owner={owner} repo={repo} />
1064 <RepoNav owner={owner} repo={repo} active="code" />
1065 <form
1066 method="GET"
1067 action={`/${owner}/${repo}/search`}
1068 style="margin-bottom: 20px"
1069 >
1070 <div style="display: flex; gap: 8px">
1071 <input
1072 type="text"
1073 name="q"
1074 value={q}
1075 placeholder="Search code..."
1076 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1077 />
1078 <button type="submit" class="btn btn-primary">
1079 Search
1080 </button>
1081 </div>
1082 </form>
1083 {q && (
1084 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1085 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1086 <strong style="color: var(--text)">"{q}"</strong>
1087 </p>
1088 )}
1089 {results.length > 0 && (
1090 <div class="search-results">
1091 {(() => {
1092 // Group by file
1093 const grouped: Record<
1094 string,
1095 Array<{ lineNum: number; line: string }>
1096 > = {};
1097 for (const r of results) {
1098 if (!grouped[r.file]) grouped[r.file] = [];
1099 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1100 }
1101 return Object.entries(grouped).map(([file, matches]) => (
1102 <div class="diff-file" style="margin-bottom: 12px">
1103 <div class="diff-file-header">
1104 <a
1105 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1106 >
1107 {file}
1108 </a>
1109 </div>
1110 <div class="blob-code">
1111 <table>
1112 <tbody>
1113 {matches.map((m) => (
1114 <tr>
1115 <td class="line-num">{m.lineNum}</td>
1116 <td class="line-content">{m.line}</td>
1117 </tr>
1118 ))}
1119 </tbody>
1120 </table>
1121 </div>
1122 </div>
1123 ));
1124 })()}
1125 </div>
1126 )}
1127 </Layout>
1128 );
1129});
1130
fc1817aClaude1131export default web;