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.tsxBlame907 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";
fc1817aClaude44
06d5ffeClaude45const web = new Hono<AuthEnv>();
46
47// Soft auth on all web routes — c.get("user") available but may be null
48web.use("*", softAuth);
fc1817aClaude49
50// Home page
06d5ffeClaude51web.get("/", async (c) => {
52 const user = c.get("user");
53
54 if (user) {
3ef4c9dClaude55 const { renderDashboard } = await import("./dashboard");
56 return renderDashboard(c);
06d5ffeClaude57 }
58
fc1817aClaude59 return c.html(
06d5ffeClaude60 <Layout user={null}>
fc1817aClaude61 <div class="empty-state">
62 <h2>gluecron</h2>
63 <p>AI-native code intelligence platform</p>
06d5ffeClaude64 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
65 <a href="/register" class="btn btn-primary">
66 Get started
67 </a>
68 <a href="/login" class="btn">
69 Sign in
70 </a>
71 </div>
72 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude73curl -X POST http://localhost:3000/api/setup \\
74 -H 'Content-Type: application/json' \\
75 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
76
77git remote add gluecron http://localhost:3000/you/hello.git
78git push gluecron main`}</pre>
79 </div>
80 </Layout>
81 );
82});
83
06d5ffeClaude84// New repository form
85web.get("/new", requireAuth, (c) => {
86 const user = c.get("user")!;
87 const error = c.req.query("error");
88
89 return c.html(
90 <Layout title="New repository" user={user}>
91 <div class="new-repo-form">
92 <h2>Create a new repository</h2>
93 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
94 <form method="POST" action="/new">
95 <div class="form-group">
96 <label>Owner</label>
97 <input type="text" value={user.username} disabled class="input-disabled" />
98 </div>
99 <div class="form-group">
100 <label for="name">Repository name</label>
101 <input
102 type="text"
103 id="name"
104 name="name"
105 required
106 pattern="^[a-zA-Z0-9._-]+$"
107 placeholder="my-project"
108 autocomplete="off"
109 />
110 </div>
111 <div class="form-group">
112 <label for="description">Description (optional)</label>
113 <input
114 type="text"
115 id="description"
116 name="description"
117 placeholder="A short description of your repository"
118 />
119 </div>
120 <div class="visibility-options">
121 <label class="visibility-option">
122 <input type="radio" name="visibility" value="public" checked />
123 <div class="vis-label">Public</div>
124 <div class="vis-desc">Anyone can see this repository</div>
125 </label>
126 <label class="visibility-option">
127 <input type="radio" name="visibility" value="private" />
128 <div class="vis-label">Private</div>
129 <div class="vis-desc">Only you can see this repository</div>
130 </label>
131 </div>
132 <button type="submit" class="btn btn-primary">
133 Create repository
134 </button>
135 </form>
136 </div>
137 </Layout>
138 );
139});
140
141web.post("/new", requireAuth, async (c) => {
142 const user = c.get("user")!;
143 const body = await c.req.parseBody();
144 const name = String(body.name || "").trim();
145 const description = String(body.description || "").trim();
146 const isPrivate = body.visibility === "private";
147
148 if (!name) {
149 return c.redirect("/new?error=Repository+name+is+required");
150 }
151
152 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
153 return c.redirect("/new?error=Invalid+repository+name");
154 }
155
156 if (await repoExists(user.username, name)) {
157 return c.redirect("/new?error=Repository+already+exists");
158 }
159
160 const diskPath = await initBareRepo(user.username, name);
161
3ef4c9dClaude162 const [newRepo] = await db
163 .insert(repositories)
164 .values({
165 name,
166 ownerId: user.id,
167 description: description || null,
168 isPrivate,
169 diskPath,
170 })
171 .returning();
172
173 if (newRepo) {
174 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
175 await bootstrapRepository({
176 repositoryId: newRepo.id,
177 ownerUserId: user.id,
178 defaultBranch: "main",
179 });
180 }
06d5ffeClaude181
182 return c.redirect(`/${user.username}/${name}`);
183});
184
185// User profile
fc1817aClaude186web.get("/:owner", async (c) => {
06d5ffeClaude187 const { owner: ownerName } = c.req.param();
188 const user = c.get("user");
189
190 // Avoid clashing with fixed routes
191 if (
192 ["login", "register", "logout", "new", "settings", "api"].includes(
193 ownerName
194 )
195 ) {
196 return c.notFound();
197 }
198
199 let ownerUser;
200 try {
201 const [found] = await db
202 .select()
203 .from(users)
204 .where(eq(users.username, ownerName))
205 .limit(1);
206 ownerUser = found;
207 } catch {
208 // DB not available — check if repos exist on disk
209 ownerUser = null;
210 }
211
212 // Even without DB, show repos if they exist on disk
213 let repos: any[] = [];
214 if (ownerUser) {
215 const allRepos = await db
216 .select()
217 .from(repositories)
218 .where(eq(repositories.ownerId, ownerUser.id))
219 .orderBy(desc(repositories.updatedAt));
220
221 // Show public repos to everyone, private only to owner
222 repos =
223 user?.id === ownerUser.id
224 ? allRepos
225 : allRepos.filter((r) => !r.isPrivate);
226 }
227
fc1817aClaude228 return c.html(
06d5ffeClaude229 <Layout title={ownerName} user={user}>
230 <div class="user-profile">
231 <div class="user-avatar">
232 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
233 </div>
234 <div class="user-info">
235 <h2>{ownerUser?.displayName || ownerName}</h2>
236 <div class="username">@{ownerName}</div>
237 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
238 </div>
239 </div>
240 <h3 style="margin-bottom: 16px">Repositories</h3>
241 {repos.length === 0 ? (
242 <p style="color: var(--text-muted)">No repositories yet.</p>
243 ) : (
244 <div class="card-grid">
245 {repos.map((repo) => (
246 <RepoCard repo={repo} ownerName={ownerName} />
247 ))}
248 </div>
249 )}
fc1817aClaude250 </Layout>
251 );
252});
253
06d5ffeClaude254// Star/unstar a repo
255web.post("/:owner/:repo/star", requireAuth, async (c) => {
256 const { owner: ownerName, repo: repoName } = c.req.param();
257 const user = c.get("user")!;
258
259 try {
260 const [ownerUser] = await db
261 .select()
262 .from(users)
263 .where(eq(users.username, ownerName))
264 .limit(1);
265 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
266
267 const [repo] = await db
268 .select()
269 .from(repositories)
270 .where(
271 and(
272 eq(repositories.ownerId, ownerUser.id),
273 eq(repositories.name, repoName)
274 )
275 )
276 .limit(1);
277 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
278
279 // Toggle star
280 const [existing] = await db
281 .select()
282 .from(stars)
283 .where(
284 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
285 )
286 .limit(1);
287
288 if (existing) {
289 await db.delete(stars).where(eq(stars.id, existing.id));
290 await db
291 .update(repositories)
292 .set({ starCount: Math.max(0, repo.starCount - 1) })
293 .where(eq(repositories.id, repo.id));
294 } else {
295 await db.insert(stars).values({
296 userId: user.id,
297 repositoryId: repo.id,
298 });
299 await db
300 .update(repositories)
301 .set({ starCount: repo.starCount + 1 })
302 .where(eq(repositories.id, repo.id));
303 }
304 } catch {
305 // DB error — ignore
306 }
307
308 return c.redirect(`/${ownerName}/${repoName}`);
309});
310
fc1817aClaude311// Repository overview — file tree at HEAD
312web.get("/:owner/:repo", async (c) => {
313 const { owner, repo } = c.req.param();
06d5ffeClaude314 const user = c.get("user");
fc1817aClaude315
316 if (!(await repoExists(owner, repo))) {
317 return c.html(
06d5ffeClaude318 <Layout title="Not Found" user={user}>
fc1817aClaude319 <div class="empty-state">
320 <h2>Repository not found</h2>
321 <p>
322 {owner}/{repo} does not exist.
323 </p>
324 </div>
325 </Layout>,
326 404
327 );
328 }
329
05b973eClaude330 // Parallelize all independent operations
331 const [defaultBranch, branches] = await Promise.all([
332 getDefaultBranch(owner, repo).then((b) => b || "main"),
333 listBranches(owner, repo),
334 ]);
335 const [tree, starInfo] = await Promise.all([
336 getTree(owner, repo, defaultBranch),
337 // Star info fetched in parallel with tree
338 (async () => {
339 try {
340 const [ownerUser] = await db
341 .select()
342 .from(users)
343 .where(eq(users.username, owner))
344 .limit(1);
345 if (!ownerUser) return { starCount: 0, starred: false };
346 const [repoRow] = await db
347 .select()
348 .from(repositories)
349 .where(
350 and(
351 eq(repositories.ownerId, ownerUser.id),
352 eq(repositories.name, repo)
353 )
06d5ffeClaude354 )
05b973eClaude355 .limit(1);
356 if (!repoRow) return { starCount: 0, starred: false };
357 let starred = false;
06d5ffeClaude358 if (user) {
359 const [star] = await db
360 .select()
361 .from(stars)
362 .where(
363 and(
364 eq(stars.userId, user.id),
365 eq(stars.repositoryId, repoRow.id)
366 )
367 )
368 .limit(1);
369 starred = !!star;
370 }
05b973eClaude371 return { starCount: repoRow.starCount, starred };
372 } catch {
373 return { starCount: 0, starred: false };
06d5ffeClaude374 }
05b973eClaude375 })(),
376 ]);
377 const { starCount, starred } = starInfo;
06d5ffeClaude378
fc1817aClaude379 if (tree.length === 0) {
380 return c.html(
06d5ffeClaude381 <Layout title={`${owner}/${repo}`} user={user}>
382 <RepoHeader
383 owner={owner}
384 repo={repo}
385 starCount={starCount}
386 starred={starred}
387 currentUser={user?.username}
388 />
fc1817aClaude389 <RepoNav owner={owner} repo={repo} active="code" />
390 <div class="empty-state">
391 <h2>Empty repository</h2>
392 <p>Get started by pushing code:</p>
393 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
394git push -u gluecron main`}</pre>
395 </div>
396 </Layout>
397 );
398 }
399
400 const readme = await getReadme(owner, repo, defaultBranch);
401
402 return c.html(
06d5ffeClaude403 <Layout title={`${owner}/${repo}`} user={user}>
404 <RepoHeader
405 owner={owner}
406 repo={repo}
407 starCount={starCount}
408 starred={starred}
409 currentUser={user?.username}
410 />
fc1817aClaude411 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude412 <BranchSwitcher
413 owner={owner}
414 repo={repo}
415 currentRef={defaultBranch}
416 branches={branches}
417 pathType="tree"
418 />
fc1817aClaude419 <FileTable
420 entries={tree}
421 owner={owner}
422 repo={repo}
423 ref={defaultBranch}
424 path=""
425 />
79136bbClaude426 {readme && (() => {
427 const readmeHtml = renderMarkdown(readme);
428 return (
429 <div class="blob-view" style="margin-top: 20px">
430 <div class="blob-header">README.md</div>
431 <style>{markdownCss}</style>
432 <div class="markdown-body">
433 {html([readmeHtml] as unknown as TemplateStringsArray)}
434 </div>
fc1817aClaude435 </div>
79136bbClaude436 );
437 })()}
fc1817aClaude438 </Layout>
439 );
440});
441
442// Browse tree at ref/path
443web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
444 const { owner, repo } = c.req.param();
06d5ffeClaude445 const user = c.get("user");
fc1817aClaude446 const refAndPath = c.req.param("ref");
447
448 const branches = await listBranches(owner, repo);
449 let ref = "";
450 let treePath = "";
451
452 for (const branch of branches) {
453 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
454 ref = branch;
455 treePath = refAndPath.slice(branch.length + 1);
456 break;
457 }
458 }
459
460 if (!ref) {
461 const slashIdx = refAndPath.indexOf("/");
462 if (slashIdx === -1) {
463 ref = refAndPath;
464 } else {
465 ref = refAndPath.slice(0, slashIdx);
466 treePath = refAndPath.slice(slashIdx + 1);
467 }
468 }
469
470 const tree = await getTree(owner, repo, ref, treePath);
471
472 return c.html(
06d5ffeClaude473 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude474 <RepoHeader owner={owner} repo={repo} />
475 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude476 <BranchSwitcher
477 owner={owner}
478 repo={repo}
479 currentRef={ref}
480 branches={branches}
481 pathType="tree"
482 subPath={treePath}
483 />
fc1817aClaude484 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
485 <FileTable
486 entries={tree}
487 owner={owner}
488 repo={repo}
489 ref={ref}
490 path={treePath}
491 />
492 </Layout>
493 );
494});
495
06d5ffeClaude496// View file blob with syntax highlighting
fc1817aClaude497web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
498 const { owner, repo } = c.req.param();
06d5ffeClaude499 const user = c.get("user");
fc1817aClaude500 const refAndPath = c.req.param("ref");
501
502 const branches = await listBranches(owner, repo);
503 let ref = "";
504 let filePath = "";
505
506 for (const branch of branches) {
507 if (refAndPath.startsWith(branch + "/")) {
508 ref = branch;
509 filePath = refAndPath.slice(branch.length + 1);
510 break;
511 }
512 }
513
514 if (!ref) {
515 const slashIdx = refAndPath.indexOf("/");
516 if (slashIdx === -1) return c.text("Not found", 404);
517 ref = refAndPath.slice(0, slashIdx);
518 filePath = refAndPath.slice(slashIdx + 1);
519 }
520
521 const blob = await getBlob(owner, repo, ref, filePath);
522 if (!blob) {
523 return c.html(
06d5ffeClaude524 <Layout title="Not Found" user={user}>
fc1817aClaude525 <div class="empty-state">
526 <h2>File not found</h2>
527 </div>
528 </Layout>,
529 404
530 );
531 }
532
06d5ffeClaude533 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude534
535 return c.html(
06d5ffeClaude536 <Layout title={`${filePath} — ${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="blob"
545 subPath={filePath}
546 />
fc1817aClaude547 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
548 <div class="blob-view">
549 <div class="blob-header">
06d5ffeClaude550 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude551 <span style="display: flex; gap: 12px">
552 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
553 Raw
554 </a>
555 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
556 Blame
557 </a>
0074234Claude558 {user && (
559 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
560 Edit
561 </a>
562 )}
79136bbClaude563 </span>
fc1817aClaude564 </div>
565 {blob.isBinary ? (
566 <div style="padding: 16px; color: var(--text-muted)">
567 Binary file not shown.
568 </div>
06d5ffeClaude569 ) : (() => {
570 const { html: highlighted, language } = highlightCode(
571 blob.content,
572 fileName
573 );
574 const lineCount = blob.content.split("\n").length;
575 // Trim trailing newline from count
576 const adjustedCount =
577 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
578
579 if (language) {
580 return (
581 <HighlightedCode
582 highlightedHtml={highlighted}
583 lineCount={adjustedCount}
584 />
585 );
586 }
587 const lines = blob.content.split("\n");
588 if (lines[lines.length - 1] === "") lines.pop();
589 return <PlainCode lines={lines} />;
590 })()}
fc1817aClaude591 </div>
592 </Layout>
593 );
594});
595
596// Commit log
597web.get("/:owner/:repo/commits/:ref?", async (c) => {
598 const { owner, repo } = c.req.param();
06d5ffeClaude599 const user = c.get("user");
fc1817aClaude600 const ref =
601 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude602 const branches = await listBranches(owner, repo);
fc1817aClaude603
604 const commits = await listCommits(owner, repo, ref, 50);
605
606 return c.html(
06d5ffeClaude607 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude608 <RepoHeader owner={owner} repo={repo} />
609 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude610 <BranchSwitcher
611 owner={owner}
612 repo={repo}
613 currentRef={ref}
614 branches={branches}
615 pathType="commits"
616 />
fc1817aClaude617 {commits.length === 0 ? (
618 <div class="empty-state">
619 <p>No commits yet.</p>
620 </div>
621 ) : (
622 <CommitList commits={commits} owner={owner} repo={repo} />
623 )}
624 </Layout>
625 );
626});
627
628// Single commit with diff
629web.get("/:owner/:repo/commit/:sha", async (c) => {
630 const { owner, repo, sha } = c.req.param();
06d5ffeClaude631 const user = c.get("user");
fc1817aClaude632
05b973eClaude633 // Fetch commit, full message, and diff in parallel
634 const [commit, fullMessage, diffResult] = await Promise.all([
635 getCommit(owner, repo, sha),
636 getCommitFullMessage(owner, repo, sha),
637 getDiff(owner, repo, sha),
638 ]);
fc1817aClaude639 if (!commit) {
640 return c.html(
06d5ffeClaude641 <Layout title="Not Found" user={user}>
fc1817aClaude642 <div class="empty-state">
643 <h2>Commit not found</h2>
644 </div>
645 </Layout>,
646 404
647 );
648 }
649
05b973eClaude650 const { files, raw } = diffResult;
fc1817aClaude651
652 return c.html(
06d5ffeClaude653 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude654 <RepoHeader owner={owner} repo={repo} />
655 <div
656 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
657 >
658 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
659 {commit.message}
660 </div>
661 {fullMessage !== commit.message && (
662 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
663 {fullMessage}
664 </div>
665 )}
666 <div style="font-size: 13px; color: var(--text-muted)">
667 <strong style="color: var(--text)">{commit.author}</strong>{" "}
668 committed on{" "}
669 {new Date(commit.date).toLocaleDateString("en-US", {
670 month: "long",
671 day: "numeric",
672 year: "numeric",
673 })}
674 </div>
675 <div style="margin-top: 8px">
676 <span class="commit-sha">{commit.sha}</span>
677 {commit.parentShas.length > 0 && (
678 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
679 Parent:{" "}
680 {commit.parentShas.map((p) => (
681 <a
682 href={`/${owner}/${repo}/commit/${p}`}
683 class="commit-sha"
684 style="margin-left: 4px"
685 >
686 {p.slice(0, 7)}
687 </a>
688 ))}
689 </span>
690 )}
691 </div>
692 </div>
693 <DiffView raw={raw} files={files} />
694 </Layout>
695 );
696});
697
79136bbClaude698// Raw file download
699web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
700 const { owner, repo } = c.req.param();
701 const refAndPath = c.req.param("ref");
702
703 const branches = await listBranches(owner, repo);
704 let ref = "";
705 let filePath = "";
706
707 for (const branch of branches) {
708 if (refAndPath.startsWith(branch + "/")) {
709 ref = branch;
710 filePath = refAndPath.slice(branch.length + 1);
711 break;
712 }
713 }
714
715 if (!ref) {
716 const slashIdx = refAndPath.indexOf("/");
717 if (slashIdx === -1) return c.text("Not found", 404);
718 ref = refAndPath.slice(0, slashIdx);
719 filePath = refAndPath.slice(slashIdx + 1);
720 }
721
722 const data = await getRawBlob(owner, repo, ref, filePath);
723 if (!data) return c.text("Not found", 404);
724
725 const fileName = filePath.split("/").pop() || "file";
726 return new Response(data, {
727 headers: {
728 "Content-Type": "application/octet-stream",
729 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude730 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude731 },
732 });
733});
734
735// Blame view
736web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
737 const { owner, repo } = c.req.param();
738 const user = c.get("user");
739 const refAndPath = c.req.param("ref");
740
741 const branches = await listBranches(owner, repo);
742 let ref = "";
743 let filePath = "";
744
745 for (const branch of branches) {
746 if (refAndPath.startsWith(branch + "/")) {
747 ref = branch;
748 filePath = refAndPath.slice(branch.length + 1);
749 break;
750 }
751 }
752
753 if (!ref) {
754 const slashIdx = refAndPath.indexOf("/");
755 if (slashIdx === -1) return c.text("Not found", 404);
756 ref = refAndPath.slice(0, slashIdx);
757 filePath = refAndPath.slice(slashIdx + 1);
758 }
759
760 const blameLines = await getBlame(owner, repo, ref, filePath);
761 if (blameLines.length === 0) {
762 return c.html(
763 <Layout title="Not Found" user={user}>
764 <div class="empty-state">
765 <h2>File not found</h2>
766 </div>
767 </Layout>,
768 404
769 );
770 }
771
772 const fileName = filePath.split("/").pop() || filePath;
773
774 return c.html(
775 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
776 <RepoHeader owner={owner} repo={repo} />
777 <RepoNav owner={owner} repo={repo} active="code" />
778 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
779 <div class="blob-view">
780 <div class="blob-header">
781 <span>{fileName} — blame</span>
782 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
783 Normal view
784 </a>
785 </div>
786 <div class="blob-code" style="overflow-x: auto">
787 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
788 <tbody>
789 {blameLines.map((line, i) => {
790 const showInfo =
791 i === 0 || blameLines[i - 1].sha !== line.sha;
792 return (
793 <tr style="border-bottom: 1px solid var(--border)">
794 <td
795 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)" : ""}`}
796 >
797 {showInfo && (
798 <>
799 <a
800 href={`/${owner}/${repo}/commit/${line.sha}`}
801 style="color: var(--text-link); font-family: var(--font-mono)"
802 >
803 {line.sha.slice(0, 7)}
804 </a>{" "}
805 <span>{line.author}</span>
806 </>
807 )}
808 </td>
809 <td class="line-num">{line.lineNum}</td>
810 <td class="line-content">{line.content}</td>
811 </tr>
812 );
813 })}
814 </tbody>
815 </table>
816 </div>
817 </div>
818 </Layout>
819 );
820});
821
822// Search
823web.get("/:owner/:repo/search", async (c) => {
824 const { owner, repo } = c.req.param();
825 const user = c.get("user");
826 const q = c.req.query("q") || "";
827
828 if (!(await repoExists(owner, repo))) return c.notFound();
829
830 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
831 let results: Array<{ file: string; lineNum: number; line: string }> = [];
832
833 if (q.trim()) {
834 results = await searchCode(owner, repo, defaultBranch, q.trim());
835 }
836
837 return c.html(
838 <Layout title={`Search — ${owner}/${repo}`} user={user}>
839 <RepoHeader owner={owner} repo={repo} />
840 <RepoNav owner={owner} repo={repo} active="code" />
841 <form
842 method="GET"
843 action={`/${owner}/${repo}/search`}
844 style="margin-bottom: 20px"
845 >
846 <div style="display: flex; gap: 8px">
847 <input
848 type="text"
849 name="q"
850 value={q}
851 placeholder="Search code..."
852 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
853 />
854 <button type="submit" class="btn btn-primary">
855 Search
856 </button>
857 </div>
858 </form>
859 {q && (
860 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
861 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
862 <strong style="color: var(--text)">"{q}"</strong>
863 </p>
864 )}
865 {results.length > 0 && (
866 <div class="search-results">
867 {(() => {
868 // Group by file
869 const grouped: Record<
870 string,
871 Array<{ lineNum: number; line: string }>
872 > = {};
873 for (const r of results) {
874 if (!grouped[r.file]) grouped[r.file] = [];
875 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
876 }
877 return Object.entries(grouped).map(([file, matches]) => (
878 <div class="diff-file" style="margin-bottom: 12px">
879 <div class="diff-file-header">
880 <a
881 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
882 >
883 {file}
884 </a>
885 </div>
886 <div class="blob-code">
887 <table>
888 <tbody>
889 {matches.map((m) => (
890 <tr>
891 <td class="line-num">{m.lineNum}</td>
892 <td class="line-content">{m.line}</td>
893 </tr>
894 ))}
895 </tbody>
896 </table>
897 </div>
898 </div>
899 ));
900 })()}
901 </div>
902 )}
903 </Layout>
904 );
905});
906
fc1817aClaude907export default web;