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.tsxBlame1384 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
8ec29ffClaude277 // Block J13 — pinned repositories. Viewer-aware (private repos filtered
278 // to owner in the lib helper). Always safe on DB failure.
279 let pinnedRepos: Awaited<
280 ReturnType<typeof import("../lib/pinned-repos").listPinnedForUser>
281 > = [];
282 if (ownerUser) {
283 try {
284 const { listPinnedForUser } = await import("../lib/pinned-repos");
285 const all = await listPinnedForUser(ownerUser.id);
286 pinnedRepos = all.filter(
287 (p) => !p.isPrivate || user?.id === ownerUser.id
288 );
289 } catch {
290 pinnedRepos = [];
291 }
292 }
293
4438e31Claude294 // Block J9 — contribution heatmap. 52-week activity grid sourced from
295 // activity_feed rows authored by this user. Failures fall through silently.
296 let heatmap: Awaited<
297 ReturnType<
298 typeof import("../lib/contribution-heatmap").buildHeatmap
299 >
300 > | null = null;
301 if (ownerUser) {
302 try {
303 const since = new Date();
304 since.setUTCDate(since.getUTCDate() - 365);
305 const activities = await db
306 .select({ createdAt: activityFeed.createdAt })
307 .from(activityFeed)
308 .where(
309 and(
310 eq(activityFeed.userId, ownerUser.id),
311 gte(activityFeed.createdAt, since)
312 )
313 );
314 const { buildHeatmap } = await import("../lib/contribution-heatmap");
315 heatmap = buildHeatmap(activities, 365);
316 } catch {
317 heatmap = null;
318 }
319 }
320
fc1817aClaude321 return c.html(
06d5ffeClaude322 <Layout title={ownerName} user={user}>
323 <div class="user-profile">
324 <div class="user-avatar">
325 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
326 </div>
327 <div class="user-info">
328 <h2>{ownerUser?.displayName || ownerName}</h2>
329 <div class="username">@{ownerName}</div>
330 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
7aa8b99Claude331 <div
332 style="margin-top:8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13px"
333 >
334 <a
335 href={`/${ownerName}/followers`}
336 style="color:var(--text-muted)"
337 >
338 <strong style="color:var(--text)">
339 {followState.followers}
340 </strong>{" "}
341 follower{followState.followers === 1 ? "" : "s"}
342 </a>
343 <a
344 href={`/${ownerName}/following`}
345 style="color:var(--text-muted)"
346 >
347 <strong style="color:var(--text)">
348 {followState.following}
349 </strong>{" "}
350 following
351 </a>
352 {canFollow && (
353 <form
354 method="POST"
355 action={`/${ownerName}/${
356 followState.viewerFollows ? "unfollow" : "follow"
357 }`}
358 >
359 <button
360 type="submit"
361 class={`btn ${
362 followState.viewerFollows ? "" : "btn-primary"
363 } btn-sm`}
364 >
365 {followState.viewerFollows ? "Unfollow" : "Follow"}
366 </button>
367 </form>
368 )}
369 </div>
06d5ffeClaude370 </div>
371 </div>
4438e31Claude372 {heatmap && heatmap.totalContributions > 0 && (
373 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin-bottom:20px">
374 <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
375 <strong>
376 {heatmap.totalContributions} contributions in the last year
377 </strong>
378 <span style="color:var(--text-muted);font-size:12px">
379 Longest streak {heatmap.longestStreak}d · Current{" "}
380 {heatmap.currentStreak}d
381 </span>
382 </div>
383 <div style="display:flex;gap:3px;overflow-x:auto;padding-bottom:4px">
384 {heatmap.weeks.map((w) => (
385 <div style="display:flex;flex-direction:column;gap:3px">
386 {w.days.map((d) =>
387 d ? (
388 <div
389 title={`${d.date}: ${d.count} contribution${d.count === 1 ? "" : "s"}`}
390 style={`width:11px;height:11px;border-radius:2px;background:${
391 d.level === 0
392 ? "var(--border)"
393 : d.level === 1
394 ? "#0e4429"
395 : d.level === 2
396 ? "#006d32"
397 : d.level === 3
398 ? "#26a641"
399 : "#39d353"
400 }`}
401 />
402 ) : (
403 <div style="width:11px;height:11px" />
404 )
405 )}
406 </div>
407 ))}
408 </div>
409 <div style="margin-top:8px;display:flex;gap:4px;align-items:center;justify-content:flex-end;font-size:11px;color:var(--text-muted)">
410 <span>Less</span>
411 {[0, 1, 2, 3, 4].map((l) => (
412 <div
413 style={`width:11px;height:11px;border-radius:2px;background:${
414 l === 0
415 ? "var(--border)"
416 : l === 1
417 ? "#0e4429"
418 : l === 2
419 ? "#006d32"
420 : l === 3
421 ? "#26a641"
422 : "#39d353"
423 }`}
424 />
425 ))}
426 <span>More</span>
427 </div>
428 </div>
429 )}
d412586Claude430 {profileReadmeHtml && (
431 <div
432 class="markdown-body"
433 style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:24px"
434 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
435 />
436 )}
8ec29ffClaude437 {pinnedRepos.length > 0 && (
438 <div style="margin-bottom: 24px" data-testid="pinned-repos">
439 <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px">
440 <h3 style="margin: 0">Pinned</h3>
441 {user && ownerUser && user.id === ownerUser.id && (
442 <a href="/settings/pins" style="font-size: 12px; color: var(--text-muted)">
443 Customize pins
444 </a>
445 )}
446 </div>
447 <div
448 style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px"
449 >
450 {pinnedRepos.map((p) => (
451 <div
452 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; background: var(--bg-secondary)"
453 >
454 <div style="display: flex; align-items: center; gap: 6px">
455 <a href={`/${p.ownerUsername}/${p.name}`}>
456 <strong>{p.name}</strong>
457 </a>
458 {p.isPrivate && (
459 <span
460 style="font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: var(--text-muted); text-transform: uppercase"
461 >
462 Private
463 </span>
464 )}
465 </div>
466 {p.description && (
467 <div
468 style="color: var(--text-muted); font-size: 12px; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
469 >
470 {p.description}
471 </div>
472 )}
473 <div style="margin-top: 8px; font-size: 11px; color: var(--text-muted)">
474 {p.starCount} {"\u2605"} · {p.forkCount} forks
475 </div>
476 </div>
477 ))}
478 </div>
479 </div>
480 )}
06d5ffeClaude481 <h3 style="margin-bottom: 16px">Repositories</h3>
482 {repos.length === 0 ? (
483 <p style="color: var(--text-muted)">No repositories yet.</p>
484 ) : (
485 <div class="card-grid">
486 {repos.map((repo) => (
487 <RepoCard repo={repo} ownerName={ownerName} />
488 ))}
489 </div>
490 )}
fc1817aClaude491 </Layout>
492 );
493});
494
06d5ffeClaude495// Star/unstar a repo
496web.post("/:owner/:repo/star", requireAuth, async (c) => {
497 const { owner: ownerName, repo: repoName } = c.req.param();
498 const user = c.get("user")!;
499
500 try {
501 const [ownerUser] = await db
502 .select()
503 .from(users)
504 .where(eq(users.username, ownerName))
505 .limit(1);
506 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
507
508 const [repo] = await db
509 .select()
510 .from(repositories)
511 .where(
512 and(
513 eq(repositories.ownerId, ownerUser.id),
514 eq(repositories.name, repoName)
515 )
516 )
517 .limit(1);
518 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
519
520 // Toggle star
521 const [existing] = await db
522 .select()
523 .from(stars)
524 .where(
525 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
526 )
527 .limit(1);
528
529 if (existing) {
530 await db.delete(stars).where(eq(stars.id, existing.id));
531 await db
532 .update(repositories)
533 .set({ starCount: Math.max(0, repo.starCount - 1) })
534 .where(eq(repositories.id, repo.id));
535 } else {
536 await db.insert(stars).values({
537 userId: user.id,
538 repositoryId: repo.id,
539 });
540 await db
541 .update(repositories)
542 .set({ starCount: repo.starCount + 1 })
543 .where(eq(repositories.id, repo.id));
544 }
545 } catch {
546 // DB error — ignore
547 }
548
549 return c.redirect(`/${ownerName}/${repoName}`);
550});
551
fc1817aClaude552// Repository overview — file tree at HEAD
553web.get("/:owner/:repo", async (c) => {
554 const { owner, repo } = c.req.param();
06d5ffeClaude555 const user = c.get("user");
fc1817aClaude556
8f50ed0Claude557 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
558 trackByName(owner, repo, "view", {
559 userId: user?.id || null,
560 path: `/${owner}/${repo}`,
561 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
562 userAgent: c.req.header("user-agent") || null,
563 referer: c.req.header("referer") || null,
564 }).catch(() => {});
565
fc1817aClaude566 if (!(await repoExists(owner, repo))) {
567 return c.html(
06d5ffeClaude568 <Layout title="Not Found" user={user}>
fc1817aClaude569 <div class="empty-state">
570 <h2>Repository not found</h2>
571 <p>
572 {owner}/{repo} does not exist.
573 </p>
574 </div>
575 </Layout>,
576 404
577 );
578 }
579
05b973eClaude580 // Parallelize all independent operations
581 const [defaultBranch, branches] = await Promise.all([
582 getDefaultBranch(owner, repo).then((b) => b || "main"),
583 listBranches(owner, repo),
584 ]);
585 const [tree, starInfo] = await Promise.all([
586 getTree(owner, repo, defaultBranch),
587 // Star info fetched in parallel with tree
588 (async () => {
589 try {
590 const [ownerUser] = await db
591 .select()
592 .from(users)
593 .where(eq(users.username, owner))
594 .limit(1);
71cd5ecClaude595 if (!ownerUser)
596 return {
597 starCount: 0,
598 starred: false,
599 archived: false,
600 isTemplate: false,
601 };
05b973eClaude602 const [repoRow] = await db
603 .select()
604 .from(repositories)
605 .where(
606 and(
607 eq(repositories.ownerId, ownerUser.id),
608 eq(repositories.name, repo)
609 )
06d5ffeClaude610 )
05b973eClaude611 .limit(1);
71cd5ecClaude612 if (!repoRow)
613 return {
614 starCount: 0,
615 starred: false,
616 archived: false,
617 isTemplate: false,
618 };
05b973eClaude619 let starred = false;
06d5ffeClaude620 if (user) {
621 const [star] = await db
622 .select()
623 .from(stars)
624 .where(
625 and(
626 eq(stars.userId, user.id),
627 eq(stars.repositoryId, repoRow.id)
628 )
629 )
630 .limit(1);
631 starred = !!star;
632 }
71cd5ecClaude633 return {
634 starCount: repoRow.starCount,
635 starred,
636 archived: repoRow.isArchived,
637 isTemplate: repoRow.isTemplate,
638 };
05b973eClaude639 } catch {
71cd5ecClaude640 return {
641 starCount: 0,
642 starred: false,
643 archived: false,
644 isTemplate: false,
645 };
06d5ffeClaude646 }
05b973eClaude647 })(),
648 ]);
71cd5ecClaude649 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude650
fc1817aClaude651 if (tree.length === 0) {
652 return c.html(
06d5ffeClaude653 <Layout title={`${owner}/${repo}`} user={user}>
654 <RepoHeader
655 owner={owner}
656 repo={repo}
657 starCount={starCount}
658 starred={starred}
659 currentUser={user?.username}
71cd5ecClaude660 archived={archived}
661 isTemplate={isTemplate}
06d5ffeClaude662 />
fc1817aClaude663 <RepoNav owner={owner} repo={repo} active="code" />
664 <div class="empty-state">
665 <h2>Empty repository</h2>
666 <p>Get started by pushing code:</p>
667 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
668git push -u gluecron main`}</pre>
669 </div>
670 </Layout>
671 );
672 }
673
674 const readme = await getReadme(owner, repo, defaultBranch);
675
676 return c.html(
06d5ffeClaude677 <Layout title={`${owner}/${repo}`} user={user}>
678 <RepoHeader
679 owner={owner}
680 repo={repo}
681 starCount={starCount}
682 starred={starred}
683 currentUser={user?.username}
71cd5ecClaude684 archived={archived}
685 isTemplate={isTemplate}
06d5ffeClaude686 />
71cd5ecClaude687 {isTemplate && user && user.username !== owner && (
688 <div
689 class="panel"
690 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
691 >
692 <div style="font-size:13px">
693 <strong>Template repository.</strong> Create a new repository from
694 this template's files.
695 </div>
696 <form
697 method="POST"
698 action={`/${owner}/${repo}/use-template`}
699 style="display:flex;gap:8px;align-items:center"
700 >
701 <input
702 type="text"
703 name="name"
704 placeholder="new-repo-name"
705 required
706 style="width:200px"
707 />
708 <button type="submit" class="btn btn-primary">
709 Use this template
710 </button>
711 </form>
712 </div>
713 )}
fc1817aClaude714 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude715 <BranchSwitcher
716 owner={owner}
717 repo={repo}
718 currentRef={defaultBranch}
719 branches={branches}
720 pathType="tree"
721 />
fc1817aClaude722 <FileTable
723 entries={tree}
724 owner={owner}
725 repo={repo}
726 ref={defaultBranch}
727 path=""
728 />
79136bbClaude729 {readme && (() => {
730 const readmeHtml = renderMarkdown(readme);
731 return (
732 <div class="blob-view" style="margin-top: 20px">
733 <div class="blob-header">README.md</div>
734 <style>{markdownCss}</style>
735 <div class="markdown-body">
736 {html([readmeHtml] as unknown as TemplateStringsArray)}
737 </div>
fc1817aClaude738 </div>
79136bbClaude739 );
740 })()}
fc1817aClaude741 </Layout>
742 );
743});
744
745// Browse tree at ref/path
746web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
747 const { owner, repo } = c.req.param();
06d5ffeClaude748 const user = c.get("user");
fc1817aClaude749 const refAndPath = c.req.param("ref");
750
751 const branches = await listBranches(owner, repo);
752 let ref = "";
753 let treePath = "";
754
755 for (const branch of branches) {
756 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
757 ref = branch;
758 treePath = refAndPath.slice(branch.length + 1);
759 break;
760 }
761 }
762
763 if (!ref) {
764 const slashIdx = refAndPath.indexOf("/");
765 if (slashIdx === -1) {
766 ref = refAndPath;
767 } else {
768 ref = refAndPath.slice(0, slashIdx);
769 treePath = refAndPath.slice(slashIdx + 1);
770 }
771 }
772
773 const tree = await getTree(owner, repo, ref, treePath);
774
775 return c.html(
06d5ffeClaude776 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude777 <RepoHeader owner={owner} repo={repo} />
778 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude779 <BranchSwitcher
780 owner={owner}
781 repo={repo}
782 currentRef={ref}
783 branches={branches}
784 pathType="tree"
785 subPath={treePath}
786 />
fc1817aClaude787 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
788 <FileTable
789 entries={tree}
790 owner={owner}
791 repo={repo}
792 ref={ref}
793 path={treePath}
794 />
795 </Layout>
796 );
797});
798
06d5ffeClaude799// View file blob with syntax highlighting
fc1817aClaude800web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
801 const { owner, repo } = c.req.param();
06d5ffeClaude802 const user = c.get("user");
fc1817aClaude803 const refAndPath = c.req.param("ref");
804
805 const branches = await listBranches(owner, repo);
806 let ref = "";
807 let filePath = "";
808
809 for (const branch of branches) {
810 if (refAndPath.startsWith(branch + "/")) {
811 ref = branch;
812 filePath = refAndPath.slice(branch.length + 1);
813 break;
814 }
815 }
816
817 if (!ref) {
818 const slashIdx = refAndPath.indexOf("/");
819 if (slashIdx === -1) return c.text("Not found", 404);
820 ref = refAndPath.slice(0, slashIdx);
821 filePath = refAndPath.slice(slashIdx + 1);
822 }
823
824 const blob = await getBlob(owner, repo, ref, filePath);
825 if (!blob) {
826 return c.html(
06d5ffeClaude827 <Layout title="Not Found" user={user}>
fc1817aClaude828 <div class="empty-state">
829 <h2>File not found</h2>
830 </div>
831 </Layout>,
832 404
833 );
834 }
835
06d5ffeClaude836 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude837
838 return c.html(
06d5ffeClaude839 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude840 <RepoHeader owner={owner} repo={repo} />
841 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude842 <BranchSwitcher
843 owner={owner}
844 repo={repo}
845 currentRef={ref}
846 branches={branches}
847 pathType="blob"
848 subPath={filePath}
849 />
fc1817aClaude850 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
851 <div class="blob-view">
852 <div class="blob-header">
06d5ffeClaude853 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude854 <span style="display: flex; gap: 12px">
855 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
856 Raw
857 </a>
858 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
859 Blame
860 </a>
0074234Claude861 {user && (
862 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
863 Edit
864 </a>
865 )}
79136bbClaude866 </span>
fc1817aClaude867 </div>
868 {blob.isBinary ? (
869 <div style="padding: 16px; color: var(--text-muted)">
870 Binary file not shown.
871 </div>
06d5ffeClaude872 ) : (() => {
873 const { html: highlighted, language } = highlightCode(
874 blob.content,
875 fileName
876 );
877 const lineCount = blob.content.split("\n").length;
878 // Trim trailing newline from count
879 const adjustedCount =
880 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
881
882 if (language) {
883 return (
884 <HighlightedCode
885 highlightedHtml={highlighted}
886 lineCount={adjustedCount}
887 />
888 );
889 }
890 const lines = blob.content.split("\n");
891 if (lines[lines.length - 1] === "") lines.pop();
892 return <PlainCode lines={lines} />;
893 })()}
fc1817aClaude894 </div>
895 </Layout>
896 );
897});
898
899// Commit log
900web.get("/:owner/:repo/commits/:ref?", async (c) => {
901 const { owner, repo } = c.req.param();
06d5ffeClaude902 const user = c.get("user");
fc1817aClaude903 const ref =
904 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude905 const branches = await listBranches(owner, repo);
fc1817aClaude906
907 const commits = await listCommits(owner, repo, ref, 50);
908
3951454Claude909 // Block J3 — batch-fetch cached verification results for the page.
910 let verifications: Record<string, { verified: boolean; reason: string }> = {};
911 try {
912 const [ownerRow] = await db
913 .select()
914 .from(users)
915 .where(eq(users.username, owner))
916 .limit(1);
917 if (ownerRow) {
918 const [repoRow] = await db
919 .select()
920 .from(repositories)
921 .where(
922 and(
923 eq(repositories.ownerId, ownerRow.id),
924 eq(repositories.name, repo)
925 )
926 )
927 .limit(1);
928 if (repoRow && commits.length > 0) {
929 const rows = await db
930 .select()
931 .from(commitVerifications)
932 .where(
933 and(
934 eq(commitVerifications.repositoryId, repoRow.id),
935 inArray(
936 commitVerifications.commitSha,
937 commits.map((c) => c.sha)
938 )
939 )
940 );
941 for (const r of rows) {
942 verifications[r.commitSha] = {
943 verified: r.verified,
944 reason: r.reason,
945 };
946 }
947 }
948 }
949 } catch {
950 // DB unavailable — skip the badges gracefully.
951 }
952
fc1817aClaude953 return c.html(
06d5ffeClaude954 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude955 <RepoHeader owner={owner} repo={repo} />
956 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude957 <BranchSwitcher
958 owner={owner}
959 repo={repo}
960 currentRef={ref}
961 branches={branches}
962 pathType="commits"
963 />
fc1817aClaude964 {commits.length === 0 ? (
965 <div class="empty-state">
966 <p>No commits yet.</p>
967 </div>
968 ) : (
3951454Claude969 <CommitList
970 commits={commits}
971 owner={owner}
972 repo={repo}
973 verifications={verifications}
974 />
fc1817aClaude975 )}
976 </Layout>
977 );
978});
979
980// Single commit with diff
981web.get("/:owner/:repo/commit/:sha", async (c) => {
982 const { owner, repo, sha } = c.req.param();
06d5ffeClaude983 const user = c.get("user");
fc1817aClaude984
05b973eClaude985 // Fetch commit, full message, and diff in parallel
986 const [commit, fullMessage, diffResult] = await Promise.all([
987 getCommit(owner, repo, sha),
988 getCommitFullMessage(owner, repo, sha),
989 getDiff(owner, repo, sha),
990 ]);
fc1817aClaude991 if (!commit) {
992 return c.html(
06d5ffeClaude993 <Layout title="Not Found" user={user}>
fc1817aClaude994 <div class="empty-state">
995 <h2>Commit not found</h2>
996 </div>
997 </Layout>,
998 404
999 );
1000 }
1001
3951454Claude1002 // Block J3 — try to verify this commit's signature.
1003 let verification:
1004 | { verified: boolean; reason: string; signatureType: string | null }
1005 | null = null;
0cdfd89Claude1006 // Block J8 — external CI commit statuses rollup.
1007 let statusCombined:
1008 | {
1009 state: "pending" | "success" | "failure";
1010 total: number;
1011 contexts: Array<{
1012 context: string;
1013 state: string;
1014 description: string | null;
1015 targetUrl: string | null;
1016 }>;
1017 }
1018 | null = null;
3951454Claude1019 try {
1020 const [ownerRow] = await db
1021 .select()
1022 .from(users)
1023 .where(eq(users.username, owner))
1024 .limit(1);
1025 if (ownerRow) {
1026 const [repoRow] = await db
1027 .select()
1028 .from(repositories)
1029 .where(
1030 and(
1031 eq(repositories.ownerId, ownerRow.id),
1032 eq(repositories.name, repo)
1033 )
1034 )
1035 .limit(1);
1036 if (repoRow) {
1037 const { verifyCommit } = await import("../lib/signatures");
1038 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
1039 verification = {
1040 verified: v.verified,
1041 reason: v.reason,
1042 signatureType: v.signatureType,
1043 };
0cdfd89Claude1044 try {
1045 const { combinedStatus } = await import("../lib/commit-statuses");
1046 const combined = await combinedStatus(repoRow.id, commit.sha);
1047 if (combined.total > 0) {
1048 statusCombined = {
1049 state: combined.state as any,
1050 total: combined.total,
1051 contexts: combined.contexts.map((c) => ({
1052 context: c.context,
1053 state: c.state,
1054 description: c.description,
1055 targetUrl: c.targetUrl,
1056 })),
1057 };
1058 }
1059 } catch {
1060 statusCombined = null;
1061 }
3951454Claude1062 }
1063 }
1064 } catch {
1065 verification = null;
1066 }
1067
05b973eClaude1068 const { files, raw } = diffResult;
fc1817aClaude1069
1070 return c.html(
06d5ffeClaude1071 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude1072 <RepoHeader owner={owner} repo={repo} />
1073 <div
1074 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
1075 >
1076 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
1077 {commit.message}
1078 </div>
1079 {fullMessage !== commit.message && (
1080 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
1081 {fullMessage}
1082 </div>
1083 )}
1084 <div style="font-size: 13px; color: var(--text-muted)">
1085 <strong style="color: var(--text)">{commit.author}</strong>{" "}
1086 committed on{" "}
1087 {new Date(commit.date).toLocaleDateString("en-US", {
1088 month: "long",
1089 day: "numeric",
1090 year: "numeric",
1091 })}
3951454Claude1092 {verification && verification.reason !== "unsigned" && (
1093 <span
1094 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
1095 verification.verified
1096 ? "var(--green,#2ea043)"
1097 : "var(--yellow,#d29922)"
1098 }`}
1099 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
1100 >
1101 {verification.verified ? "Verified" : verification.reason}
1102 </span>
1103 )}
fc1817aClaude1104 </div>
1105 <div style="margin-top: 8px">
1106 <span class="commit-sha">{commit.sha}</span>
1107 {commit.parentShas.length > 0 && (
1108 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
1109 Parent:{" "}
1110 {commit.parentShas.map((p) => (
1111 <a
1112 href={`/${owner}/${repo}/commit/${p}`}
1113 class="commit-sha"
1114 style="margin-left: 4px"
1115 >
1116 {p.slice(0, 7)}
1117 </a>
1118 ))}
1119 </span>
1120 )}
1121 </div>
0cdfd89Claude1122 {statusCombined && (
1123 <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 13px">
1124 <strong style="color: var(--text)">Checks</strong>
1125 <span style="margin-left: 8px; color: var(--text-muted)">
1126 {statusCombined.total} total —{" "}
1127 <span
1128 style={`color:${
1129 statusCombined.state === "success"
1130 ? "var(--green,#2ea043)"
1131 : statusCombined.state === "failure"
1132 ? "var(--red,#da3633)"
1133 : "var(--yellow,#d29922)"
1134 }`}
1135 >
1136 {statusCombined.state}
1137 </span>
1138 </span>
1139 <div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px">
1140 {statusCombined.contexts.map((cx) => (
1141 <span
1142 style={`font-size:11px;padding:2px 6px;border-radius:3px;color:#fff;background:${
1143 cx.state === "success"
1144 ? "var(--green,#2ea043)"
1145 : cx.state === "pending"
1146 ? "var(--yellow,#d29922)"
1147 : "var(--red,#da3633)"
1148 }`}
1149 title={cx.description || cx.context}
1150 >
1151 {cx.targetUrl ? (
1152 <a
1153 href={cx.targetUrl}
1154 style="color: inherit; text-decoration: none"
1155 rel="noopener"
1156 >
1157 {cx.context}: {cx.state}
1158 </a>
1159 ) : (
1160 <>
1161 {cx.context}: {cx.state}
1162 </>
1163 )}
1164 </span>
1165 ))}
1166 </div>
1167 </div>
1168 )}
fc1817aClaude1169 </div>
1170 <DiffView raw={raw} files={files} />
1171 </Layout>
1172 );
1173});
1174
79136bbClaude1175// Raw file download
1176web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
1177 const { owner, repo } = c.req.param();
1178 const refAndPath = c.req.param("ref");
1179
1180 const branches = await listBranches(owner, repo);
1181 let ref = "";
1182 let filePath = "";
1183
1184 for (const branch of branches) {
1185 if (refAndPath.startsWith(branch + "/")) {
1186 ref = branch;
1187 filePath = refAndPath.slice(branch.length + 1);
1188 break;
1189 }
1190 }
1191
1192 if (!ref) {
1193 const slashIdx = refAndPath.indexOf("/");
1194 if (slashIdx === -1) return c.text("Not found", 404);
1195 ref = refAndPath.slice(0, slashIdx);
1196 filePath = refAndPath.slice(slashIdx + 1);
1197 }
1198
1199 const data = await getRawBlob(owner, repo, ref, filePath);
1200 if (!data) return c.text("Not found", 404);
1201
1202 const fileName = filePath.split("/").pop() || "file";
1203 return new Response(data, {
1204 headers: {
1205 "Content-Type": "application/octet-stream",
1206 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude1207 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude1208 },
1209 });
1210});
1211
1212// Blame view
1213web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
1214 const { owner, repo } = c.req.param();
1215 const user = c.get("user");
1216 const refAndPath = c.req.param("ref");
1217
1218 const branches = await listBranches(owner, repo);
1219 let ref = "";
1220 let filePath = "";
1221
1222 for (const branch of branches) {
1223 if (refAndPath.startsWith(branch + "/")) {
1224 ref = branch;
1225 filePath = refAndPath.slice(branch.length + 1);
1226 break;
1227 }
1228 }
1229
1230 if (!ref) {
1231 const slashIdx = refAndPath.indexOf("/");
1232 if (slashIdx === -1) return c.text("Not found", 404);
1233 ref = refAndPath.slice(0, slashIdx);
1234 filePath = refAndPath.slice(slashIdx + 1);
1235 }
1236
1237 const blameLines = await getBlame(owner, repo, ref, filePath);
1238 if (blameLines.length === 0) {
1239 return c.html(
1240 <Layout title="Not Found" user={user}>
1241 <div class="empty-state">
1242 <h2>File not found</h2>
1243 </div>
1244 </Layout>,
1245 404
1246 );
1247 }
1248
1249 const fileName = filePath.split("/").pop() || filePath;
1250
1251 return c.html(
1252 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
1253 <RepoHeader owner={owner} repo={repo} />
1254 <RepoNav owner={owner} repo={repo} active="code" />
1255 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
1256 <div class="blob-view">
1257 <div class="blob-header">
1258 <span>{fileName} — blame</span>
1259 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
1260 Normal view
1261 </a>
1262 </div>
1263 <div class="blob-code" style="overflow-x: auto">
1264 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
1265 <tbody>
1266 {blameLines.map((line, i) => {
1267 const showInfo =
1268 i === 0 || blameLines[i - 1].sha !== line.sha;
1269 return (
1270 <tr style="border-bottom: 1px solid var(--border)">
1271 <td
1272 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)" : ""}`}
1273 >
1274 {showInfo && (
1275 <>
1276 <a
1277 href={`/${owner}/${repo}/commit/${line.sha}`}
1278 style="color: var(--text-link); font-family: var(--font-mono)"
1279 >
1280 {line.sha.slice(0, 7)}
1281 </a>{" "}
1282 <span>{line.author}</span>
1283 </>
1284 )}
1285 </td>
1286 <td class="line-num">{line.lineNum}</td>
1287 <td class="line-content">{line.content}</td>
1288 </tr>
1289 );
1290 })}
1291 </tbody>
1292 </table>
1293 </div>
1294 </div>
1295 </Layout>
1296 );
1297});
1298
1299// Search
1300web.get("/:owner/:repo/search", async (c) => {
1301 const { owner, repo } = c.req.param();
1302 const user = c.get("user");
1303 const q = c.req.query("q") || "";
1304
1305 if (!(await repoExists(owner, repo))) return c.notFound();
1306
1307 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
1308 let results: Array<{ file: string; lineNum: number; line: string }> = [];
1309
1310 if (q.trim()) {
1311 results = await searchCode(owner, repo, defaultBranch, q.trim());
1312 }
1313
1314 return c.html(
1315 <Layout title={`Search — ${owner}/${repo}`} user={user}>
1316 <RepoHeader owner={owner} repo={repo} />
1317 <RepoNav owner={owner} repo={repo} active="code" />
1318 <form
1319 method="GET"
1320 action={`/${owner}/${repo}/search`}
1321 style="margin-bottom: 20px"
1322 >
1323 <div style="display: flex; gap: 8px">
1324 <input
1325 type="text"
1326 name="q"
1327 value={q}
1328 placeholder="Search code..."
1329 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
1330 />
1331 <button type="submit" class="btn btn-primary">
1332 Search
1333 </button>
1334 </div>
1335 </form>
1336 {q && (
1337 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
1338 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
1339 <strong style="color: var(--text)">"{q}"</strong>
1340 </p>
1341 )}
1342 {results.length > 0 && (
1343 <div class="search-results">
1344 {(() => {
1345 // Group by file
1346 const grouped: Record<
1347 string,
1348 Array<{ lineNum: number; line: string }>
1349 > = {};
1350 for (const r of results) {
1351 if (!grouped[r.file]) grouped[r.file] = [];
1352 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
1353 }
1354 return Object.entries(grouped).map(([file, matches]) => (
1355 <div class="diff-file" style="margin-bottom: 12px">
1356 <div class="diff-file-header">
1357 <a
1358 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
1359 >
1360 {file}
1361 </a>
1362 </div>
1363 <div class="blob-code">
1364 <table>
1365 <tbody>
1366 {matches.map((m) => (
1367 <tr>
1368 <td class="line-num">{m.lineNum}</td>
1369 <td class="line-content">{m.line}</td>
1370 </tr>
1371 ))}
1372 </tbody>
1373 </table>
1374 </div>
1375 </div>
1376 ));
1377 })()}
1378 </div>
1379 )}
1380 </Layout>
1381 );
1382});
1383
fc1817aClaude1384export default web;