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.tsxBlame231 lines · 1 contributor
fc1817aClaude1import type { FC } from "hono/jsx";
2import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository";
3
4export const RepoHeader: FC<{ owner: string; repo: string }> = ({
5 owner,
6 repo,
7}) => (
8 <div class="repo-header">
9 <a href={`/${owner}`} class="owner">
10 {owner}
11 </a>
12 <span class="separator">/</span>
13 <a href={`/${owner}/${repo}`} class="name">
14 {repo}
15 </a>
16 </div>
17);
18
19export const RepoNav: FC<{
20 owner: string;
21 repo: string;
22 active: "code" | "commits";
23}> = ({ owner, repo, active }) => (
24 <div class="repo-nav">
25 <a
26 href={`/${owner}/${repo}`}
27 class={active === "code" ? "active" : ""}
28 >
29 Code
30 </a>
31 <a
32 href={`/${owner}/${repo}/commits`}
33 class={active === "commits" ? "active" : ""}
34 >
35 Commits
36 </a>
37 </div>
38);
39
40export const Breadcrumb: FC<{
41 owner: string;
42 repo: string;
43 ref: string;
44 path: string;
45}> = ({ owner, repo, ref, path }) => {
46 const parts = path.split("/").filter(Boolean);
47 const crumbs: { name: string; href: string }[] = [
48 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
49 ];
50 let accumulated = "";
51 for (const part of parts) {
52 accumulated += (accumulated ? "/" : "") + part;
53 crumbs.push({
54 name: part,
55 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
56 });
57 }
58 return (
59 <div class="breadcrumb">
60 {crumbs.map((crumb, i) => (
61 <>
62 {i > 0 && <span>/</span>}
63 {i === crumbs.length - 1 ? (
64 <strong>{crumb.name}</strong>
65 ) : (
66 <a href={crumb.href}>{crumb.name}</a>
67 )}
68 </>
69 ))}
70 </div>
71 );
72};
73
74export const FileTable: FC<{
75 entries: GitTreeEntry[];
76 owner: string;
77 repo: string;
78 ref: string;
79 path: string;
80}> = ({ entries, owner, repo, ref, path }) => (
81 <table class="file-table">
82 <tbody>
83 {entries.map((entry) => {
84 const fullPath = path ? `${path}/${entry.name}` : entry.name;
85 const href =
86 entry.type === "tree"
87 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
88 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
89 return (
90 <tr>
91 <td class="file-icon">
92 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
93 </td>
94 <td class="file-name">
95 <a href={href}>{entry.name}</a>
96 </td>
97 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
98 {entry.size !== undefined ? formatSize(entry.size) : ""}
99 </td>
100 </tr>
101 );
102 })}
103 </tbody>
104 </table>
105);
106
107export const CommitList: FC<{
108 commits: GitCommit[];
109 owner: string;
110 repo: string;
111}> = ({ commits, owner, repo }) => (
112 <div class="commit-list">
113 {commits.map((commit) => (
114 <div class="commit-item">
115 <div>
116 <div class="commit-message">
117 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
118 {commit.message}
119 </a>
120 </div>
121 <div class="commit-meta">
122 {commit.author} committed{" "}
123 {formatRelativeDate(commit.date)}
124 </div>
125 </div>
126 <a
127 href={`/${owner}/${repo}/commit/${commit.sha}`}
128 class="commit-sha"
129 >
130 {commit.sha.slice(0, 7)}
131 </a>
132 </div>
133 ))}
134 </div>
135);
136
137export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
138 raw,
139 files,
140}) => {
141 // Parse unified diff into per-file sections
142 const sections = parseDiff(raw);
143
144 return (
145 <div class="diff-view">
146 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
147 Showing{" "}
148 <strong style="color: var(--text)">{files.length}</strong> changed
149 file{files.length !== 1 ? "s" : ""} with{" "}
150 <span class="stat-add">
151 +{files.reduce((s, f) => s + f.additions, 0)}
152 </span>{" "}
153 and{" "}
154 <span class="stat-del">
155 -{files.reduce((s, f) => s + f.deletions, 0)}
156 </span>
157 </div>
158 {sections.map((section) => (
159 <div class="diff-file">
160 <div class="diff-file-header">{section.path}</div>
161 <div class="diff-content">
162 {section.lines.map((line) => {
163 let cls = "line";
164 if (line.startsWith("+")) cls += " line-add";
165 else if (line.startsWith("-")) cls += " line-del";
166 else if (line.startsWith("@@")) cls += " line-hunk";
167 return <span class={cls}>{line + "\n"}</span>;
168 })}
169 </div>
170 </div>
171 ))}
172 </div>
173 );
174};
175
176function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
177 const sections: Array<{ path: string; lines: string[] }> = [];
178 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
179 let current: { path: string; lines: string[] } | null = null;
180
181 for (const line of raw.split("\n")) {
182 const match = line.match(diffRegex);
183 if (match) {
184 if (current) sections.push(current);
185 current = { path: match[1], lines: [] };
186 continue;
187 }
188 if (current && !line.startsWith("diff --git")) {
189 // Skip index/--- /+++ header lines from display
190 if (
191 line.startsWith("index ") ||
192 line.startsWith("--- ") ||
193 line.startsWith("+++ ") ||
194 line.startsWith("new file") ||
195 line.startsWith("deleted file") ||
196 line.startsWith("old mode") ||
197 line.startsWith("new mode")
198 ) {
199 continue;
200 }
201 current.lines.push(line);
202 }
203 }
204 if (current) sections.push(current);
205 return sections;
206}
207
208function formatSize(bytes: number): string {
209 if (bytes < 1024) return `${bytes} B`;
210 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
211 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
212}
213
214function formatRelativeDate(dateStr: string): string {
215 const date = new Date(dateStr);
216 const now = new Date();
217 const diffMs = now.getTime() - date.getTime();
218 const diffMins = Math.floor(diffMs / 60000);
219 if (diffMins < 1) return "just now";
220 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
221 const diffHours = Math.floor(diffMins / 60);
222 if (diffHours < 24)
223 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
224 const diffDays = Math.floor(diffHours / 24);
225 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
226 return date.toLocaleDateString("en-US", {
227 month: "short",
228 day: "numeric",
229 year: "numeric",
230 });
231}