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.tsxBlame495 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;
71cd5ecClaude14 archived?: boolean;
15 isTemplate?: boolean;
16}> = ({
17 owner,
18 repo,
19 starCount,
20 starred,
21 forkCount,
22 currentUser,
23 forkedFrom,
24 archived,
25 isTemplate,
26}) => (
fc1817aClaude27 <div class="repo-header">
c81ab7aClaude28 <div>
29 <div style="display: flex; align-items: center; gap: 8px; font-size: 20px">
30 <a href={`/${owner}`} class="owner">
31 {owner}
32 </a>
33 <span class="separator">/</span>
34 <a href={`/${owner}/${repo}`} class="name">
35 {repo}
36 </a>
71cd5ecClaude37 {archived && (
38 <span
39 class="badge"
40 style="background:var(--bg-secondary);color:var(--text-muted);font-size:11px;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:0.5px"
41 title="Read-only: pushes and new issues/PRs disabled"
42 >
43 Archived
44 </span>
45 )}
46 {isTemplate && (
47 <span
48 class="badge"
49 style="background:var(--bg-secondary);color:var(--accent);font-size:11px;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:0.5px"
50 title="This repository can be used as a template"
51 >
52 Template
53 </span>
54 )}
c81ab7aClaude55 </div>
56 {forkedFrom && (
57 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
58 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
59 </div>
60 )}
61 </div>
06d5ffeClaude62 <div class="repo-header-actions">
c81ab7aClaude63 {currentUser && currentUser !== owner && (
45e31d0Claude64 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
c81ab7aClaude65 <button type="submit" class="star-btn">
66 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
67 </button>
68 </form>
69 )}
06d5ffeClaude70 {starCount !== undefined && (
71 currentUser ? (
45e31d0Claude72 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
06d5ffeClaude73 <button
74 type="submit"
75 class={`star-btn${starred ? " starred" : ""}`}
76 >
77 {starred ? "\u2605" : "\u2606"} {starCount}
78 </button>
79 </form>
80 ) : (
81 <span class="star-btn">
82 {"\u2606"} {starCount}
83 </span>
84 )
85 )}
86 </div>
fc1817aClaude87 </div>
88);
89
90export const RepoNav: FC<{
91 owner: string;
92 repo: string;
3ef4c9dClaude93 active:
94 | "code"
95 | "commits"
96 | "issues"
97 | "pulls"
98 | "releases"
eafe8c6Claude99 | "actions"
3ef4c9dClaude100 | "gates"
3cbe3d6Claude101 | "insights"
102 | "explain"
103 | "changelog"
1e162a8Claude104 | "semantic"
105 | "wiki"
0316dbbClaude106 | "projects"
eb3b81aClaude107 | "settings"
108 | "ask"
109 | "spec";
fc1817aClaude110}> = ({ owner, repo, active }) => (
111 <div class="repo-nav">
06d5ffeClaude112 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
fc1817aClaude113 Code
114 </a>
79136bbClaude115 <a
116 href={`/${owner}/${repo}/issues`}
117 class={active === "issues" ? "active" : ""}
118 >
119 Issues
120 </a>
1e162a8Claude121 <a
122 href={`/${owner}/${repo}/wiki`}
123 class={active === "wiki" ? "active" : ""}
124 >
125 Wiki
126 </a>
0074234Claude127 <a
128 href={`/${owner}/${repo}/pulls`}
129 class={active === "pulls" ? "active" : ""}
130 >
131 Pull Requests
132 </a>
1e162a8Claude133 <a
134 href={`/${owner}/${repo}/projects`}
135 class={active === "projects" ? "active" : ""}
136 >
137 Projects
138 </a>
fc1817aClaude139 <a
140 href={`/${owner}/${repo}/commits`}
141 class={active === "commits" ? "active" : ""}
142 >
143 Commits
144 </a>
eafe8c6Claude145 <a
146 href={`/${owner}/${repo}/actions`}
147 class={active === "actions" ? "active" : ""}
148 >
149 Actions
150 </a>
3ef4c9dClaude151 <a
152 href={`/${owner}/${repo}/releases`}
153 class={active === "releases" ? "active" : ""}
154 >
155 Releases
156 </a>
157 <a
158 href={`/${owner}/${repo}/gates`}
159 class={active === "gates" ? "active" : ""}
160 >
161 {"\u25CF"} Gates
162 </a>
163 <a
164 href={`/${owner}/${repo}/insights`}
165 class={active === "insights" ? "active" : ""}
166 >
167 Insights
168 </a>
3cbe3d6Claude169 <a
170 href={`/${owner}/${repo}/explain`}
171 class={active === "explain" ? "active" : ""}
172 style="margin-left: auto; color: #bc8cff"
173 >
174 {"\u2728"} Explain
175 </a>
eb3b81aClaude176 <a href={`/${owner}/${repo}/ask`} class={active === "ask" ? "active" : ""} style="color: #bc8cff">
3ef4c9dClaude177 {"\u2728"} Ask AI
178 </a>
14c3cc8Claude179 <a
180 href={`/${owner}/${repo}/spec`}
eb3b81aClaude181 class={active === "spec" ? "active" : ""}
14c3cc8Claude182 style="color: #bc8cff"
183 title="Spec to PR — paste a feature spec, AI opens a draft PR"
184 >
185 {"\u2728"} Spec
186 </a>
fc1817aClaude187 </div>
188);
189
06d5ffeClaude190export const BranchSwitcher: FC<{
191 owner: string;
192 repo: string;
193 currentRef: string;
194 branches: string[];
195 pathType: "tree" | "blob" | "commits";
196 subPath?: string;
197}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
198 if (branches.length <= 1) {
199 return <div class="branch-selector">{currentRef}</div>;
200 }
201
202 return (
203 <div class="branch-dropdown">
204 <button class="branch-selector" type="button">
205 {currentRef} &#9662;
206 </button>
207 <div class="branch-dropdown-content">
208 {branches.map((branch) => {
209 let href: string;
210 if (pathType === "commits") {
211 href = `/${owner}/${repo}/commits/${branch}`;
212 } else if (subPath) {
213 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
214 } else {
215 href = `/${owner}/${repo}/tree/${branch}`;
216 }
217 return (
218 <a
219 href={href}
220 class={branch === currentRef ? "active-branch" : ""}
221 >
222 {branch}
223 </a>
224 );
225 })}
226 </div>
227 </div>
228 );
229};
230
fc1817aClaude231export const Breadcrumb: FC<{
232 owner: string;
233 repo: string;
234 ref: string;
235 path: string;
236}> = ({ owner, repo, ref, path }) => {
237 const parts = path.split("/").filter(Boolean);
238 const crumbs: { name: string; href: string }[] = [
239 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
240 ];
241 let accumulated = "";
242 for (const part of parts) {
243 accumulated += (accumulated ? "/" : "") + part;
244 crumbs.push({
245 name: part,
246 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
247 });
248 }
249 return (
250 <div class="breadcrumb">
251 {crumbs.map((crumb, i) => (
eb3b81aClaude252 <span key={crumb.href}>
fc1817aClaude253 {i > 0 && <span>/</span>}
254 {i === crumbs.length - 1 ? (
255 <strong>{crumb.name}</strong>
256 ) : (
257 <a href={crumb.href}>{crumb.name}</a>
258 )}
eb3b81aClaude259 </span>
fc1817aClaude260 ))}
261 </div>
262 );
263};
264
265export const FileTable: FC<{
266 entries: GitTreeEntry[];
267 owner: string;
268 repo: string;
269 ref: string;
270 path: string;
271}> = ({ entries, owner, repo, ref, path }) => (
272 <table class="file-table">
273 <tbody>
274 {entries.map((entry) => {
275 const fullPath = path ? `${path}/${entry.name}` : entry.name;
276 const href =
277 entry.type === "tree"
278 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
279 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
280 return (
281 <tr>
282 <td class="file-icon">
283 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
284 </td>
285 <td class="file-name">
286 <a href={href}>{entry.name}</a>
287 </td>
288 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
289 {entry.size !== undefined ? formatSize(entry.size) : ""}
290 </td>
291 </tr>
292 );
293 })}
294 </tbody>
295 </table>
296);
297
06d5ffeClaude298export const HighlightedCode: FC<{
299 highlightedHtml: string;
300 lineCount: number;
301}> = ({ highlightedHtml, lineCount }) => {
302 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
303 return (
304 <div class="blob-code">
305 <table>
306 <tbody>
307 <tr>
308 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
309 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
310 {lineNums.map((n) => (
311 <>
312 <span>{n}</span>
313 {"\n"}
314 </>
315 ))}
316 </pre>
317 </td>
318 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
319 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
320 </td>
321 </tr>
322 </tbody>
323 </table>
324 </div>
325 );
326};
327
328export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
329 <div class="blob-code">
330 <table>
331 <tbody>
332 {lines.map((line, i) => (
333 <tr>
334 <td class="line-num">{i + 1}</td>
335 <td class="line-content">{line}</td>
336 </tr>
337 ))}
338 </tbody>
339 </table>
340 </div>
341);
342
fc1817aClaude343export const CommitList: FC<{
344 commits: GitCommit[];
345 owner: string;
346 repo: string;
3951454Claude347 verifications?: Record<string, { verified: boolean; reason: string }>;
348}> = ({ commits, owner, repo, verifications }) => (
fc1817aClaude349 <div class="commit-list">
3951454Claude350 {commits.map((commit) => {
351 const v = verifications?.[commit.sha];
352 return (
353 <div class="commit-item">
354 <div>
355 <div class="commit-message">
356 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
357 {commit.message}
358 </a>
359 {v?.verified && (
360 <span
361 title="Signed with a registered key"
362 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"
363 >
364 Verified
365 </span>
366 )}
367 </div>
368 <div class="commit-meta">
369 {commit.author} committed {formatRelativeDate(commit.date)}
370 </div>
fc1817aClaude371 </div>
3951454Claude372 <a
373 href={`/${owner}/${repo}/commit/${commit.sha}`}
374 class="commit-sha"
375 >
376 {commit.sha.slice(0, 7)}
377 </a>
fc1817aClaude378 </div>
3951454Claude379 );
380 })}
fc1817aClaude381 </div>
382);
383
384export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
385 raw,
386 files,
387}) => {
388 const sections = parseDiff(raw);
389
390 return (
391 <div class="diff-view">
392 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
393 Showing{" "}
394 <strong style="color: var(--text)">{files.length}</strong> changed
395 file{files.length !== 1 ? "s" : ""} with{" "}
396 <span class="stat-add">
397 +{files.reduce((s, f) => s + f.additions, 0)}
398 </span>{" "}
399 and{" "}
400 <span class="stat-del">
401 -{files.reduce((s, f) => s + f.deletions, 0)}
402 </span>
403 </div>
404 {sections.map((section) => (
405 <div class="diff-file">
406 <div class="diff-file-header">{section.path}</div>
407 <div class="diff-content">
408 {section.lines.map((line) => {
409 let cls = "line";
410 if (line.startsWith("+")) cls += " line-add";
411 else if (line.startsWith("-")) cls += " line-del";
412 else if (line.startsWith("@@")) cls += " line-hunk";
413 return <span class={cls}>{line + "\n"}</span>;
414 })}
415 </div>
416 </div>
417 ))}
418 </div>
419 );
420};
421
06d5ffeClaude422export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
423 repo,
424 ownerName,
425}) => (
426 <div class="card">
427 <h3>
428 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
429 </h3>
430 {repo.description && <p>{repo.description}</p>}
431 <div class="card-meta">
432 {repo.isPrivate && <span class="badge">Private</span>}
433 <span>{"\u2606"} {repo.starCount}</span>
434 {repo.pushedAt && (
435 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
436 )}
437 </div>
438 </div>
439);
440
fc1817aClaude441function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
442 const sections: Array<{ path: string; lines: string[] }> = [];
443 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
444 let current: { path: string; lines: string[] } | null = null;
445
446 for (const line of raw.split("\n")) {
447 const match = line.match(diffRegex);
448 if (match) {
449 if (current) sections.push(current);
450 current = { path: match[1], lines: [] };
451 continue;
452 }
453 if (current && !line.startsWith("diff --git")) {
454 if (
455 line.startsWith("index ") ||
456 line.startsWith("--- ") ||
457 line.startsWith("+++ ") ||
458 line.startsWith("new file") ||
459 line.startsWith("deleted file") ||
460 line.startsWith("old mode") ||
461 line.startsWith("new mode")
462 ) {
463 continue;
464 }
465 current.lines.push(line);
466 }
467 }
468 if (current) sections.push(current);
469 return sections;
470}
471
472function formatSize(bytes: number): string {
473 if (bytes < 1024) return `${bytes} B`;
474 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
475 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
476}
477
478function formatRelativeDate(dateStr: string): string {
479 const date = new Date(dateStr);
480 const now = new Date();
481 const diffMs = now.getTime() - date.getTime();
482 const diffMins = Math.floor(diffMs / 60000);
483 if (diffMins < 1) return "just now";
484 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
485 const diffHours = Math.floor(diffMins / 60);
486 if (diffHours < 24)
487 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
488 const diffDays = Math.floor(diffHours / 24);
489 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
490 return date.toLocaleDateString("en-US", {
491 month: "short",
492 day: "numeric",
493 year: "numeric",
494 });
495}