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.tsxBlame970 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";
06d5ffeClaude8import { eq, and, desc } from "drizzle-orm";
9import { db } from "../db";
10import { users, repositories, stars } from "../db/schema";
fc1817aClaude11import { Layout } from "../views/layout";
12import {
13 RepoHeader,
14 RepoNav,
15 Breadcrumb,
16 FileTable,
17 CommitList,
18 DiffView,
06d5ffeClaude19 RepoCard,
20 BranchSwitcher,
21 HighlightedCode,
22 PlainCode,
fc1817aClaude23} from "../views/components";
24import {
25 getTree,
26 getBlob,
27 listCommits,
28 getCommit,
29 getCommitFullMessage,
30 getDiff,
31 getReadme,
32 getDefaultBranch,
33 listBranches,
34 repoExists,
06d5ffeClaude35 initBareRepo,
79136bbClaude36 getBlame,
37 getRawBlob,
38 searchCode,
fc1817aClaude39} from "../git/repository";
79136bbClaude40import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude41import { highlightCode } from "../lib/highlight";
42import { softAuth, requireAuth } from "../middleware/auth";
43import type { AuthEnv } from "../middleware/auth";
8f50ed0Claude44import { trackByName } from "../lib/traffic";
fc1817aClaude45
06d5ffeClaude46const web = new Hono<AuthEnv>();
47
48// Soft auth on all web routes — c.get("user") available but may be null
49web.use("*", softAuth);
fc1817aClaude50
51// Home page
06d5ffeClaude52web.get("/", async (c) => {
53 const user = c.get("user");
54
55 if (user) {
3ef4c9dClaude56 const { renderDashboard } = await import("./dashboard");
57 return renderDashboard(c);
06d5ffeClaude58 }
59
fc1817aClaude60 return c.html(
06d5ffeClaude61 <Layout user={null}>
fc1817aClaude62 <div class="empty-state">
63 <h2>gluecron</h2>
64 <p>AI-native code intelligence platform</p>
06d5ffeClaude65 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
66 <a href="/register" class="btn btn-primary">
67 Get started
68 </a>
69 <a href="/login" class="btn">
70 Sign in
71 </a>
72 </div>
73 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude74curl -X POST http://localhost:3000/api/setup \\
75 -H 'Content-Type: application/json' \\
76 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
77
78git remote add gluecron http://localhost:3000/you/hello.git
79git push gluecron main`}</pre>
80 </div>
81 </Layout>
82 );
83});
84
06d5ffeClaude85// New repository form
86web.get("/new", requireAuth, (c) => {
87 const user = c.get("user")!;
88 const error = c.req.query("error");
89
90 return c.html(
91 <Layout title="New repository" user={user}>
92 <div class="new-repo-form">
93 <h2>Create a new repository</h2>
94 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
95 <form method="POST" action="/new">
96 <div class="form-group">
97 <label>Owner</label>
98 <input type="text" value={user.username} disabled class="input-disabled" />
99 </div>
100 <div class="form-group">
101 <label for="name">Repository name</label>
102 <input
103 type="text"
104 id="name"
105 name="name"
106 required
107 pattern="^[a-zA-Z0-9._-]+$"
108 placeholder="my-project"
109 autocomplete="off"
110 />
111 </div>
112 <div class="form-group">
113 <label for="description">Description (optional)</label>
114 <input
115 type="text"
116 id="description"
117 name="description"
118 placeholder="A short description of your repository"
119 />
120 </div>
121 <div class="visibility-options">
122 <label class="visibility-option">
123 <input type="radio" name="visibility" value="public" checked />
124 <div class="vis-label">Public</div>
125 <div class="vis-desc">Anyone can see this repository</div>
126 </label>
127 <label class="visibility-option">
128 <input type="radio" name="visibility" value="private" />
129 <div class="vis-label">Private</div>
130 <div class="vis-desc">Only you can see this repository</div>
131 </label>
132 </div>
133 <button type="submit" class="btn btn-primary">
134 Create repository
135 </button>
136 </form>
137 </div>
138 </Layout>
139 );
140});
141
142web.post("/new", requireAuth, async (c) => {
143 const user = c.get("user")!;
144 const body = await c.req.parseBody();
145 const name = String(body.name || "").trim();
146 const description = String(body.description || "").trim();
147 const isPrivate = body.visibility === "private";
148
149 if (!name) {
150 return c.redirect("/new?error=Repository+name+is+required");
151 }
152
153 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
154 return c.redirect("/new?error=Invalid+repository+name");
155 }
156
157 if (await repoExists(user.username, name)) {
158 return c.redirect("/new?error=Repository+already+exists");
159 }
160
161 const diskPath = await initBareRepo(user.username, name);
162
3ef4c9dClaude163 const [newRepo] = await db
164 .insert(repositories)
165 .values({
166 name,
167 ownerId: user.id,
168 description: description || null,
169 isPrivate,
170 diskPath,
171 })
172 .returning();
173
174 if (newRepo) {
175 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
176 await bootstrapRepository({
177 repositoryId: newRepo.id,
178 ownerUserId: user.id,
179 defaultBranch: "main",
180 });
181 }
06d5ffeClaude182
183 return c.redirect(`/${user.username}/${name}`);
184});
185
186// User profile
fc1817aClaude187web.get("/:owner", async (c) => {
06d5ffeClaude188 const { owner: ownerName } = c.req.param();
189 const user = c.get("user");
190
191 // Avoid clashing with fixed routes
192 if (
193 ["login", "register", "logout", "new", "settings", "api"].includes(
194 ownerName
195 )
196 ) {
197 return c.notFound();
198 }
199
200 let ownerUser;
201 try {
202 const [found] = await db
203 .select()
204 .from(users)
205 .where(eq(users.username, ownerName))
206 .limit(1);
207 ownerUser = found;
208 } catch {
209 // DB not available — check if repos exist on disk
210 ownerUser = null;
211 }
212
213 // Even without DB, show repos if they exist on disk
214 let repos: any[] = [];
215 if (ownerUser) {
216 const allRepos = await db
217 .select()
218 .from(repositories)
219 .where(eq(repositories.ownerId, ownerUser.id))
220 .orderBy(desc(repositories.updatedAt));
221
222 // Show public repos to everyone, private only to owner
223 repos =
224 user?.id === ownerUser.id
225 ? allRepos
226 : allRepos.filter((r) => !r.isPrivate);
227 }
228
fc1817aClaude229 return c.html(
06d5ffeClaude230 <Layout title={ownerName} user={user}>
231 <div class="user-profile">
232 <div class="user-avatar">
233 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
234 </div>
235 <div class="user-info">
236 <h2>{ownerUser?.displayName || ownerName}</h2>
237 <div class="username">@{ownerName}</div>
238 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
239 </div>
240 </div>
241 <h3 style="margin-bottom: 16px">Repositories</h3>
242 {repos.length === 0 ? (
243 <p style="color: var(--text-muted)">No repositories yet.</p>
244 ) : (
245 <div class="card-grid">
246 {repos.map((repo) => (
247 <RepoCard repo={repo} ownerName={ownerName} />
248 ))}
249 </div>
250 )}
fc1817aClaude251 </Layout>
252 );
253});
254
06d5ffeClaude255// Star/unstar a repo
256web.post("/:owner/:repo/star", requireAuth, async (c) => {
257 const { owner: ownerName, repo: repoName } = c.req.param();
258 const user = c.get("user")!;
259
260 try {
261 const [ownerUser] = await db
262 .select()
263 .from(users)
264 .where(eq(users.username, ownerName))
265 .limit(1);
266 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
267
268 const [repo] = await db
269 .select()
270 .from(repositories)
271 .where(
272 and(
273 eq(repositories.ownerId, ownerUser.id),
274 eq(repositories.name, repoName)
275 )
276 )
277 .limit(1);
278 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
279
280 // Toggle star
281 const [existing] = await db
282 .select()
283 .from(stars)
284 .where(
285 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
286 )
287 .limit(1);
288
289 if (existing) {
290 await db.delete(stars).where(eq(stars.id, existing.id));
291 await db
292 .update(repositories)
293 .set({ starCount: Math.max(0, repo.starCount - 1) })
294 .where(eq(repositories.id, repo.id));
295 } else {
296 await db.insert(stars).values({
297 userId: user.id,
298 repositoryId: repo.id,
299 });
300 await db
301 .update(repositories)
302 .set({ starCount: repo.starCount + 1 })
303 .where(eq(repositories.id, repo.id));
304 }
305 } catch {
306 // DB error — ignore
307 }
308
309 return c.redirect(`/${ownerName}/${repoName}`);
310});
311
fc1817aClaude312// Repository overview — file tree at HEAD
313web.get("/:owner/:repo", async (c) => {
314 const { owner, repo } = c.req.param();
06d5ffeClaude315 const user = c.get("user");
fc1817aClaude316
8f50ed0Claude317 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
318 trackByName(owner, repo, "view", {
319 userId: user?.id || null,
320 path: `/${owner}/${repo}`,
321 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
322 userAgent: c.req.header("user-agent") || null,
323 referer: c.req.header("referer") || null,
324 }).catch(() => {});
325
fc1817aClaude326 if (!(await repoExists(owner, repo))) {
327 return c.html(
06d5ffeClaude328 <Layout title="Not Found" user={user}>
fc1817aClaude329 <div class="empty-state">
330 <h2>Repository not found</h2>
331 <p>
332 {owner}/{repo} does not exist.
333 </p>
334 </div>
335 </Layout>,
336 404
337 );
338 }
339
05b973eClaude340 // Parallelize all independent operations
341 const [defaultBranch, branches] = await Promise.all([
342 getDefaultBranch(owner, repo).then((b) => b || "main"),
343 listBranches(owner, repo),
344 ]);
345 const [tree, starInfo] = await Promise.all([
346 getTree(owner, repo, defaultBranch),
347 // Star info fetched in parallel with tree
348 (async () => {
349 try {
350 const [ownerUser] = await db
351 .select()
352 .from(users)
353 .where(eq(users.username, owner))
354 .limit(1);
71cd5ecClaude355 if (!ownerUser)
356 return {
357 starCount: 0,
358 starred: false,
359 archived: false,
360 isTemplate: false,
361 };
05b973eClaude362 const [repoRow] = await db
363 .select()
364 .from(repositories)
365 .where(
366 and(
367 eq(repositories.ownerId, ownerUser.id),
368 eq(repositories.name, repo)
369 )
06d5ffeClaude370 )
05b973eClaude371 .limit(1);
71cd5ecClaude372 if (!repoRow)
373 return {
374 starCount: 0,
375 starred: false,
376 archived: false,
377 isTemplate: false,
378 };
05b973eClaude379 let starred = false;
06d5ffeClaude380 if (user) {
381 const [star] = await db
382 .select()
383 .from(stars)
384 .where(
385 and(
386 eq(stars.userId, user.id),
387 eq(stars.repositoryId, repoRow.id)
388 )
389 )
390 .limit(1);
391 starred = !!star;
392 }
71cd5ecClaude393 return {
394 starCount: repoRow.starCount,
395 starred,
396 archived: repoRow.isArchived,
397 isTemplate: repoRow.isTemplate,
398 };
05b973eClaude399 } catch {
71cd5ecClaude400 return {
401 starCount: 0,
402 starred: false,
403 archived: false,
404 isTemplate: false,
405 };
06d5ffeClaude406 }
05b973eClaude407 })(),
408 ]);
71cd5ecClaude409 const { starCount, starred, archived, isTemplate } = starInfo;
06d5ffeClaude410
fc1817aClaude411 if (tree.length === 0) {
412 return c.html(
06d5ffeClaude413 <Layout title={`${owner}/${repo}`} user={user}>
414 <RepoHeader
415 owner={owner}
416 repo={repo}
417 starCount={starCount}
418 starred={starred}
419 currentUser={user?.username}
71cd5ecClaude420 archived={archived}
421 isTemplate={isTemplate}
06d5ffeClaude422 />
fc1817aClaude423 <RepoNav owner={owner} repo={repo} active="code" />
424 <div class="empty-state">
425 <h2>Empty repository</h2>
426 <p>Get started by pushing code:</p>
427 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
428git push -u gluecron main`}</pre>
429 </div>
430 </Layout>
431 );
432 }
433
434 const readme = await getReadme(owner, repo, defaultBranch);
435
436 return c.html(
06d5ffeClaude437 <Layout title={`${owner}/${repo}`} user={user}>
438 <RepoHeader
439 owner={owner}
440 repo={repo}
441 starCount={starCount}
442 starred={starred}
443 currentUser={user?.username}
71cd5ecClaude444 archived={archived}
445 isTemplate={isTemplate}
06d5ffeClaude446 />
71cd5ecClaude447 {isTemplate && user && user.username !== owner && (
448 <div
449 class="panel"
450 style="margin-bottom:16px;padding:12px;display:flex;align-items:center;justify-content:space-between;gap:12px"
451 >
452 <div style="font-size:13px">
453 <strong>Template repository.</strong> Create a new repository from
454 this template's files.
455 </div>
456 <form
457 method="POST"
458 action={`/${owner}/${repo}/use-template`}
459 style="display:flex;gap:8px;align-items:center"
460 >
461 <input
462 type="text"
463 name="name"
464 placeholder="new-repo-name"
465 required
466 style="width:200px"
467 />
468 <button type="submit" class="btn btn-primary">
469 Use this template
470 </button>
471 </form>
472 </div>
473 )}
fc1817aClaude474 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude475 <BranchSwitcher
476 owner={owner}
477 repo={repo}
478 currentRef={defaultBranch}
479 branches={branches}
480 pathType="tree"
481 />
fc1817aClaude482 <FileTable
483 entries={tree}
484 owner={owner}
485 repo={repo}
486 ref={defaultBranch}
487 path=""
488 />
79136bbClaude489 {readme && (() => {
490 const readmeHtml = renderMarkdown(readme);
491 return (
492 <div class="blob-view" style="margin-top: 20px">
493 <div class="blob-header">README.md</div>
494 <style>{markdownCss}</style>
495 <div class="markdown-body">
496 {html([readmeHtml] as unknown as TemplateStringsArray)}
497 </div>
fc1817aClaude498 </div>
79136bbClaude499 );
500 })()}
fc1817aClaude501 </Layout>
502 );
503});
504
505// Browse tree at ref/path
506web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
507 const { owner, repo } = c.req.param();
06d5ffeClaude508 const user = c.get("user");
fc1817aClaude509 const refAndPath = c.req.param("ref");
510
511 const branches = await listBranches(owner, repo);
512 let ref = "";
513 let treePath = "";
514
515 for (const branch of branches) {
516 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
517 ref = branch;
518 treePath = refAndPath.slice(branch.length + 1);
519 break;
520 }
521 }
522
523 if (!ref) {
524 const slashIdx = refAndPath.indexOf("/");
525 if (slashIdx === -1) {
526 ref = refAndPath;
527 } else {
528 ref = refAndPath.slice(0, slashIdx);
529 treePath = refAndPath.slice(slashIdx + 1);
530 }
531 }
532
533 const tree = await getTree(owner, repo, ref, treePath);
534
535 return c.html(
06d5ffeClaude536 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude537 <RepoHeader owner={owner} repo={repo} />
538 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude539 <BranchSwitcher
540 owner={owner}
541 repo={repo}
542 currentRef={ref}
543 branches={branches}
544 pathType="tree"
545 subPath={treePath}
546 />
fc1817aClaude547 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
548 <FileTable
549 entries={tree}
550 owner={owner}
551 repo={repo}
552 ref={ref}
553 path={treePath}
554 />
555 </Layout>
556 );
557});
558
06d5ffeClaude559// View file blob with syntax highlighting
fc1817aClaude560web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
561 const { owner, repo } = c.req.param();
06d5ffeClaude562 const user = c.get("user");
fc1817aClaude563 const refAndPath = c.req.param("ref");
564
565 const branches = await listBranches(owner, repo);
566 let ref = "";
567 let filePath = "";
568
569 for (const branch of branches) {
570 if (refAndPath.startsWith(branch + "/")) {
571 ref = branch;
572 filePath = refAndPath.slice(branch.length + 1);
573 break;
574 }
575 }
576
577 if (!ref) {
578 const slashIdx = refAndPath.indexOf("/");
579 if (slashIdx === -1) return c.text("Not found", 404);
580 ref = refAndPath.slice(0, slashIdx);
581 filePath = refAndPath.slice(slashIdx + 1);
582 }
583
584 const blob = await getBlob(owner, repo, ref, filePath);
585 if (!blob) {
586 return c.html(
06d5ffeClaude587 <Layout title="Not Found" user={user}>
fc1817aClaude588 <div class="empty-state">
589 <h2>File not found</h2>
590 </div>
591 </Layout>,
592 404
593 );
594 }
595
06d5ffeClaude596 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude597
598 return c.html(
06d5ffeClaude599 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude600 <RepoHeader owner={owner} repo={repo} />
601 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude602 <BranchSwitcher
603 owner={owner}
604 repo={repo}
605 currentRef={ref}
606 branches={branches}
607 pathType="blob"
608 subPath={filePath}
609 />
fc1817aClaude610 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
611 <div class="blob-view">
612 <div class="blob-header">
06d5ffeClaude613 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude614 <span style="display: flex; gap: 12px">
615 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
616 Raw
617 </a>
618 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
619 Blame
620 </a>
0074234Claude621 {user && (
622 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
623 Edit
624 </a>
625 )}
79136bbClaude626 </span>
fc1817aClaude627 </div>
628 {blob.isBinary ? (
629 <div style="padding: 16px; color: var(--text-muted)">
630 Binary file not shown.
631 </div>
06d5ffeClaude632 ) : (() => {
633 const { html: highlighted, language } = highlightCode(
634 blob.content,
635 fileName
636 );
637 const lineCount = blob.content.split("\n").length;
638 // Trim trailing newline from count
639 const adjustedCount =
640 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
641
642 if (language) {
643 return (
644 <HighlightedCode
645 highlightedHtml={highlighted}
646 lineCount={adjustedCount}
647 />
648 );
649 }
650 const lines = blob.content.split("\n");
651 if (lines[lines.length - 1] === "") lines.pop();
652 return <PlainCode lines={lines} />;
653 })()}
fc1817aClaude654 </div>
655 </Layout>
656 );
657});
658
659// Commit log
660web.get("/:owner/:repo/commits/:ref?", async (c) => {
661 const { owner, repo } = c.req.param();
06d5ffeClaude662 const user = c.get("user");
fc1817aClaude663 const ref =
664 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude665 const branches = await listBranches(owner, repo);
fc1817aClaude666
667 const commits = await listCommits(owner, repo, ref, 50);
668
669 return c.html(
06d5ffeClaude670 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude671 <RepoHeader owner={owner} repo={repo} />
672 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude673 <BranchSwitcher
674 owner={owner}
675 repo={repo}
676 currentRef={ref}
677 branches={branches}
678 pathType="commits"
679 />
fc1817aClaude680 {commits.length === 0 ? (
681 <div class="empty-state">
682 <p>No commits yet.</p>
683 </div>
684 ) : (
685 <CommitList commits={commits} owner={owner} repo={repo} />
686 )}
687 </Layout>
688 );
689});
690
691// Single commit with diff
692web.get("/:owner/:repo/commit/:sha", async (c) => {
693 const { owner, repo, sha } = c.req.param();
06d5ffeClaude694 const user = c.get("user");
fc1817aClaude695
05b973eClaude696 // Fetch commit, full message, and diff in parallel
697 const [commit, fullMessage, diffResult] = await Promise.all([
698 getCommit(owner, repo, sha),
699 getCommitFullMessage(owner, repo, sha),
700 getDiff(owner, repo, sha),
701 ]);
fc1817aClaude702 if (!commit) {
703 return c.html(
06d5ffeClaude704 <Layout title="Not Found" user={user}>
fc1817aClaude705 <div class="empty-state">
706 <h2>Commit not found</h2>
707 </div>
708 </Layout>,
709 404
710 );
711 }
712
05b973eClaude713 const { files, raw } = diffResult;
fc1817aClaude714
715 return c.html(
06d5ffeClaude716 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude717 <RepoHeader owner={owner} repo={repo} />
718 <div
719 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
720 >
721 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
722 {commit.message}
723 </div>
724 {fullMessage !== commit.message && (
725 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
726 {fullMessage}
727 </div>
728 )}
729 <div style="font-size: 13px; color: var(--text-muted)">
730 <strong style="color: var(--text)">{commit.author}</strong>{" "}
731 committed on{" "}
732 {new Date(commit.date).toLocaleDateString("en-US", {
733 month: "long",
734 day: "numeric",
735 year: "numeric",
736 })}
737 </div>
738 <div style="margin-top: 8px">
739 <span class="commit-sha">{commit.sha}</span>
740 {commit.parentShas.length > 0 && (
741 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
742 Parent:{" "}
743 {commit.parentShas.map((p) => (
744 <a
745 href={`/${owner}/${repo}/commit/${p}`}
746 class="commit-sha"
747 style="margin-left: 4px"
748 >
749 {p.slice(0, 7)}
750 </a>
751 ))}
752 </span>
753 )}
754 </div>
755 </div>
756 <DiffView raw={raw} files={files} />
757 </Layout>
758 );
759});
760
79136bbClaude761// Raw file download
762web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
763 const { owner, repo } = c.req.param();
764 const refAndPath = c.req.param("ref");
765
766 const branches = await listBranches(owner, repo);
767 let ref = "";
768 let filePath = "";
769
770 for (const branch of branches) {
771 if (refAndPath.startsWith(branch + "/")) {
772 ref = branch;
773 filePath = refAndPath.slice(branch.length + 1);
774 break;
775 }
776 }
777
778 if (!ref) {
779 const slashIdx = refAndPath.indexOf("/");
780 if (slashIdx === -1) return c.text("Not found", 404);
781 ref = refAndPath.slice(0, slashIdx);
782 filePath = refAndPath.slice(slashIdx + 1);
783 }
784
785 const data = await getRawBlob(owner, repo, ref, filePath);
786 if (!data) return c.text("Not found", 404);
787
788 const fileName = filePath.split("/").pop() || "file";
789 return new Response(data, {
790 headers: {
791 "Content-Type": "application/octet-stream",
792 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude793 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude794 },
795 });
796});
797
798// Blame view
799web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
800 const { owner, repo } = c.req.param();
801 const user = c.get("user");
802 const refAndPath = c.req.param("ref");
803
804 const branches = await listBranches(owner, repo);
805 let ref = "";
806 let filePath = "";
807
808 for (const branch of branches) {
809 if (refAndPath.startsWith(branch + "/")) {
810 ref = branch;
811 filePath = refAndPath.slice(branch.length + 1);
812 break;
813 }
814 }
815
816 if (!ref) {
817 const slashIdx = refAndPath.indexOf("/");
818 if (slashIdx === -1) return c.text("Not found", 404);
819 ref = refAndPath.slice(0, slashIdx);
820 filePath = refAndPath.slice(slashIdx + 1);
821 }
822
823 const blameLines = await getBlame(owner, repo, ref, filePath);
824 if (blameLines.length === 0) {
825 return c.html(
826 <Layout title="Not Found" user={user}>
827 <div class="empty-state">
828 <h2>File not found</h2>
829 </div>
830 </Layout>,
831 404
832 );
833 }
834
835 const fileName = filePath.split("/").pop() || filePath;
836
837 return c.html(
838 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
839 <RepoHeader owner={owner} repo={repo} />
840 <RepoNav owner={owner} repo={repo} active="code" />
841 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
842 <div class="blob-view">
843 <div class="blob-header">
844 <span>{fileName} — blame</span>
845 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
846 Normal view
847 </a>
848 </div>
849 <div class="blob-code" style="overflow-x: auto">
850 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
851 <tbody>
852 {blameLines.map((line, i) => {
853 const showInfo =
854 i === 0 || blameLines[i - 1].sha !== line.sha;
855 return (
856 <tr style="border-bottom: 1px solid var(--border)">
857 <td
858 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)" : ""}`}
859 >
860 {showInfo && (
861 <>
862 <a
863 href={`/${owner}/${repo}/commit/${line.sha}`}
864 style="color: var(--text-link); font-family: var(--font-mono)"
865 >
866 {line.sha.slice(0, 7)}
867 </a>{" "}
868 <span>{line.author}</span>
869 </>
870 )}
871 </td>
872 <td class="line-num">{line.lineNum}</td>
873 <td class="line-content">{line.content}</td>
874 </tr>
875 );
876 })}
877 </tbody>
878 </table>
879 </div>
880 </div>
881 </Layout>
882 );
883});
884
885// Search
886web.get("/:owner/:repo/search", async (c) => {
887 const { owner, repo } = c.req.param();
888 const user = c.get("user");
889 const q = c.req.query("q") || "";
890
891 if (!(await repoExists(owner, repo))) return c.notFound();
892
893 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
894 let results: Array<{ file: string; lineNum: number; line: string }> = [];
895
896 if (q.trim()) {
897 results = await searchCode(owner, repo, defaultBranch, q.trim());
898 }
899
900 return c.html(
901 <Layout title={`Search — ${owner}/${repo}`} user={user}>
902 <RepoHeader owner={owner} repo={repo} />
903 <RepoNav owner={owner} repo={repo} active="code" />
904 <form
905 method="GET"
906 action={`/${owner}/${repo}/search`}
907 style="margin-bottom: 20px"
908 >
909 <div style="display: flex; gap: 8px">
910 <input
911 type="text"
912 name="q"
913 value={q}
914 placeholder="Search code..."
915 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
916 />
917 <button type="submit" class="btn btn-primary">
918 Search
919 </button>
920 </div>
921 </form>
922 {q && (
923 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
924 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
925 <strong style="color: var(--text)">"{q}"</strong>
926 </p>
927 )}
928 {results.length > 0 && (
929 <div class="search-results">
930 {(() => {
931 // Group by file
932 const grouped: Record<
933 string,
934 Array<{ lineNum: number; line: string }>
935 > = {};
936 for (const r of results) {
937 if (!grouped[r.file]) grouped[r.file] = [];
938 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
939 }
940 return Object.entries(grouped).map(([file, matches]) => (
941 <div class="diff-file" style="margin-bottom: 12px">
942 <div class="diff-file-header">
943 <a
944 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
945 >
946 {file}
947 </a>
948 </div>
949 <div class="blob-code">
950 <table>
951 <tbody>
952 {matches.map((m) => (
953 <tr>
954 <td class="line-num">{m.lineNum}</td>
955 <td class="line-content">{m.line}</td>
956 </tr>
957 ))}
958 </tbody>
959 </table>
960 </div>
961 </div>
962 ));
963 })()}
964 </div>
965 )}
966 </Layout>
967 );
968});
969
fc1817aClaude970export default web;