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.tsxBlame1071 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
fc1817aClaude234 return c.html(
06d5ffeClaude235 <Layout title={ownerName} user={user}>
236 <div class="user-profile">
237 <div class="user-avatar">
238 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
239 </div>
240 <div class="user-info">
241 <h2>{ownerUser?.displayName || ownerName}</h2>
242 <div class="username">@{ownerName}</div>
243 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
244 </div>
245 </div>
246 <h3 style="margin-bottom: 16px">Repositories</h3>
247 {repos.length === 0 ? (
248 <p style="color: var(--text-muted)">No repositories yet.</p>
249 ) : (
250 <div class="card-grid">
251 {repos.map((repo) => (
252 <RepoCard repo={repo} ownerName={ownerName} />
253 ))}
254 </div>
255 )}
fc1817aClaude256 </Layout>
257 );
258});
259
06d5ffeClaude260// Star/unstar a repo
261web.post("/:owner/:repo/star", requireAuth, async (c) => {
262 const { owner: ownerName, repo: repoName } = c.req.param();
263 const user = c.get("user")!;
264
265 try {
266 const [ownerUser] = await db
267 .select()
268 .from(users)
269 .where(eq(users.username, ownerName))
270 .limit(1);
271 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
272
273 const [repo] = await db
274 .select()
275 .from(repositories)
276 .where(
277 and(
278 eq(repositories.ownerId, ownerUser.id),
279 eq(repositories.name, repoName)
280 )
281 )
282 .limit(1);
283 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
284
285 // Toggle star
286 const [existing] = await db
287 .select()
288 .from(stars)
289 .where(
290 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
291 )
292 .limit(1);
293
294 if (existing) {
295 await db.delete(stars).where(eq(stars.id, existing.id));
296 await db
297 .update(repositories)
298 .set({ starCount: Math.max(0, repo.starCount - 1) })
299 .where(eq(repositories.id, repo.id));
300 } else {
301 await db.insert(stars).values({
302 userId: user.id,
303 repositoryId: repo.id,
304 });
305 await db
306 .update(repositories)
307 .set({ starCount: repo.starCount + 1 })
308 .where(eq(repositories.id, repo.id));
309 }
310 } catch {
311 // DB error — ignore
312 }
313
314 return c.redirect(`/${ownerName}/${repoName}`);
315});
316
fc1817aClaude317// Repository overview — file tree at HEAD
318web.get("/:owner/:repo", async (c) => {
319 const { owner, repo } = c.req.param();
06d5ffeClaude320 const user = c.get("user");
fc1817aClaude321
8f50ed0Claude322 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
323 trackByName(owner, repo, "view", {
324 userId: user?.id || null,
325 path: `/${owner}/${repo}`,
326 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
327 userAgent: c.req.header("user-agent") || null,
328 referer: c.req.header("referer") || null,
329 }).catch(() => {});
330
fc1817aClaude331 if (!(await repoExists(owner, repo))) {
332 return c.html(
06d5ffeClaude333 <Layout title="Not Found" user={user}>
fc1817aClaude334 <div class="empty-state">
335 <h2>Repository not found</h2>
336 <p>
337 {owner}/{repo} does not exist.
338 </p>
339 </div>
340 </Layout>,
341 404
342 );
343 }
344
05b973eClaude345 // Parallelize all independent operations
346 const [defaultBranch, branches] = await Promise.all([
347 getDefaultBranch(owner, repo).then((b) => b || "main"),
348 listBranches(owner, repo),
349 ]);
350 const [tree, starInfo] = await Promise.all([
351 getTree(owner, repo, defaultBranch),
352 // Star info fetched in parallel with tree
353 (async () => {
354 try {
355 const [ownerUser] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, owner))
359 .limit(1);
71cd5ecClaude360 if (!ownerUser)
361 return {
362 starCount: 0,
363 starred: false,
364 archived: false,
365 isTemplate: false,
366 };
05b973eClaude367 const [repoRow] = await db
368 .select()
369 .from(repositories)
370 .where(
371 and(
372 eq(repositories.ownerId, ownerUser.id),
373 eq(repositories.name, repo)
374 )
06d5ffeClaude375 )
05b973eClaude376 .limit(1);
71cd5ecClaude377 if (!repoRow)
378 return {
379 starCount: 0,
380 starred: false,
381 archived: false,
382 isTemplate: false,
383 };
05b973eClaude384 let starred = false;
06d5ffeClaude385 if (user) {
386 const [star] = await db
387 .select()
388 .from(stars)
389 .where(
390 and(
391 eq(stars.userId, user.id),
392 eq(stars.repositoryId, repoRow.id)
393 )
394 )
395 .limit(1);
396 starred = !!star;
397 }
71cd5ecClaude398 return {
399 starCount: repoRow.starCount,
400 starred,
401 archived: repoRow.isArchived,
402 isTemplate: repoRow.isTemplate,
403 };
05b973eClaude404 } catch {
71cd5ecClaude405 return {
406 starCount: 0,
407 starred: false,
408 archived: false,
409 isTemplate: false,
410 };
06d5ffeClaude411 }
05b973eClaude412 })(),
413 ]);
71cd5ecClaude414 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude415
fc1817aClaude416 if (tree.length === 0) {
417 return c.html(
06d5ffeClaude418 <Layout title={`${owner}/${repo}`} user={user}>
419 <RepoHeader
420 owner={owner}
421 repo={repo}
422 starCount={starCount}
423 starred={starred}
424 currentUser={user?.username}
71cd5ecClaude425 archived={archived}
426 isTemplate={isTemplate}
06d5ffeClaude427 />
fc1817aClaude428 <RepoNav owner={owner} repo={repo} active="code" />
429 <div class="empty-state">
430 <h2>Empty repository</h2>
431 <p>Get started by pushing code:</p>
432 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
433git push -u gluecron main`}</pre>
434 </div>
435 </Layout>
436 );
437 }
438
439 const readme = await getReadme(owner, repo, defaultBranch);
440
441 return c.html(
06d5ffeClaude442 <Layout title={`${owner}/${repo}`} user={user}>
443 <RepoHeader
444 owner={owner}
445 repo={repo}
446 starCount={starCount}
447 starred={starred}
448 currentUser={user?.username}
71cd5ecClaude449 archived={archived}
450 isTemplate={isTemplate}
06d5ffeClaude451 />
71cd5ecClaude452 {isTemplate && user && user.username !== owner && (
453 <div
454 class="panel"
455 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
456 >
457 <div style="font-size:13px">
458 <strong>Template repository.</strong> Create a new repository from
459 this template's files.
460 </div>
461 <form
462 method="POST"
463 action={`/${owner}/${repo}/use-template`}
464 style="display:flex;gap:8px;align-items:center"
465 >
466 <input
467 type="text"
468 name="name"
469 placeholder="new-repo-name"
470 required
471 style="width:200px"
472 />
473 <button type="submit" class="btn btn-primary">
474 Use this template
475 </button>
476 </form>
477 </div>
478 )}
fc1817aClaude479 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude480 <BranchSwitcher
481 owner={owner}
482 repo={repo}
483 currentRef={defaultBranch}
484 branches={branches}
485 pathType="tree"
486 />
fc1817aClaude487 <FileTable
488 entries={tree}
489 owner={owner}
490 repo={repo}
491 ref={defaultBranch}
492 path=""
493 />
79136bbClaude494 {readme && (() => {
495 const readmeHtml = renderMarkdown(readme);
496 return (
497 <div class="blob-view" style="margin-top: 20px">
498 <div class="blob-header">README.md</div>
499 <style>{markdownCss}</style>
500 <div class="markdown-body">
501 {html([readmeHtml] as unknown as TemplateStringsArray)}
502 </div>
fc1817aClaude503 </div>
79136bbClaude504 );
505 })()}
fc1817aClaude506 </Layout>
507 );
508});
509
510// Browse tree at ref/path
511web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
512 const { owner, repo } = c.req.param();
06d5ffeClaude513 const user = c.get("user");
fc1817aClaude514 const refAndPath = c.req.param("ref");
515
516 const branches = await listBranches(owner, repo);
517 let ref = "";
518 let treePath = "";
519
520 for (const branch of branches) {
521 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
522 ref = branch;
523 treePath = refAndPath.slice(branch.length + 1);
524 break;
525 }
526 }
527
528 if (!ref) {
529 const slashIdx = refAndPath.indexOf("/");
530 if (slashIdx === -1) {
531 ref = refAndPath;
532 } else {
533 ref = refAndPath.slice(0, slashIdx);
534 treePath = refAndPath.slice(slashIdx + 1);
535 }
536 }
537
538 const tree = await getTree(owner, repo, ref, treePath);
539
540 return c.html(
06d5ffeClaude541 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude542 <RepoHeader owner={owner} repo={repo} />
543 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude544 <BranchSwitcher
545 owner={owner}
546 repo={repo}
547 currentRef={ref}
548 branches={branches}
549 pathType="tree"
550 subPath={treePath}
551 />
fc1817aClaude552 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
553 <FileTable
554 entries={tree}
555 owner={owner}
556 repo={repo}
557 ref={ref}
558 path={treePath}
559 />
560 </Layout>
561 );
562});
563
06d5ffeClaude564// View file blob with syntax highlighting
fc1817aClaude565web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
566 const { owner, repo } = c.req.param();
06d5ffeClaude567 const user = c.get("user");
fc1817aClaude568 const refAndPath = c.req.param("ref");
569
570 const branches = await listBranches(owner, repo);
571 let ref = "";
572 let filePath = "";
573
574 for (const branch of branches) {
575 if (refAndPath.startsWith(branch + "/")) {
576 ref = branch;
577 filePath = refAndPath.slice(branch.length + 1);
578 break;
579 }
580 }
581
582 if (!ref) {
583 const slashIdx = refAndPath.indexOf("/");
584 if (slashIdx === -1) return c.text("Not found", 404);
585 ref = refAndPath.slice(0, slashIdx);
586 filePath = refAndPath.slice(slashIdx + 1);
587 }
588
589 const blob = await getBlob(owner, repo, ref, filePath);
590 if (!blob) {
591 return c.html(
06d5ffeClaude592 <Layout title="Not Found" user={user}>
fc1817aClaude593 <div class="empty-state">
594 <h2>File not found</h2>
595 </div>
596 </Layout>,
597 404
598 );
599 }
600
06d5ffeClaude601 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude602
603 return c.html(
06d5ffeClaude604 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude605 <RepoHeader owner={owner} repo={repo} />
606 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude607 <BranchSwitcher
608 owner={owner}
609 repo={repo}
610 currentRef={ref}
611 branches={branches}
612 pathType="blob"
613 subPath={filePath}
614 />
fc1817aClaude615 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
616 <div class="blob-view">
617 <div class="blob-header">
06d5ffeClaude618 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude619 <span style="display: flex; gap: 12px">
620 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
621 Raw
622 </a>
623 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
624 Blame
625 </a>
0074234Claude626 {user && (
627 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
628 Edit
629 </a>
630 )}
79136bbClaude631 </span>
fc1817aClaude632 </div>
633 {blob.isBinary ? (
634 <div style="padding: 16px; color: var(--text-muted)">
635 Binary file not shown.
636 </div>
06d5ffeClaude637 ) : (() => {
638 const { html: highlighted, language } = highlightCode(
639 blob.content,
640 fileName
641 );
642 const lineCount = blob.content.split("\n").length;
643 // Trim trailing newline from count
644 const adjustedCount =
645 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
646
647 if (language) {
648 return (
649 <HighlightedCode
650 highlightedHtml={highlighted}
651 lineCount={adjustedCount}
652 />
653 );
654 }
655 const lines = blob.content.split("\n");
656 if (lines[lines.length - 1] === "") lines.pop();
657 return <PlainCode lines={lines} />;
658 })()}
fc1817aClaude659 </div>
660 </Layout>
661 );
662});
663
664// Commit log
665web.get("/:owner/:repo/commits/:ref?", async (c) => {
666 const { owner, repo } = c.req.param();
06d5ffeClaude667 const user = c.get("user");
fc1817aClaude668 const ref =
669 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude670 const branches = await listBranches(owner, repo);
fc1817aClaude671
672 const commits = await listCommits(owner, repo, ref, 50);
673
3951454Claude674 // Block J3 — batch-fetch cached verification results for the page.
675 let verifications: Record<string, { verified: boolean; reason: string }> = {};
676 try {
677 const [ownerRow] = await db
678 .select()
679 .from(users)
680 .where(eq(users.username, owner))
681 .limit(1);
682 if (ownerRow) {
683 const [repoRow] = await db
684 .select()
685 .from(repositories)
686 .where(
687 and(
688 eq(repositories.ownerId, ownerRow.id),
689 eq(repositories.name, repo)
690 )
691 )
692 .limit(1);
693 if (repoRow && commits.length > 0) {
694 const rows = await db
695 .select()
696 .from(commitVerifications)
697 .where(
698 and(
699 eq(commitVerifications.repositoryId, repoRow.id),
700 inArray(
701 commitVerifications.commitSha,
702 commits.map((c) => c.sha)
703 )
704 )
705 );
706 for (const r of rows) {
707 verifications[r.commitSha] = {
708 verified: r.verified,
709 reason: r.reason,
710 };
711 }
712 }
713 }
714 } catch {
715 // DB unavailable — skip the badges gracefully.
716 }
717
fc1817aClaude718 return c.html(
06d5ffeClaude719 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude720 <RepoHeader owner={owner} repo={repo} />
721 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude722 <BranchSwitcher
723 owner={owner}
724 repo={repo}
725 currentRef={ref}
726 branches={branches}
727 pathType="commits"
728 />
fc1817aClaude729 {commits.length === 0 ? (
730 <div class="empty-state">
731 <p>No commits yet.</p>
732 </div>
733 ) : (
3951454Claude734 <CommitList
735 commits={commits}
736 owner={owner}
737 repo={repo}
738 verifications={verifications}
739 />
fc1817aClaude740 )}
741 </Layout>
742 );
743});
744
745// Single commit with diff
746web.get("/:owner/:repo/commit/:sha", async (c) => {
747 const { owner, repo, sha } = c.req.param();
06d5ffeClaude748 const user = c.get("user");
fc1817aClaude749
05b973eClaude750 // Fetch commit, full message, and diff in parallel
751 const [commit, fullMessage, diffResult] = await Promise.all([
752 getCommit(owner, repo, sha),
753 getCommitFullMessage(owner, repo, sha),
754 getDiff(owner, repo, sha),
755 ]);
fc1817aClaude756 if (!commit) {
757 return c.html(
06d5ffeClaude758 <Layout title="Not Found" user={user}>
fc1817aClaude759 <div class="empty-state">
760 <h2>Commit not found</h2>
761 </div>
762 </Layout>,
763 404
764 );
765 }
766
3951454Claude767 // Block J3 — try to verify this commit's signature.
768 let verification:
769 | { verified: boolean; reason: string; signatureType: string | null }
770 | null = null;
771 try {
772 const [ownerRow] = await db
773 .select()
774 .from(users)
775 .where(eq(users.username, owner))
776 .limit(1);
777 if (ownerRow) {
778 const [repoRow] = await db
779 .select()
780 .from(repositories)
781 .where(
782 and(
783 eq(repositories.ownerId, ownerRow.id),
784 eq(repositories.name, repo)
785 )
786 )
787 .limit(1);
788 if (repoRow) {
789 const { verifyCommit } = await import("../lib/signatures");
790 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
791 verification = {
792 verified: v.verified,
793 reason: v.reason,
794 signatureType: v.signatureType,
795 };
796 }
797 }
798 } catch {
799 verification = null;
800 }
801
05b973eClaude802 const { files, raw } = diffResult;
fc1817aClaude803
804 return c.html(
06d5ffeClaude805 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude806 <RepoHeader owner={owner} repo={repo} />
807 <div
808 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
809 >
810 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
811 {commit.message}
812 </div>
813 {fullMessage !== commit.message && (
814 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
815 {fullMessage}
816 </div>
817 )}
818 <div style="font-size: 13px; color: var(--text-muted)">
819 <strong style="color: var(--text)">{commit.author}</strong>{" "}
820 committed on{" "}
821 {new Date(commit.date).toLocaleDateString("en-US", {
822 month: "long",
823 day: "numeric",
824 year: "numeric",
825 })}
3951454Claude826 {verification && verification.reason !== "unsigned" && (
827 <span
828 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
829 verification.verified
830 ? "var(--green,#2ea043)"
831 : "var(--yellow,#d29922)"
832 }`}
833 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
834 >
835 {verification.verified ? "Verified" : verification.reason}
836 </span>
837 )}
fc1817aClaude838 </div>
839 <div style="margin-top: 8px">
840 <span class="commit-sha">{commit.sha}</span>
841 {commit.parentShas.length > 0 && (
842 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
843 Parent:{" "}
844 {commit.parentShas.map((p) => (
845 <a
846 href={`/${owner}/${repo}/commit/${p}`}
847 class="commit-sha"
848 style="margin-left: 4px"
849 >
850 {p.slice(0, 7)}
851 </a>
852 ))}
853 </span>
854 )}
855 </div>
856 </div>
857 <DiffView raw={raw} files={files} />
858 </Layout>
859 );
860});
861
79136bbClaude862// Raw file download
863web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
864 const { owner, repo } = c.req.param();
865 const refAndPath = c.req.param("ref");
866
867 const branches = await listBranches(owner, repo);
868 let ref = "";
869 let filePath = "";
870
871 for (const branch of branches) {
872 if (refAndPath.startsWith(branch + "/")) {
873 ref = branch;
874 filePath = refAndPath.slice(branch.length + 1);
875 break;
876 }
877 }
878
879 if (!ref) {
880 const slashIdx = refAndPath.indexOf("/");
881 if (slashIdx === -1) return c.text("Not found", 404);
882 ref = refAndPath.slice(0, slashIdx);
883 filePath = refAndPath.slice(slashIdx + 1);
884 }
885
886 const data = await getRawBlob(owner, repo, ref, filePath);
887 if (!data) return c.text("Not found", 404);
888
889 const fileName = filePath.split("/").pop() || "file";
890 return new Response(data, {
891 headers: {
892 "Content-Type": "application/octet-stream",
893 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude894 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude895 },
896 });
897});
898
899// Blame view
900web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
901 const { owner, repo } = c.req.param();
902 const user = c.get("user");
903 const refAndPath = c.req.param("ref");
904
905 const branches = await listBranches(owner, repo);
906 let ref = "";
907 let filePath = "";
908
909 for (const branch of branches) {
910 if (refAndPath.startsWith(branch + "/")) {
911 ref = branch;
912 filePath = refAndPath.slice(branch.length + 1);
913 break;
914 }
915 }
916
917 if (!ref) {
918 const slashIdx = refAndPath.indexOf("/");
919 if (slashIdx === -1) return c.text("Not found", 404);
920 ref = refAndPath.slice(0, slashIdx);
921 filePath = refAndPath.slice(slashIdx + 1);
922 }
923
924 const blameLines = await getBlame(owner, repo, ref, filePath);
925 if (blameLines.length === 0) {
926 return c.html(
927 <Layout title="Not Found" user={user}>
928 <div class="empty-state">
929 <h2>File not found</h2>
930 </div>
931 </Layout>,
932 404
933 );
934 }
935
936 const fileName = filePath.split("/").pop() || filePath;
937
938 return c.html(
939 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
940 <RepoHeader owner={owner} repo={repo} />
941 <RepoNav owner={owner} repo={repo} active="code" />
942 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
943 <div class="blob-view">
944 <div class="blob-header">
945 <span>{fileName} — blame</span>
946 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
947 Normal view
948 </a>
949 </div>
950 <div class="blob-code" style="overflow-x: auto">
951 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
952 <tbody>
953 {blameLines.map((line, i) => {
954 const showInfo =
955 i === 0 || blameLines[i - 1].sha !== line.sha;
956 return (
957 <tr style="border-bottom: 1px solid var(--border)">
958 <td
959 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)" : ""}`}
960 >
961 {showInfo && (
962 <>
963 <a
964 href={`/${owner}/${repo}/commit/${line.sha}`}
965 style="color: var(--text-link); font-family: var(--font-mono)"
966 >
967 {line.sha.slice(0, 7)}
968 </a>{" "}
969 <span>{line.author}</span>
970 </>
971 )}
972 </td>
973 <td class="line-num">{line.lineNum}</td>
974 <td class="line-content">{line.content}</td>
975 </tr>
976 );
977 })}
978 </tbody>
979 </table>
980 </div>
981 </div>
982 </Layout>
983 );
984});
985
986// Search
987web.get("/:owner/:repo/search", async (c) => {
988 const { owner, repo } = c.req.param();
989 const user = c.get("user");
990 const q = c.req.query("q") || "";
991
992 if (!(await repoExists(owner, repo))) return c.notFound();
993
994 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
995 let results: Array<{ file: string; lineNum: number; line: string }> = [];
996
997 if (q.trim()) {
998 results = await searchCode(owner, repo, defaultBranch, q.trim());
999 }
1000
1001 return c.html(
1002 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1003 <RepoHeader owner={owner} repo={repo} />
1004 <RepoNav owner={owner} repo={repo} active="code" />
1005 <form
1006 method="GET"
1007 action={`/${owner}/${repo}/search`}
1008 style="margin-bottom: 20px"
1009 >
1010 <div style="display: flex; gap: 8px">
1011 <input
1012 type="text"
1013 name="q"
1014 value={q}
1015 placeholder="Search code..."
1016 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1017 />
1018 <button type="submit" class="btn btn-primary">
1019 Search
1020 </button>
1021 </div>
1022 </form>
1023 {q && (
1024 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1025 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1026 <strong style="color: var(--text)">"{q}"</strong>
1027 </p>
1028 )}
1029 {results.length > 0 && (
1030 <div class="search-results">
1031 {(() => {
1032 // Group by file
1033 const grouped: Record<
1034 string,
1035 Array<{ lineNum: number; line: string }>
1036 > = {};
1037 for (const r of results) {
1038 if (!grouped[r.file]) grouped[r.file] = [];
1039 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1040 }
1041 return Object.entries(grouped).map(([file, matches]) => (
1042 <div class="diff-file" style="margin-bottom: 12px">
1043 <div class="diff-file-header">
1044 <a
1045 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1046 >
1047 {file}
1048 </a>
1049 </div>
1050 <div class="blob-code">
1051 <table>
1052 <tbody>
1053 {matches.map((m) => (
1054 <tr>
1055 <td class="line-num">{m.lineNum}</td>
1056 <td class="line-content">{m.line}</td>
1057 </tr>
1058 ))}
1059 </tbody>
1060 </table>
1061 </div>
1062 </div>
1063 ));
1064 })()}
1065 </div>
1066 )}
1067 </Layout>
1068 );
1069});
1070
fc1817aClaude1071export default web;