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.tsxBlame428 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
06d5ffeClaude6export const RepoHeader: FC<{
7 owner: string;
8 repo: string;
9 starCount?: number;
10 starred?: boolean;
c81ab7aClaude11 forkCount?: number;
06d5ffeClaude12 currentUser?: string | null;
c81ab7aClaude13 forkedFrom?: string | null;
14}> = ({ owner, repo, starCount, starred, forkCount, currentUser, forkedFrom }) => (
fc1817aClaude15 <div class="repo-header">
c81ab7aClaude16 <div>
17 <div style="display: flex; align-items: center; gap: 8px; font-size: 20px">
18 <a href={`/${owner}`} class="owner">
19 {owner}
20 </a>
21 <span class="separator">/</span>
22 <a href={`/${owner}/${repo}`} class="name">
23 {repo}
24 </a>
25 </div>
26 {forkedFrom && (
27 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
28 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
29 </div>
30 )}
31 </div>
06d5ffeClaude32 <div class="repo-header-actions">
c81ab7aClaude33 {currentUser && currentUser !== owner && (
34 <form method="POST" action={`/${owner}/${repo}/fork`} style="display:inline">
35 <button type="submit" class="star-btn">
36 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
37 </button>
38 </form>
39 )}
06d5ffeClaude40 {starCount !== undefined && (
41 currentUser ? (
42 <form method="POST" action={`/${owner}/${repo}/star`} style="display:inline">
43 <button
44 type="submit"
45 class={`star-btn${starred ? " starred" : ""}`}
46 >
47 {starred ? "\u2605" : "\u2606"} {starCount}
48 </button>
49 </form>
50 ) : (
51 <span class="star-btn">
52 {"\u2606"} {starCount}
53 </span>
54 )
55 )}
56 </div>
fc1817aClaude57 </div>
58);
59
60export const RepoNav: FC<{
61 owner: string;
62 repo: string;
3ef4c9dClaude63 active:
64 | "code"
65 | "commits"
66 | "issues"
67 | "pulls"
68 | "releases"
eafe8c6Claude69 | "actions"
3ef4c9dClaude70 | "gates"
3cbe3d6Claude71 | "insights"
72 | "explain"
73 | "changelog"
74 | "semantic";
fc1817aClaude75}> = ({ owner, repo, active }) => (
76 <div class="repo-nav">
06d5ffeClaude77 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
fc1817aClaude78 Code
79 </a>
79136bbClaude80 <a
81 href={`/${owner}/${repo}/issues`}
82 class={active === "issues" ? "active" : ""}
83 >
84 Issues
85 </a>
0074234Claude86 <a
87 href={`/${owner}/${repo}/pulls`}
88 class={active === "pulls" ? "active" : ""}
89 >
90 Pull Requests
91 </a>
fc1817aClaude92 <a
93 href={`/${owner}/${repo}/commits`}
94 class={active === "commits" ? "active" : ""}
95 >
96 Commits
97 </a>
eafe8c6Claude98 <a
99 href={`/${owner}/${repo}/actions`}
100 class={active === "actions" ? "active" : ""}
101 >
102 Actions
103 </a>
3ef4c9dClaude104 <a
105 href={`/${owner}/${repo}/releases`}
106 class={active === "releases" ? "active" : ""}
107 >
108 Releases
109 </a>
110 <a
111 href={`/${owner}/${repo}/gates`}
112 class={active === "gates" ? "active" : ""}
113 >
114 {"\u25CF"} Gates
115 </a>
116 <a
117 href={`/${owner}/${repo}/insights`}
118 class={active === "insights" ? "active" : ""}
119 >
120 Insights
121 </a>
3cbe3d6Claude122 <a
123 href={`/${owner}/${repo}/explain`}
124 class={active === "explain" ? "active" : ""}
125 style="margin-left: auto; color: #bc8cff"
126 >
127 {"\u2728"} Explain
128 </a>
129 <a href={`/${owner}/${repo}/ask`} style="color: #bc8cff">
3ef4c9dClaude130 {"\u2728"} Ask AI
131 </a>
fc1817aClaude132 </div>
133);
134
06d5ffeClaude135export const BranchSwitcher: FC<{
136 owner: string;
137 repo: string;
138 currentRef: string;
139 branches: string[];
140 pathType: "tree" | "blob" | "commits";
141 subPath?: string;
142}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
143 if (branches.length <= 1) {
144 return <div class="branch-selector">{currentRef}</div>;
145 }
146
147 return (
148 <div class="branch-dropdown">
149 <button class="branch-selector" type="button">
150 {currentRef} &#9662;
151 </button>
152 <div class="branch-dropdown-content">
153 {branches.map((branch) => {
154 let href: string;
155 if (pathType === "commits") {
156 href = `/${owner}/${repo}/commits/${branch}`;
157 } else if (subPath) {
158 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
159 } else {
160 href = `/${owner}/${repo}/tree/${branch}`;
161 }
162 return (
163 <a
164 href={href}
165 class={branch === currentRef ? "active-branch" : ""}
166 >
167 {branch}
168 </a>
169 );
170 })}
171 </div>
172 </div>
173 );
174};
175
fc1817aClaude176export const Breadcrumb: FC<{
177 owner: string;
178 repo: string;
179 ref: string;
180 path: string;
181}> = ({ owner, repo, ref, path }) => {
182 const parts = path.split("/").filter(Boolean);
183 const crumbs: { name: string; href: string }[] = [
184 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
185 ];
186 let accumulated = "";
187 for (const part of parts) {
188 accumulated += (accumulated ? "/" : "") + part;
189 crumbs.push({
190 name: part,
191 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
192 });
193 }
194 return (
195 <div class="breadcrumb">
196 {crumbs.map((crumb, i) => (
197 <>
198 {i > 0 && <span>/</span>}
199 {i === crumbs.length - 1 ? (
200 <strong>{crumb.name}</strong>
201 ) : (
202 <a href={crumb.href}>{crumb.name}</a>
203 )}
204 </>
205 ))}
206 </div>
207 );
208};
209
210export const FileTable: FC<{
211 entries: GitTreeEntry[];
212 owner: string;
213 repo: string;
214 ref: string;
215 path: string;
216}> = ({ entries, owner, repo, ref, path }) => (
217 <table class="file-table">
218 <tbody>
219 {entries.map((entry) => {
220 const fullPath = path ? `${path}/${entry.name}` : entry.name;
221 const href =
222 entry.type === "tree"
223 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
224 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
225 return (
226 <tr>
227 <td class="file-icon">
228 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
229 </td>
230 <td class="file-name">
231 <a href={href}>{entry.name}</a>
232 </td>
233 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
234 {entry.size !== undefined ? formatSize(entry.size) : ""}
235 </td>
236 </tr>
237 );
238 })}
239 </tbody>
240 </table>
241);
242
06d5ffeClaude243export const HighlightedCode: FC<{
244 highlightedHtml: string;
245 lineCount: number;
246}> = ({ highlightedHtml, lineCount }) => {
247 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
248 return (
249 <div class="blob-code">
250 <table>
251 <tbody>
252 <tr>
253 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
254 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
255 {lineNums.map((n) => (
256 <>
257 <span>{n}</span>
258 {"\n"}
259 </>
260 ))}
261 </pre>
262 </td>
263 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
264 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
265 </td>
266 </tr>
267 </tbody>
268 </table>
269 </div>
270 );
271};
272
273export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
274 <div class="blob-code">
275 <table>
276 <tbody>
277 {lines.map((line, i) => (
278 <tr>
279 <td class="line-num">{i + 1}</td>
280 <td class="line-content">{line}</td>
281 </tr>
282 ))}
283 </tbody>
284 </table>
285 </div>
286);
287
fc1817aClaude288export const CommitList: FC<{
289 commits: GitCommit[];
290 owner: string;
291 repo: string;
292}> = ({ commits, owner, repo }) => (
293 <div class="commit-list">
294 {commits.map((commit) => (
295 <div class="commit-item">
296 <div>
297 <div class="commit-message">
298 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
299 {commit.message}
300 </a>
301 </div>
302 <div class="commit-meta">
06d5ffeClaude303 {commit.author} committed {formatRelativeDate(commit.date)}
fc1817aClaude304 </div>
305 </div>
306 <a
307 href={`/${owner}/${repo}/commit/${commit.sha}`}
308 class="commit-sha"
309 >
310 {commit.sha.slice(0, 7)}
311 </a>
312 </div>
313 ))}
314 </div>
315);
316
317export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
318 raw,
319 files,
320}) => {
321 const sections = parseDiff(raw);
322
323 return (
324 <div class="diff-view">
325 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
326 Showing{" "}
327 <strong style="color: var(--text)">{files.length}</strong> changed
328 file{files.length !== 1 ? "s" : ""} with{" "}
329 <span class="stat-add">
330 +{files.reduce((s, f) => s + f.additions, 0)}
331 </span>{" "}
332 and{" "}
333 <span class="stat-del">
334 -{files.reduce((s, f) => s + f.deletions, 0)}
335 </span>
336 </div>
337 {sections.map((section) => (
338 <div class="diff-file">
339 <div class="diff-file-header">{section.path}</div>
340 <div class="diff-content">
341 {section.lines.map((line) => {
342 let cls = "line";
343 if (line.startsWith("+")) cls += " line-add";
344 else if (line.startsWith("-")) cls += " line-del";
345 else if (line.startsWith("@@")) cls += " line-hunk";
346 return <span class={cls}>{line + "\n"}</span>;
347 })}
348 </div>
349 </div>
350 ))}
351 </div>
352 );
353};
354
06d5ffeClaude355export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
356 repo,
357 ownerName,
358}) => (
359 <div class="card">
360 <h3>
361 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
362 </h3>
363 {repo.description && <p>{repo.description}</p>}
364 <div class="card-meta">
365 {repo.isPrivate && <span class="badge">Private</span>}
366 <span>{"\u2606"} {repo.starCount}</span>
367 {repo.pushedAt && (
368 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
369 )}
370 </div>
371 </div>
372);
373
fc1817aClaude374function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
375 const sections: Array<{ path: string; lines: string[] }> = [];
376 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
377 let current: { path: string; lines: string[] } | null = null;
378
379 for (const line of raw.split("\n")) {
380 const match = line.match(diffRegex);
381 if (match) {
382 if (current) sections.push(current);
383 current = { path: match[1], lines: [] };
384 continue;
385 }
386 if (current && !line.startsWith("diff --git")) {
387 if (
388 line.startsWith("index ") ||
389 line.startsWith("--- ") ||
390 line.startsWith("+++ ") ||
391 line.startsWith("new file") ||
392 line.startsWith("deleted file") ||
393 line.startsWith("old mode") ||
394 line.startsWith("new mode")
395 ) {
396 continue;
397 }
398 current.lines.push(line);
399 }
400 }
401 if (current) sections.push(current);
402 return sections;
403}
404
405function formatSize(bytes: number): string {
406 if (bytes < 1024) return `${bytes} B`;
407 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
408 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
409}
410
411function formatRelativeDate(dateStr: string): string {
412 const date = new Date(dateStr);
413 const now = new Date();
414 const diffMs = now.getTime() - date.getTime();
415 const diffMins = Math.floor(diffMs / 60000);
416 if (diffMins < 1) return "just now";
417 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
418 const diffHours = Math.floor(diffMins / 60);
419 if (diffHours < 24)
420 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
421 const diffDays = Math.floor(diffHours / 24);
422 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
423 return date.toLocaleDateString("en-US", {
424 month: "short",
425 day: "numeric",
426 year: "numeric",
427 });
428}