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.tsxBlame922 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) {
55 // Show user's repos
56 const repos = await db
57 .select()
58 .from(repositories)
59 .where(eq(repositories.ownerId, user.id))
60 .orderBy(desc(repositories.updatedAt));
61
62 return c.html(
63 <Layout title="Dashboard" user={user}>
64 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
65 <h2>Your repositories</h2>
66 <a href="/new" class="btn btn-primary">
67 + New repository
68 </a>
69 </div>
70 {repos.length === 0 ? (
71 <div class="empty-state">
72 <h2>No repositories yet</h2>
73 <p>Create your first repository to get started.</p>
74 </div>
75 ) : (
76 <div class="card-grid">
77 {repos.map((repo) => (
78 <RepoCard repo={repo} ownerName={user.username} />
79 ))}
80 </div>
81 )}
82 </Layout>
83 );
84 }
85
fc1817aClaude86 return c.html(
06d5ffeClaude87 <Layout user={null}>
fc1817aClaude88 <div class="empty-state">
89 <h2>gluecron</h2>
90 <p>AI-native code intelligence platform</p>
06d5ffeClaude91 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
92 <a href="/register" class="btn btn-primary">
93 Get started
94 </a>
95 <a href="/login" class="btn">
96 Sign in
97 </a>
98 </div>
99 <pre style="margin-top: 32px">{`# Quick start
fc1817aClaude100curl -X POST http://localhost:3000/api/setup \\
101 -H 'Content-Type: application/json' \\
102 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
103
104git remote add gluecron http://localhost:3000/you/hello.git
105git push gluecron main`}</pre>
106 </div>
107 </Layout>
108 );
109});
110
06d5ffeClaude111// New repository form
112web.get("/new", requireAuth, (c) => {
113 const user = c.get("user")!;
114 const error = c.req.query("error");
115
116 return c.html(
117 <Layout title="New repository" user={user}>
118 <div class="new-repo-form">
119 <h2>Create a new repository</h2>
120 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
121 <form method="POST" action="/new">
122 <div class="form-group">
123 <label>Owner</label>
124 <input type="text" value={user.username} disabled class="input-disabled" />
125 </div>
126 <div class="form-group">
127 <label for="name">Repository name</label>
128 <input
129 type="text"
130 id="name"
131 name="name"
132 required
133 pattern="^[a-zA-Z0-9._-]+$"
134 placeholder="my-project"
135 autocomplete="off"
136 />
137 </div>
138 <div class="form-group">
139 <label for="description">Description (optional)</label>
140 <input
141 type="text"
142 id="description"
143 name="description"
144 placeholder="A short description of your repository"
145 />
146 </div>
147 <div class="visibility-options">
148 <label class="visibility-option">
149 <input type="radio" name="visibility" value="public" checked />
150 <div class="vis-label">Public</div>
151 <div class="vis-desc">Anyone can see this repository</div>
152 </label>
153 <label class="visibility-option">
154 <input type="radio" name="visibility" value="private" />
155 <div class="vis-label">Private</div>
156 <div class="vis-desc">Only you can see this repository</div>
157 </label>
158 </div>
159 <button type="submit" class="btn btn-primary">
160 Create repository
161 </button>
162 </form>
163 </div>
164 </Layout>
165 );
166});
167
168web.post("/new", requireAuth, async (c) => {
169 const user = c.get("user")!;
170 const body = await c.req.parseBody();
171 const name = String(body.name || "").trim();
172 const description = String(body.description || "").trim();
173 const isPrivate = body.visibility === "private";
174
175 if (!name) {
176 return c.redirect("/new?error=Repository+name+is+required");
177 }
178
179 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
180 return c.redirect("/new?error=Invalid+repository+name");
181 }
182
183 if (await repoExists(user.username, name)) {
184 return c.redirect("/new?error=Repository+already+exists");
185 }
186
187 const diskPath = await initBareRepo(user.username, name);
188
189 await db.insert(repositories).values({
190 name,
191 ownerId: user.id,
192 description: description || null,
193 isPrivate,
194 diskPath,
195 });
196
197 return c.redirect(`/${user.username}/${name}`);
198});
199
200// User profile
fc1817aClaude201web.get("/:owner", async (c) => {
06d5ffeClaude202 const { owner: ownerName } = c.req.param();
203 const user = c.get("user");
204
205 // Avoid clashing with fixed routes
206 if (
207 ["login", "register", "logout", "new", "settings", "api"].includes(
208 ownerName
209 )
210 ) {
211 return c.notFound();
212 }
213
214 let ownerUser;
215 try {
216 const [found] = await db
217 .select()
218 .from(users)
219 .where(eq(users.username, ownerName))
220 .limit(1);
221 ownerUser = found;
222 } catch {
223 // DB not available — check if repos exist on disk
224 ownerUser = null;
225 }
226
227 // Even without DB, show repos if they exist on disk
228 let repos: any[] = [];
229 if (ownerUser) {
230 const allRepos = await db
231 .select()
232 .from(repositories)
233 .where(eq(repositories.ownerId, ownerUser.id))
234 .orderBy(desc(repositories.updatedAt));
235
236 // Show public repos to everyone, private only to owner
237 repos =
238 user?.id === ownerUser.id
239 ? allRepos
240 : allRepos.filter((r) => !r.isPrivate);
241 }
242
fc1817aClaude243 return c.html(
06d5ffeClaude244 <Layout title={ownerName} user={user}>
245 <div class="user-profile">
246 <div class="user-avatar">
247 {(ownerUser?.displayName || ownerName)[0].toUpperCase()}
248 </div>
249 <div class="user-info">
250 <h2>{ownerUser?.displayName || ownerName}</h2>
251 <div class="username">@{ownerName}</div>
252 {ownerUser?.bio && <div class="bio">{ownerUser.bio}</div>}
253 </div>
254 </div>
255 <h3 style="margin-bottom: 16px">Repositories</h3>
256 {repos.length === 0 ? (
257 <p style="color: var(--text-muted)">No repositories yet.</p>
258 ) : (
259 <div class="card-grid">
260 {repos.map((repo) => (
261 <RepoCard repo={repo} ownerName={ownerName} />
262 ))}
263 </div>
264 )}
fc1817aClaude265 </Layout>
266 );
267});
268
06d5ffeClaude269// Star/unstar a repo
270web.post("/:owner/:repo/star", requireAuth, async (c) => {
271 const { owner: ownerName, repo: repoName } = c.req.param();
272 const user = c.get("user")!;
273
274 try {
275 const [ownerUser] = await db
276 .select()
277 .from(users)
278 .where(eq(users.username, ownerName))
279 .limit(1);
280 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
281
282 const [repo] = await db
283 .select()
284 .from(repositories)
285 .where(
286 and(
287 eq(repositories.ownerId, ownerUser.id),
288 eq(repositories.name, repoName)
289 )
290 )
291 .limit(1);
292 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
293
294 // Toggle star
295 const [existing] = await db
296 .select()
297 .from(stars)
298 .where(
299 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
300 )
301 .limit(1);
302
303 if (existing) {
304 await db.delete(stars).where(eq(stars.id, existing.id));
305 await db
306 .update(repositories)
307 .set({ starCount: Math.max(0, repo.starCount - 1) })
308 .where(eq(repositories.id, repo.id));
309 } else {
310 await db.insert(stars).values({
311 userId: user.id,
312 repositoryId: repo.id,
313 });
314 await db
315 .update(repositories)
316 .set({ starCount: repo.starCount + 1 })
317 .where(eq(repositories.id, repo.id));
318 }
319 } catch {
320 // DB error — ignore
321 }
322
323 return c.redirect(`/${ownerName}/${repoName}`);
324});
325
fc1817aClaude326// Repository overview — file tree at HEAD
327web.get("/:owner/:repo", async (c) => {
328 const { owner, repo } = c.req.param();
06d5ffeClaude329 const user = c.get("user");
fc1817aClaude330
331 if (!(await repoExists(owner, repo))) {
332 return c.html(
06d5ffeClaude333 <Layout title="Not Found" user={user}>
fc1817aClaude334 <div class="empty-state">
335 <h2>Repository not found</h2>
336 <p>
337 {owner}/{repo} does not exist.
338 </p>
339 </div>
340 </Layout>,
341 404
342 );
343 }
344
05b973eClaude345 // Parallelize all independent operations
346 const [defaultBranch, branches] = await Promise.all([
347 getDefaultBranch(owner, repo).then((b) => b || "main"),
348 listBranches(owner, repo),
349 ]);
350 const [tree, starInfo] = await Promise.all([
351 getTree(owner, repo, defaultBranch),
352 // Star info fetched in parallel with tree
353 (async () => {
354 try {
355 const [ownerUser] = await db
356 .select()
357 .from(users)
358 .where(eq(users.username, owner))
359 .limit(1);
360 if (!ownerUser) return { starCount: 0, starred: false };
361 const [repoRow] = await db
362 .select()
363 .from(repositories)
364 .where(
365 and(
366 eq(repositories.ownerId, ownerUser.id),
367 eq(repositories.name, repo)
368 )
06d5ffeClaude369 )
05b973eClaude370 .limit(1);
371 if (!repoRow) return { starCount: 0, starred: false };
372 let starred = false;
06d5ffeClaude373 if (user) {
374 const [star] = await db
375 .select()
376 .from(stars)
377 .where(
378 and(
379 eq(stars.userId, user.id),
380 eq(stars.repositoryId, repoRow.id)
381 )
382 )
383 .limit(1);
384 starred = !!star;
385 }
05b973eClaude386 return { starCount: repoRow.starCount, starred };
387 } catch {
388 return { starCount: 0, starred: false };
06d5ffeClaude389 }
05b973eClaude390 })(),
391 ]);
392 const { starCount, starred } = starInfo;
06d5ffeClaude393
fc1817aClaude394 if (tree.length === 0) {
395 return c.html(
06d5ffeClaude396 <Layout title={`${owner}/${repo}`} user={user}>
397 <RepoHeader
398 owner={owner}
399 repo={repo}
400 starCount={starCount}
401 starred={starred}
402 currentUser={user?.username}
403 />
fc1817aClaude404 <RepoNav owner={owner} repo={repo} active="code" />
405 <div class="empty-state">
406 <h2>Empty repository</h2>
407 <p>Get started by pushing code:</p>
408 <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git
409git push -u gluecron main`}</pre>
410 </div>
411 </Layout>
412 );
413 }
414
415 const readme = await getReadme(owner, repo, defaultBranch);
416
417 return c.html(
06d5ffeClaude418 <Layout title={`${owner}/${repo}`} user={user}>
419 <RepoHeader
420 owner={owner}
421 repo={repo}
422 starCount={starCount}
423 starred={starred}
424 currentUser={user?.username}
425 />
fc1817aClaude426 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude427 <BranchSwitcher
428 owner={owner}
429 repo={repo}
430 currentRef={defaultBranch}
431 branches={branches}
432 pathType="tree"
433 />
fc1817aClaude434 <FileTable
435 entries={tree}
436 owner={owner}
437 repo={repo}
438 ref={defaultBranch}
439 path=""
440 />
79136bbClaude441 {readme && (() => {
442 const readmeHtml = renderMarkdown(readme);
443 return (
444 <div class="blob-view" style="margin-top: 20px">
445 <div class="blob-header">README.md</div>
446 <style>{markdownCss}</style>
447 <div class="markdown-body">
448 {html([readmeHtml] as unknown as TemplateStringsArray)}
449 </div>
fc1817aClaude450 </div>
79136bbClaude451 );
452 })()}
fc1817aClaude453 </Layout>
454 );
455});
456
457// Browse tree at ref/path
458web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
459 const { owner, repo } = c.req.param();
06d5ffeClaude460 const user = c.get("user");
fc1817aClaude461 const refAndPath = c.req.param("ref");
462
463 const branches = await listBranches(owner, repo);
464 let ref = "";
465 let treePath = "";
466
467 for (const branch of branches) {
468 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
469 ref = branch;
470 treePath = refAndPath.slice(branch.length + 1);
471 break;
472 }
473 }
474
475 if (!ref) {
476 const slashIdx = refAndPath.indexOf("/");
477 if (slashIdx === -1) {
478 ref = refAndPath;
479 } else {
480 ref = refAndPath.slice(0, slashIdx);
481 treePath = refAndPath.slice(slashIdx + 1);
482 }
483 }
484
485 const tree = await getTree(owner, repo, ref, treePath);
486
487 return c.html(
06d5ffeClaude488 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
fc1817aClaude489 <RepoHeader owner={owner} repo={repo} />
490 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude491 <BranchSwitcher
492 owner={owner}
493 repo={repo}
494 currentRef={ref}
495 branches={branches}
496 pathType="tree"
497 subPath={treePath}
498 />
fc1817aClaude499 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
500 <FileTable
501 entries={tree}
502 owner={owner}
503 repo={repo}
504 ref={ref}
505 path={treePath}
506 />
507 </Layout>
508 );
509});
510
06d5ffeClaude511// View file blob with syntax highlighting
fc1817aClaude512web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
513 const { owner, repo } = c.req.param();
06d5ffeClaude514 const user = c.get("user");
fc1817aClaude515 const refAndPath = c.req.param("ref");
516
517 const branches = await listBranches(owner, repo);
518 let ref = "";
519 let filePath = "";
520
521 for (const branch of branches) {
522 if (refAndPath.startsWith(branch + "/")) {
523 ref = branch;
524 filePath = refAndPath.slice(branch.length + 1);
525 break;
526 }
527 }
528
529 if (!ref) {
530 const slashIdx = refAndPath.indexOf("/");
531 if (slashIdx === -1) return c.text("Not found", 404);
532 ref = refAndPath.slice(0, slashIdx);
533 filePath = refAndPath.slice(slashIdx + 1);
534 }
535
536 const blob = await getBlob(owner, repo, ref, filePath);
537 if (!blob) {
538 return c.html(
06d5ffeClaude539 <Layout title="Not Found" user={user}>
fc1817aClaude540 <div class="empty-state">
541 <h2>File not found</h2>
542 </div>
543 </Layout>,
544 404
545 );
546 }
547
06d5ffeClaude548 const fileName = filePath.split("/").pop() || filePath;
fc1817aClaude549
550 return c.html(
06d5ffeClaude551 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
fc1817aClaude552 <RepoHeader owner={owner} repo={repo} />
553 <RepoNav owner={owner} repo={repo} active="code" />
06d5ffeClaude554 <BranchSwitcher
555 owner={owner}
556 repo={repo}
557 currentRef={ref}
558 branches={branches}
559 pathType="blob"
560 subPath={filePath}
561 />
fc1817aClaude562 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
563 <div class="blob-view">
564 <div class="blob-header">
06d5ffeClaude565 <span>{fileName} — {blob.size} bytes</span>
79136bbClaude566 <span style="display: flex; gap: 12px">
567 <a href={`/${owner}/${repo}/raw/${ref}/${filePath}`} style="font-size: 12px">
568 Raw
569 </a>
570 <a href={`/${owner}/${repo}/blame/${ref}/${filePath}`} style="font-size: 12px">
571 Blame
572 </a>
0074234Claude573 {user && (
574 <a href={`/${owner}/${repo}/edit/${ref}/${filePath}`} style="font-size: 12px">
575 Edit
576 </a>
577 )}
79136bbClaude578 </span>
fc1817aClaude579 </div>
580 {blob.isBinary ? (
581 <div style="padding: 16px; color: var(--text-muted)">
582 Binary file not shown.
583 </div>
06d5ffeClaude584 ) : (() => {
585 const { html: highlighted, language } = highlightCode(
586 blob.content,
587 fileName
588 );
589 const lineCount = blob.content.split("\n").length;
590 // Trim trailing newline from count
591 const adjustedCount =
592 blob.content.endsWith("\n") ? lineCount - 1 : lineCount;
593
594 if (language) {
595 return (
596 <HighlightedCode
597 highlightedHtml={highlighted}
598 lineCount={adjustedCount}
599 />
600 );
601 }
602 const lines = blob.content.split("\n");
603 if (lines[lines.length - 1] === "") lines.pop();
604 return <PlainCode lines={lines} />;
605 })()}
fc1817aClaude606 </div>
607 </Layout>
608 );
609});
610
611// Commit log
612web.get("/:owner/:repo/commits/:ref?", async (c) => {
613 const { owner, repo } = c.req.param();
06d5ffeClaude614 const user = c.get("user");
fc1817aClaude615 const ref =
616 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude617 const branches = await listBranches(owner, repo);
fc1817aClaude618
619 const commits = await listCommits(owner, repo, ref, 50);
620
621 return c.html(
06d5ffeClaude622 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
fc1817aClaude623 <RepoHeader owner={owner} repo={repo} />
624 <RepoNav owner={owner} repo={repo} active="commits" />
06d5ffeClaude625 <BranchSwitcher
626 owner={owner}
627 repo={repo}
628 currentRef={ref}
629 branches={branches}
630 pathType="commits"
631 />
fc1817aClaude632 {commits.length === 0 ? (
633 <div class="empty-state">
634 <p>No commits yet.</p>
635 </div>
636 ) : (
637 <CommitList commits={commits} owner={owner} repo={repo} />
638 )}
639 </Layout>
640 );
641});
642
643// Single commit with diff
644web.get("/:owner/:repo/commit/:sha", async (c) => {
645 const { owner, repo, sha } = c.req.param();
06d5ffeClaude646 const user = c.get("user");
fc1817aClaude647
05b973eClaude648 // Fetch commit, full message, and diff in parallel
649 const [commit, fullMessage, diffResult] = await Promise.all([
650 getCommit(owner, repo, sha),
651 getCommitFullMessage(owner, repo, sha),
652 getDiff(owner, repo, sha),
653 ]);
fc1817aClaude654 if (!commit) {
655 return c.html(
06d5ffeClaude656 <Layout title="Not Found" user={user}>
fc1817aClaude657 <div class="empty-state">
658 <h2>Commit not found</h2>
659 </div>
660 </Layout>,
661 404
662 );
663 }
664
05b973eClaude665 const { files, raw } = diffResult;
fc1817aClaude666
667 return c.html(
06d5ffeClaude668 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
fc1817aClaude669 <RepoHeader owner={owner} repo={repo} />
670 <div
671 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px"
672 >
673 <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px">
674 {commit.message}
675 </div>
676 {fullMessage !== commit.message && (
677 <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px">
678 {fullMessage}
679 </div>
680 )}
681 <div style="font-size: 13px; color: var(--text-muted)">
682 <strong style="color: var(--text)">{commit.author}</strong>{" "}
683 committed on{" "}
684 {new Date(commit.date).toLocaleDateString("en-US", {
685 month: "long",
686 day: "numeric",
687 year: "numeric",
688 })}
689 </div>
690 <div style="margin-top: 8px">
691 <span class="commit-sha">{commit.sha}</span>
692 {commit.parentShas.length > 0 && (
693 <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)">
694 Parent:{" "}
695 {commit.parentShas.map((p) => (
696 <a
697 href={`/${owner}/${repo}/commit/${p}`}
698 class="commit-sha"
699 style="margin-left: 4px"
700 >
701 {p.slice(0, 7)}
702 </a>
703 ))}
704 </span>
705 )}
706 </div>
707 </div>
708 <DiffView raw={raw} files={files} />
709 </Layout>
710 );
711});
712
79136bbClaude713// Raw file download
714web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
715 const { owner, repo } = c.req.param();
716 const refAndPath = c.req.param("ref");
717
718 const branches = await listBranches(owner, repo);
719 let ref = "";
720 let filePath = "";
721
722 for (const branch of branches) {
723 if (refAndPath.startsWith(branch + "/")) {
724 ref = branch;
725 filePath = refAndPath.slice(branch.length + 1);
726 break;
727 }
728 }
729
730 if (!ref) {
731 const slashIdx = refAndPath.indexOf("/");
732 if (slashIdx === -1) return c.text("Not found", 404);
733 ref = refAndPath.slice(0, slashIdx);
734 filePath = refAndPath.slice(slashIdx + 1);
735 }
736
737 const data = await getRawBlob(owner, repo, ref, filePath);
738 if (!data) return c.text("Not found", 404);
739
740 const fileName = filePath.split("/").pop() || "file";
741 return new Response(data, {
742 headers: {
743 "Content-Type": "application/octet-stream",
744 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude745 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude746 },
747 });
748});
749
750// Blame view
751web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
752 const { owner, repo } = c.req.param();
753 const user = c.get("user");
754 const refAndPath = c.req.param("ref");
755
756 const branches = await listBranches(owner, repo);
757 let ref = "";
758 let filePath = "";
759
760 for (const branch of branches) {
761 if (refAndPath.startsWith(branch + "/")) {
762 ref = branch;
763 filePath = refAndPath.slice(branch.length + 1);
764 break;
765 }
766 }
767
768 if (!ref) {
769 const slashIdx = refAndPath.indexOf("/");
770 if (slashIdx === -1) return c.text("Not found", 404);
771 ref = refAndPath.slice(0, slashIdx);
772 filePath = refAndPath.slice(slashIdx + 1);
773 }
774
775 const blameLines = await getBlame(owner, repo, ref, filePath);
776 if (blameLines.length === 0) {
777 return c.html(
778 <Layout title="Not Found" user={user}>
779 <div class="empty-state">
780 <h2>File not found</h2>
781 </div>
782 </Layout>,
783 404
784 );
785 }
786
787 const fileName = filePath.split("/").pop() || filePath;
788
789 return c.html(
790 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
791 <RepoHeader owner={owner} repo={repo} />
792 <RepoNav owner={owner} repo={repo} active="code" />
793 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
794 <div class="blob-view">
795 <div class="blob-header">
796 <span>{fileName} — blame</span>
797 <a href={`/${owner}/${repo}/blob/${ref}/${filePath}`} style="font-size: 12px">
798 Normal view
799 </a>
800 </div>
801 <div class="blob-code" style="overflow-x: auto">
802 <table style="width: 100%; border-collapse: collapse; font-size: 13px; font-family: var(--font-mono)">
803 <tbody>
804 {blameLines.map((line, i) => {
805 const showInfo =
806 i === 0 || blameLines[i - 1].sha !== line.sha;
807 return (
808 <tr style="border-bottom: 1px solid var(--border)">
809 <td
810 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)" : ""}`}
811 >
812 {showInfo && (
813 <>
814 <a
815 href={`/${owner}/${repo}/commit/${line.sha}`}
816 style="color: var(--text-link); font-family: var(--font-mono)"
817 >
818 {line.sha.slice(0, 7)}
819 </a>{" "}
820 <span>{line.author}</span>
821 </>
822 )}
823 </td>
824 <td class="line-num">{line.lineNum}</td>
825 <td class="line-content">{line.content}</td>
826 </tr>
827 );
828 })}
829 </tbody>
830 </table>
831 </div>
832 </div>
833 </Layout>
834 );
835});
836
837// Search
838web.get("/:owner/:repo/search", async (c) => {
839 const { owner, repo } = c.req.param();
840 const user = c.get("user");
841 const q = c.req.query("q") || "";
842
843 if (!(await repoExists(owner, repo))) return c.notFound();
844
845 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
846 let results: Array<{ file: string; lineNum: number; line: string }> = [];
847
848 if (q.trim()) {
849 results = await searchCode(owner, repo, defaultBranch, q.trim());
850 }
851
852 return c.html(
853 <Layout title={`Search — ${owner}/${repo}`} user={user}>
854 <RepoHeader owner={owner} repo={repo} />
855 <RepoNav owner={owner} repo={repo} active="code" />
856 <form
857 method="GET"
858 action={`/${owner}/${repo}/search`}
859 style="margin-bottom: 20px"
860 >
861 <div style="display: flex; gap: 8px">
862 <input
863 type="text"
864 name="q"
865 value={q}
866 placeholder="Search code..."
867 style="flex: 1; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px"
868 />
869 <button type="submit" class="btn btn-primary">
870 Search
871 </button>
872 </div>
873 </form>
874 {q && (
875 <p style="font-size: 14px; color: var(--text-muted); margin-bottom: 16px">
876 {results.length} result{results.length !== 1 ? "s" : ""} for{" "}
877 <strong style="color: var(--text)">"{q}"</strong>
878 </p>
879 )}
880 {results.length > 0 && (
881 <div class="search-results">
882 {(() => {
883 // Group by file
884 const grouped: Record<
885 string,
886 Array<{ lineNum: number; line: string }>
887 > = {};
888 for (const r of results) {
889 if (!grouped[r.file]) grouped[r.file] = [];
890 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
891 }
892 return Object.entries(grouped).map(([file, matches]) => (
893 <div class="diff-file" style="margin-bottom: 12px">
894 <div class="diff-file-header">
895 <a
896 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
897 >
898 {file}
899 </a>
900 </div>
901 <div class="blob-code">
902 <table>
903 <tbody>
904 {matches.map((m) => (
905 <tr>
906 <td class="line-num">{m.lineNum}</td>
907 <td class="line-content">{m.line}</td>
908 </tr>
909 ))}
910 </tbody>
911 </table>
912 </div>
913 </div>
914 ));
915 })()}
916 </div>
917 )}
918 </Layout>
919 );
920});
921
fc1817aClaude922export default web;