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

components.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.

components.tsxBlame586 lines · 1 contributor
fc1817aClaude1import type { FC } from "hono/jsx";
06d5ffeClaude2import { html } from "hono/html";
fc1817aClaude3import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
06d5ffeClaude4import type { Repository } from "../db/schema";
fc1817aClaude5
8c790e0Claude6/**
7 * Describes the most recent push to a repo, used by RepoHeader to render
8 * the Push Watch discoverability indicator.
9 *
10 * - ageMs < 5 min \u2192 pulsing red "\u25cf Live" badge
11 * - ageMs < 24 hr \u2192 dimmer "\u25cb Watch" link
12 * - otherwise \u2192 nothing shown
13 */
14export interface RecentPush {
15 sha: string;
16 ageMs: number;
17}
18
06d5ffeClaude19export const RepoHeader: FC<{
20 owner: string;
21 repo: string;
22 starCount?: number;
23 starred?: boolean;
c81ab7aClaude24 forkCount?: number;
06d5ffeClaude25 currentUser?: string | null;
c81ab7aClaude26 forkedFrom?: string | null;
71cd5ecClaude27 archived?: boolean;
28 isTemplate?: boolean;
8c790e0Claude29 /** Most recent push info for Push Watch discoverability indicator. */
30 recentPush?: RecentPush | null;
71cd5ecClaude31}> = ({
32 owner,
33 repo,
34 starCount,
35 starred,
36 forkCount,
37 currentUser,
38 forkedFrom,
39 archived,
40 isTemplate,
8c790e0Claude41 recentPush,
42}) => {
43 const FIVE_MIN = 5 * 60 * 1000;
44 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
45 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
46 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
47
48 return (
49 <div class="repo-header">
50 <div>
51 <div class="repo-header-title">
52 <a href={`/${owner}`} class="owner">
53 {owner}
54 </a>
55 <span class="separator">/</span>
56 <a href={`/${owner}/${repo}`} class="name">
57 {repo}
58 </a>
59 {archived && (
60 <span
61 class="repo-header-pill repo-header-pill-archived"
62 title="Read-only: pushes and new issues/PRs disabled"
63 >
64 Archived
65 </span>
66 )}
67 {isTemplate && (
68 <span
69 class="repo-header-pill repo-header-pill-template"
70 title="This repository can be used as a template"
71 >
72 Template
73 </span>
74 )}
75 {isLive && recentPush && (
76 <a
77 href={`/${owner}/${repo}/push/${recentPush.sha}`}
78 class="repo-header-live-badge repo-header-live-badge--live"
79 title="Push in progress \u2014 watch live gate + deploy status"
80 aria-label="Live push \u2014 click to watch status"
81 >
82 <span class="repo-header-live-dot" aria-hidden="true">{"\u25cf"}</span>
83 Live
84 </a>
85 )}
86 {!isLive && isRecent && recentPush && (
87 <a
88 href={`/${owner}/${repo}/push/${recentPush.sha}`}
89 class="repo-header-live-badge repo-header-live-badge--recent"
90 title="Watch the most recent push's gate + deploy results"
91 aria-label="Watch most recent push"
92 >
93 <span aria-hidden="true">{"\u25cb"}</span>
94 Watch
95 </a>
96 )}
97 </div>
98 {forkedFrom && (
99 <div class="repo-header-fork">
100 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
101 </div>
71cd5ecClaude102 )}
c81ab7aClaude103 </div>
8c790e0Claude104 <div class="repo-header-actions">
105 {currentUser && currentUser !== owner && (
106 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
107 <button type="submit" class="star-btn">
108 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
06d5ffeClaude109 </button>
110 </form>
8c790e0Claude111 )}
112 {starCount !== undefined && (
113 currentUser ? (
114 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
115 <button
116 type="submit"
117 class={`star-btn${starred ? " starred" : ""}`}
118 >
119 {starred ? "\u2605" : "\u2606"} {starCount}
120 </button>
121 </form>
122 ) : (
123 <span class="star-btn">
124 {"\u2606"} {starCount}
125 </span>
126 )
127 )}
128 </div>
06d5ffeClaude129 </div>
8c790e0Claude130 );
131};
fc1817aClaude132
133export const RepoNav: FC<{
134 owner: string;
135 repo: string;
3ef4c9dClaude136 active:
137 | "code"
138 | "commits"
139 | "issues"
140 | "pulls"
141 | "releases"
eafe8c6Claude142 | "actions"
3ef4c9dClaude143 | "gates"
3cbe3d6Claude144 | "insights"
145 | "explain"
146 | "changelog"
1e162a8Claude147 | "semantic"
148 | "wiki"
0316dbbClaude149 | "projects"
ef3fd93Claude150 | "agents"
c645a86Claude151 | "discussions"
da3fc18Claude152 | "security"
c9ed210Claude153 | "deployments"
fa97fd1Claude154 | "settings"
b665cacClaude155 | "debt-map"
156 | "migrate";
fc1817aClaude157}> = ({ owner, repo, active }) => (
158 <div class="repo-nav">
06d5ffeClaude159 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
fc1817aClaude160 Code
161 </a>
79136bbClaude162 <a
163 href={`/${owner}/${repo}/issues`}
164 class={active === "issues" ? "active" : ""}
165 >
166 Issues
167 </a>
c645a86Claude168 <a
169 href={`/${owner}/${repo}/discussions`}
170 class={active === "discussions" ? "active" : ""}
171 >
172 Discussions
173 </a>
1e162a8Claude174 <a
175 href={`/${owner}/${repo}/wiki`}
176 class={active === "wiki" ? "active" : ""}
177 >
178 Wiki
179 </a>
0074234Claude180 <a
181 href={`/${owner}/${repo}/pulls`}
182 class={active === "pulls" ? "active" : ""}
183 >
184 Pull Requests
185 </a>
1e162a8Claude186 <a
187 href={`/${owner}/${repo}/projects`}
188 class={active === "projects" ? "active" : ""}
189 >
190 Projects
191 </a>
fc1817aClaude192 <a
193 href={`/${owner}/${repo}/commits`}
194 class={active === "commits" ? "active" : ""}
195 >
196 Commits
197 </a>
eafe8c6Claude198 <a
199 href={`/${owner}/${repo}/actions`}
200 class={active === "actions" ? "active" : ""}
201 >
202 Actions
203 </a>
3ef4c9dClaude204 <a
205 href={`/${owner}/${repo}/releases`}
206 class={active === "releases" ? "active" : ""}
207 >
208 Releases
209 </a>
210 <a
211 href={`/${owner}/${repo}/gates`}
212 class={active === "gates" ? "active" : ""}
213 >
214 {"\u25CF"} Gates
215 </a>
da3fc18Claude216 <a
217 href={`/${owner}/${repo}/security/vulnerabilities`}
218 class={active === "security" ? "active" : ""}
219 >
220 Security
221 </a>
c9ed210Claude222 <a
223 href={`/${owner}/${repo}/cloud-deployments`}
224 class={active === "deployments" ? "active" : ""}
225 >
226 Deployments
227 </a>
3ef4c9dClaude228 <a
229 href={`/${owner}/${repo}/insights`}
230 class={active === "insights" ? "active" : ""}
231 >
232 Insights
233 </a>
ef3fd93Claude234 <a
235 href={`/${owner}/${repo}/agents`}
236 class={active === "agents" ? "active" : ""}
237 >
238 Agents
239 </a>
3cbe3d6Claude240 <a
241 href={`/${owner}/${repo}/explain`}
debcf27Claude242 class={`repo-nav-ai${active === "explain" ? " active" : ""}`}
243 style="margin-left: auto"
3cbe3d6Claude244 >
245 {"\u2728"} Explain
246 </a>
debcf27Claude247 <a href={`/${owner}/${repo}/ask`} class="repo-nav-ai">
3ef4c9dClaude248 {"\u2728"} Ask AI
249 </a>
14c3cc8Claude250 <a
251 href={`/${owner}/${repo}/spec`}
debcf27Claude252 class="repo-nav-ai"
14c3cc8Claude253 title="Spec to PR — paste a feature spec, AI opens a draft PR"
254 >
255 {"\u2728"} Spec
256 </a>
d8ef5efClaude257 <a
258 href={`/${owner}/${repo}/ai/tests`}
debcf27Claude259 class="repo-nav-ai"
d8ef5efClaude260 title="AI Tests \u2014 generate failing test stubs from a source file"
261 >
262 {"\u2728"} Tests
263 </a>
fa97fd1Claude264 <a
265 href={`/${owner}/${repo}/debt-map`}
266 class={`repo-nav-ai${active === "debt-map" ? " active" : ""}`}
267 title="AI Debt Map \u2014 visual technical debt graph with Claude analysis"
268 >
269 {"\u2593"} Debt Map
270 </a>
b665cacClaude271 <a
272 href={`/${owner}/${repo}/migrate`}
273 class={`repo-nav-ai${active === "migrate" ? " active" : ""}`}
274 title="AI Codebase Migration \u2014 one-click language or framework translation"
275 >
276 {"\u2728"} Migrate
277 </a>
fc1817aClaude278 </div>
279);
280
06d5ffeClaude281export const BranchSwitcher: FC<{
282 owner: string;
283 repo: string;
284 currentRef: string;
285 branches: string[];
286 pathType: "tree" | "blob" | "commits";
287 subPath?: string;
288}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
289 if (branches.length <= 1) {
290 return <div class="branch-selector">{currentRef}</div>;
291 }
292
293 return (
294 <div class="branch-dropdown">
295 <button class="branch-selector" type="button">
296 {currentRef} &#9662;
297 </button>
298 <div class="branch-dropdown-content">
299 {branches.map((branch) => {
300 let href: string;
301 if (pathType === "commits") {
302 href = `/${owner}/${repo}/commits/${branch}`;
303 } else if (subPath) {
304 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
305 } else {
306 href = `/${owner}/${repo}/tree/${branch}`;
307 }
308 return (
309 <a
310 href={href}
311 class={branch === currentRef ? "active-branch" : ""}
312 >
313 {branch}
314 </a>
315 );
316 })}
317 </div>
318 </div>
319 );
320};
321
fc1817aClaude322export const Breadcrumb: FC<{
323 owner: string;
324 repo: string;
325 ref: string;
326 path: string;
327}> = ({ owner, repo, ref, path }) => {
328 const parts = path.split("/").filter(Boolean);
329 const crumbs: { name: string; href: string }[] = [
330 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
331 ];
332 let accumulated = "";
333 for (const part of parts) {
334 accumulated += (accumulated ? "/" : "") + part;
335 crumbs.push({
336 name: part,
337 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
338 });
339 }
340 return (
341 <div class="breadcrumb">
342 {crumbs.map((crumb, i) => (
343 <>
344 {i > 0 && <span>/</span>}
345 {i === crumbs.length - 1 ? (
346 <strong>{crumb.name}</strong>
347 ) : (
348 <a href={crumb.href}>{crumb.name}</a>
349 )}
350 </>
351 ))}
352 </div>
353 );
354};
355
356export const FileTable: FC<{
357 entries: GitTreeEntry[];
358 owner: string;
359 repo: string;
360 ref: string;
361 path: string;
362}> = ({ entries, owner, repo, ref, path }) => (
363 <table class="file-table">
364 <tbody>
365 {entries.map((entry) => {
366 const fullPath = path ? `${path}/${entry.name}` : entry.name;
367 const href =
368 entry.type === "tree"
369 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
370 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
371 return (
372 <tr>
373 <td class="file-icon">
374 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
375 </td>
376 <td class="file-name">
377 <a href={href}>{entry.name}</a>
378 </td>
379 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
380 {entry.size !== undefined ? formatSize(entry.size) : ""}
381 </td>
382 </tr>
383 );
384 })}
385 </tbody>
386 </table>
387);
388
06d5ffeClaude389export const HighlightedCode: FC<{
390 highlightedHtml: string;
391 lineCount: number;
392}> = ({ highlightedHtml, lineCount }) => {
393 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
394 return (
395 <div class="blob-code">
396 <table>
397 <tbody>
398 <tr>
399 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
400 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
401 {lineNums.map((n) => (
402 <>
403 <span>{n}</span>
404 {"\n"}
405 </>
406 ))}
407 </pre>
408 </td>
409 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
410 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
411 </td>
412 </tr>
413 </tbody>
414 </table>
415 </div>
416 );
417};
418
419export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
420 <div class="blob-code">
421 <table>
422 <tbody>
423 {lines.map((line, i) => (
424 <tr>
425 <td class="line-num">{i + 1}</td>
426 <td class="line-content">{line}</td>
427 </tr>
428 ))}
429 </tbody>
430 </table>
431 </div>
432);
433
fc1817aClaude434export const CommitList: FC<{
435 commits: GitCommit[];
436 owner: string;
437 repo: string;
3951454Claude438 verifications?: Record<string, { verified: boolean; reason: string }>;
439}> = ({ commits, owner, repo, verifications }) => (
fc1817aClaude440 <div class="commit-list">
3951454Claude441 {commits.map((commit) => {
442 const v = verifications?.[commit.sha];
443 return (
444 <div class="commit-item">
445 <div>
446 <div class="commit-message">
447 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
448 {commit.message}
449 </a>
450 {v?.verified && (
451 <span
452 title="Signed with a registered key"
453 style="margin-left:8px;font-size:10px;padding:1px 6px;border-radius:3px;background:var(--green,#2ea043);color:#fff;text-transform:uppercase;letter-spacing:.4px"
454 >
455 Verified
456 </span>
457 )}
458 </div>
459 <div class="commit-meta">
460 {commit.author} committed {formatRelativeDate(commit.date)}
461 </div>
fc1817aClaude462 </div>
3951454Claude463 <a
464 href={`/${owner}/${repo}/commit/${commit.sha}`}
465 class="commit-sha"
466 >
467 {commit.sha.slice(0, 7)}
468 </a>
fc1817aClaude469 </div>
3951454Claude470 );
471 })}
fc1817aClaude472 </div>
473);
474
475export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
476 raw,
477 files,
478}) => {
479 const sections = parseDiff(raw);
480
481 return (
482 <div class="diff-view">
483 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
484 Showing{" "}
485 <strong style="color: var(--text)">{files.length}</strong> changed
486 file{files.length !== 1 ? "s" : ""} with{" "}
487 <span class="stat-add">
488 +{files.reduce((s, f) => s + f.additions, 0)}
489 </span>{" "}
490 and{" "}
491 <span class="stat-del">
492 -{files.reduce((s, f) => s + f.deletions, 0)}
493 </span>
494 </div>
495 {sections.map((section) => (
496 <div class="diff-file">
497 <div class="diff-file-header">{section.path}</div>
498 <div class="diff-content">
499 {section.lines.map((line) => {
500 let cls = "line";
501 if (line.startsWith("+")) cls += " line-add";
502 else if (line.startsWith("-")) cls += " line-del";
503 else if (line.startsWith("@@")) cls += " line-hunk";
504 return <span class={cls}>{line + "\n"}</span>;
505 })}
506 </div>
507 </div>
508 ))}
509 </div>
510 );
511};
512
06d5ffeClaude513export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
514 repo,
515 ownerName,
516}) => (
517 <div class="card">
518 <h3>
519 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
520 </h3>
521 {repo.description && <p>{repo.description}</p>}
522 <div class="card-meta">
523 {repo.isPrivate && <span class="badge">Private</span>}
524 <span>{"\u2606"} {repo.starCount}</span>
525 {repo.pushedAt && (
526 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
527 )}
528 </div>
529 </div>
530);
531
fc1817aClaude532function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
533 const sections: Array<{ path: string; lines: string[] }> = [];
534 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
535 let current: { path: string; lines: string[] } | null = null;
536
537 for (const line of raw.split("\n")) {
538 const match = line.match(diffRegex);
539 if (match) {
540 if (current) sections.push(current);
541 current = { path: match[1], lines: [] };
542 continue;
543 }
544 if (current && !line.startsWith("diff --git")) {
545 if (
546 line.startsWith("index ") ||
547 line.startsWith("--- ") ||
548 line.startsWith("+++ ") ||
549 line.startsWith("new file") ||
550 line.startsWith("deleted file") ||
551 line.startsWith("old mode") ||
552 line.startsWith("new mode")
553 ) {
554 continue;
555 }
556 current.lines.push(line);
557 }
558 }
559 if (current) sections.push(current);
560 return sections;
561}
562
563function formatSize(bytes: number): string {
564 if (bytes < 1024) return `${bytes} B`;
565 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
566 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
567}
568
569function formatRelativeDate(dateStr: string): string {
570 const date = new Date(dateStr);
571 const now = new Date();
572 const diffMs = now.getTime() - date.getTime();
573 const diffMins = Math.floor(diffMs / 60000);
574 if (diffMins < 1) return "just now";
575 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
576 const diffHours = Math.floor(diffMins / 60);
577 if (diffHours < 24)
578 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
579 const diffDays = Math.floor(diffHours / 24);
580 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
581 return date.toLocaleDateString("en-US", {
582 month: "short",
583 day: "numeric",
584 year: "numeric",
585 });
586}