Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame639 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;
77cf834Claude31 /** 0-100 health score badge rendered after the repo name. Optional — omit to hide. */
32 healthScore?: number;
71cd5ecClaude33}> = ({
34 owner,
35 repo,
36 starCount,
37 starred,
38 forkCount,
39 currentUser,
40 forkedFrom,
41 archived,
42 isTemplate,
8c790e0Claude43 recentPush,
77cf834Claude44 healthScore,
8c790e0Claude45}) => {
46 const FIVE_MIN = 5 * 60 * 1000;
47 const TWENTY_FOUR_HR = 24 * 60 * 60 * 1000;
48 const isLive = recentPush != null && recentPush.ageMs < FIVE_MIN;
49 const isRecent = recentPush != null && recentPush.ageMs < TWENTY_FOUR_HR;
50
77cf834Claude51 const healthColor =
52 healthScore === undefined ? null :
53 healthScore >= 80 ? "#34d399" :
54 healthScore >= 50 ? "#facc15" :
55 "#f87171";
56
8c790e0Claude57 return (
58 <div class="repo-header">
59 <div>
60 <div class="repo-header-title">
61 <a href={`/${owner}`} class="owner">
62 {owner}
63 </a>
64 <span class="separator">/</span>
65 <a href={`/${owner}/${repo}`} class="name">
66 {repo}
67 </a>
68 {archived && (
69 <span
70 class="repo-header-pill repo-header-pill-archived"
71 title="Read-only: pushes and new issues/PRs disabled"
72 >
73 Archived
74 </span>
75 )}
76 {isTemplate && (
77 <span
78 class="repo-header-pill repo-header-pill-template"
79 title="This repository can be used as a template"
80 >
81 Template
82 </span>
83 )}
77cf834Claude84 {healthScore !== undefined && healthColor && (
85 <a
86 href={`/${owner}/${repo}/health`}
87 title={`Repository health score: ${healthScore}/100 — click for breakdown`}
88 style={`display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:700;text-decoration:none;color:${healthColor};background:${healthColor}22;border:1px solid ${healthColor}44;`}
89 >
90 &#x2665; Health {healthScore}
91 </a>
92 )}
8c790e0Claude93 {isLive && recentPush && (
94 <a
95 href={`/${owner}/${repo}/push/${recentPush.sha}`}
96 class="repo-header-live-badge repo-header-live-badge--live"
97 title="Push in progress \u2014 watch live gate + deploy status"
98 aria-label="Live push \u2014 click to watch status"
99 >
100 <span class="repo-header-live-dot" aria-hidden="true">{"\u25cf"}</span>
101 Live
102 </a>
103 )}
104 {!isLive && isRecent && recentPush && (
105 <a
106 href={`/${owner}/${repo}/push/${recentPush.sha}`}
107 class="repo-header-live-badge repo-header-live-badge--recent"
108 title="Watch the most recent push's gate + deploy results"
109 aria-label="Watch most recent push"
110 >
111 <span aria-hidden="true">{"\u25cb"}</span>
112 Watch
113 </a>
114 )}
115 </div>
116 {forkedFrom && (
117 <div class="repo-header-fork">
118 forked from <a href={`/${forkedFrom}`}>{forkedFrom}</a>
119 </div>
71cd5ecClaude120 )}
c81ab7aClaude121 </div>
8c790e0Claude122 <div class="repo-header-actions">
123 {currentUser && currentUser !== owner && (
124 <form method="post" action={`/${owner}/${repo}/fork`} style="display:inline">
125 <button type="submit" class="star-btn">
126 {"\u2442"} Fork {forkCount !== undefined && forkCount > 0 ? forkCount : ""}
06d5ffeClaude127 </button>
128 </form>
8c790e0Claude129 )}
130 {starCount !== undefined && (
131 currentUser ? (
132 <form method="post" action={`/${owner}/${repo}/star`} style="display:inline">
133 <button
134 type="submit"
135 class={`star-btn${starred ? " starred" : ""}`}
136 >
137 {starred ? "\u2605" : "\u2606"} {starCount}
138 </button>
139 </form>
140 ) : (
141 <span class="star-btn">
142 {"\u2606"} {starCount}
143 </span>
144 )
145 )}
146 </div>
06d5ffeClaude147 </div>
8c790e0Claude148 );
149};
fc1817aClaude150
151export const RepoNav: FC<{
152 owner: string;
153 repo: string;
3ef4c9dClaude154 active:
155 | "code"
156 | "commits"
157 | "issues"
158 | "pulls"
159 | "releases"
eafe8c6Claude160 | "actions"
3ef4c9dClaude161 | "gates"
3cbe3d6Claude162 | "insights"
163 | "explain"
164 | "changelog"
1e162a8Claude165 | "semantic"
166 | "wiki"
0316dbbClaude167 | "projects"
ef3fd93Claude168 | "agents"
c645a86Claude169 | "discussions"
da3fc18Claude170 | "security"
bcc4020Claude171 | "settings"
7f992cdClaude172 | "debt-map"
173 | "migrate"
77cf834Claude174 | "deployments"
0224546Claude175 | "nl-search"
176 | "contributors"
177 | "pulse"
178 | "traffic";
179 /** Current authenticated user — used for owner-only tab gating. */
180 currentUser?: string | null;
181 /** Repo owner username — used for owner-only tab gating. */
182 repoOwner?: string;
183}> = ({ owner, repo, active, currentUser, repoOwner }) => (
fc1817aClaude184 <div class="repo-nav">
06d5ffeClaude185 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
fc1817aClaude186 Code
187 </a>
79136bbClaude188 <a
189 href={`/${owner}/${repo}/issues`}
190 class={active === "issues" ? "active" : ""}
191 >
192 Issues
193 </a>
c645a86Claude194 <a
195 href={`/${owner}/${repo}/discussions`}
196 class={active === "discussions" ? "active" : ""}
197 >
198 Discussions
199 </a>
1e162a8Claude200 <a
201 href={`/${owner}/${repo}/wiki`}
202 class={active === "wiki" ? "active" : ""}
203 >
204 Wiki
205 </a>
0074234Claude206 <a
207 href={`/${owner}/${repo}/pulls`}
208 class={active === "pulls" ? "active" : ""}
209 >
210 Pull Requests
211 </a>
1e162a8Claude212 <a
213 href={`/${owner}/${repo}/projects`}
214 class={active === "projects" ? "active" : ""}
215 >
216 Projects
217 </a>
fc1817aClaude218 <a
219 href={`/${owner}/${repo}/commits`}
220 class={active === "commits" ? "active" : ""}
221 >
222 Commits
223 </a>
eafe8c6Claude224 <a
225 href={`/${owner}/${repo}/actions`}
226 class={active === "actions" ? "active" : ""}
227 >
228 Actions
229 </a>
3ef4c9dClaude230 <a
231 href={`/${owner}/${repo}/releases`}
232 class={active === "releases" ? "active" : ""}
233 >
234 Releases
235 </a>
0224546Claude236 <a
237 href={`/${owner}/${repo}/contributors`}
238 class={active === "contributors" ? "active" : ""}
239 >
240 Contributors
241 </a>
242 <a
243 href={`/${owner}/${repo}/pulse`}
244 class={active === "pulse" ? "active" : ""}
245 >
246 Pulse
247 </a>
248 {currentUser && repoOwner && currentUser === repoOwner && (
249 <a
250 href={`/${owner}/${repo}/traffic`}
251 class={active === "traffic" ? "active" : ""}
252 >
253 Traffic
254 </a>
255 )}
3ef4c9dClaude256 <a
257 href={`/${owner}/${repo}/gates`}
258 class={active === "gates" ? "active" : ""}
259 >
260 {"\u25CF"} Gates
261 </a>
da3fc18Claude262 <a
263 href={`/${owner}/${repo}/security/vulnerabilities`}
264 class={active === "security" ? "active" : ""}
265 >
266 Security
267 </a>
c9ed210Claude268 <a
269 href={`/${owner}/${repo}/cloud-deployments`}
270 class={active === "deployments" ? "active" : ""}
271 >
272 Deployments
273 </a>
3ef4c9dClaude274 <a
275 href={`/${owner}/${repo}/insights`}
276 class={active === "insights" ? "active" : ""}
277 >
278 Insights
279 </a>
ef3fd93Claude280 <a
281 href={`/${owner}/${repo}/agents`}
282 class={active === "agents" ? "active" : ""}
283 >
284 Agents
285 </a>
3cbe3d6Claude286 <a
287 href={`/${owner}/${repo}/explain`}
debcf27Claude288 class={`repo-nav-ai${active === "explain" ? " active" : ""}`}
289 style="margin-left: auto"
3cbe3d6Claude290 >
291 {"\u2728"} Explain
292 </a>
debcf27Claude293 <a href={`/${owner}/${repo}/ask`} class="repo-nav-ai">
3ef4c9dClaude294 {"\u2728"} Ask AI
295 </a>
14c3cc8Claude296 <a
297 href={`/${owner}/${repo}/spec`}
debcf27Claude298 class="repo-nav-ai"
14c3cc8Claude299 title="Spec to PR — paste a feature spec, AI opens a draft PR"
300 >
301 {"\u2728"} Spec
302 </a>
d8ef5efClaude303 <a
304 href={`/${owner}/${repo}/ai/tests`}
debcf27Claude305 class="repo-nav-ai"
d8ef5efClaude306 title="AI Tests \u2014 generate failing test stubs from a source file"
307 >
308 {"\u2728"} Tests
309 </a>
bcc4020Claude310 <a
311 href={`/${owner}/${repo}/debt-map`}
312 class={`repo-nav-ai${active === "debt-map" ? " active" : ""}`}
313 title="AI Debt Map \u2014 visual technical debt graph with Claude analysis"
314 >
315 {"\u2593"} Debt Map
316 </a>
77cf834Claude317 <a
318 href={`/${owner}/${repo}/search/nl`}
319 class={`repo-nav-ai${active === "nl-search" ? " active" : ""}`}
320 title="Natural Language Search \u2014 search by intent, not keywords"
321 >
322 {"\u2728"} NL Search
323 </a>
b1070a5Claude324 <a
325 href={`/${owner}/${repo}/archaeology`}
326 class={`repo-nav-ai${active === "archaeology" ? " active" : ""}`}
327 title="AI Archaeology \u2014 excavate why any file exists using git history, PRs, and issues"
328 >
329 {"\ud83c\udfdb"} Archaeology
330 </a>
fc1817aClaude331 </div>
332);
333
06d5ffeClaude334export const BranchSwitcher: FC<{
335 owner: string;
336 repo: string;
337 currentRef: string;
338 branches: string[];
339 pathType: "tree" | "blob" | "commits";
340 subPath?: string;
341}> = ({ owner, repo, currentRef, branches, pathType, subPath }) => {
342 if (branches.length <= 1) {
343 return <div class="branch-selector">{currentRef}</div>;
344 }
345
346 return (
347 <div class="branch-dropdown">
348 <button class="branch-selector" type="button">
349 {currentRef} &#9662;
350 </button>
351 <div class="branch-dropdown-content">
352 {branches.map((branch) => {
353 let href: string;
354 if (pathType === "commits") {
355 href = `/${owner}/${repo}/commits/${branch}`;
356 } else if (subPath) {
357 href = `/${owner}/${repo}/${pathType}/${branch}/${subPath}`;
358 } else {
359 href = `/${owner}/${repo}/tree/${branch}`;
360 }
361 return (
362 <a
363 href={href}
364 class={branch === currentRef ? "active-branch" : ""}
365 >
366 {branch}
367 </a>
368 );
369 })}
370 </div>
371 </div>
372 );
373};
374
fc1817aClaude375export const Breadcrumb: FC<{
376 owner: string;
377 repo: string;
378 ref: string;
379 path: string;
380}> = ({ owner, repo, ref, path }) => {
381 const parts = path.split("/").filter(Boolean);
382 const crumbs: { name: string; href: string }[] = [
383 { name: repo, href: `/${owner}/${repo}/tree/${ref}` },
384 ];
385 let accumulated = "";
386 for (const part of parts) {
387 accumulated += (accumulated ? "/" : "") + part;
388 crumbs.push({
389 name: part,
390 href: `/${owner}/${repo}/tree/${ref}/${accumulated}`,
391 });
392 }
393 return (
394 <div class="breadcrumb">
395 {crumbs.map((crumb, i) => (
396 <>
397 {i > 0 && <span>/</span>}
398 {i === crumbs.length - 1 ? (
399 <strong>{crumb.name}</strong>
400 ) : (
401 <a href={crumb.href}>{crumb.name}</a>
402 )}
403 </>
404 ))}
405 </div>
406 );
407};
408
409export const FileTable: FC<{
410 entries: GitTreeEntry[];
411 owner: string;
412 repo: string;
413 ref: string;
414 path: string;
415}> = ({ entries, owner, repo, ref, path }) => (
416 <table class="file-table">
417 <tbody>
418 {entries.map((entry) => {
419 const fullPath = path ? `${path}/${entry.name}` : entry.name;
420 const href =
421 entry.type === "tree"
422 ? `/${owner}/${repo}/tree/${ref}/${fullPath}`
423 : `/${owner}/${repo}/blob/${ref}/${fullPath}`;
424 return (
425 <tr>
426 <td class="file-icon">
427 {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"}
428 </td>
429 <td class="file-name">
430 <a href={href}>{entry.name}</a>
431 </td>
432 <td style="text-align: right; color: var(--text-muted); font-size: 13px;">
433 {entry.size !== undefined ? formatSize(entry.size) : ""}
434 </td>
435 </tr>
436 );
437 })}
438 </tbody>
439 </table>
440);
441
06d5ffeClaude442export const HighlightedCode: FC<{
443 highlightedHtml: string;
444 lineCount: number;
445}> = ({ highlightedHtml, lineCount }) => {
446 const lineNums = Array.from({ length: lineCount }, (_, i) => i + 1);
447 return (
448 <div class="blob-code">
449 <table>
450 <tbody>
451 <tr>
452 <td class="line-num" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
453 <pre style="margin: 0; line-height: 1.6; font-size: 13px">
454 {lineNums.map((n) => (
455 <>
456 <span>{n}</span>
457 {"\n"}
458 </>
459 ))}
460 </pre>
461 </td>
462 <td class="line-content" style="vertical-align: top; padding-top: 0; padding-bottom: 0">
463 <pre style="margin: 0; line-height: 1.6; font-size: 13px">{html([highlightedHtml] as unknown as TemplateStringsArray)}</pre>
464 </td>
465 </tr>
466 </tbody>
467 </table>
468 </div>
469 );
470};
471
472export const PlainCode: FC<{ lines: string[] }> = ({ lines }) => (
473 <div class="blob-code">
474 <table>
475 <tbody>
476 {lines.map((line, i) => (
477 <tr>
478 <td class="line-num">{i + 1}</td>
479 <td class="line-content">{line}</td>
480 </tr>
481 ))}
482 </tbody>
483 </table>
484 </div>
485);
486
fc1817aClaude487export const CommitList: FC<{
488 commits: GitCommit[];
489 owner: string;
490 repo: string;
3951454Claude491 verifications?: Record<string, { verified: boolean; reason: string }>;
492}> = ({ commits, owner, repo, verifications }) => (
fc1817aClaude493 <div class="commit-list">
3951454Claude494 {commits.map((commit) => {
495 const v = verifications?.[commit.sha];
496 return (
497 <div class="commit-item">
498 <div>
499 <div class="commit-message">
500 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
501 {commit.message}
502 </a>
503 {v?.verified && (
504 <span
505 title="Signed with a registered key"
506 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"
507 >
508 Verified
509 </span>
510 )}
511 </div>
512 <div class="commit-meta">
513 {commit.author} committed {formatRelativeDate(commit.date)}
514 </div>
fc1817aClaude515 </div>
3951454Claude516 <a
517 href={`/${owner}/${repo}/commit/${commit.sha}`}
518 class="commit-sha"
519 >
520 {commit.sha.slice(0, 7)}
521 </a>
fc1817aClaude522 </div>
3951454Claude523 );
524 })}
fc1817aClaude525 </div>
526);
527
528export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({
529 raw,
530 files,
531}) => {
532 const sections = parseDiff(raw);
533
534 return (
535 <div class="diff-view">
536 <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);">
537 Showing{" "}
538 <strong style="color: var(--text)">{files.length}</strong> changed
539 file{files.length !== 1 ? "s" : ""} with{" "}
540 <span class="stat-add">
541 +{files.reduce((s, f) => s + f.additions, 0)}
542 </span>{" "}
543 and{" "}
544 <span class="stat-del">
545 -{files.reduce((s, f) => s + f.deletions, 0)}
546 </span>
547 </div>
548 {sections.map((section) => (
549 <div class="diff-file">
550 <div class="diff-file-header">{section.path}</div>
551 <div class="diff-content">
552 {section.lines.map((line) => {
553 let cls = "line";
554 if (line.startsWith("+")) cls += " line-add";
555 else if (line.startsWith("-")) cls += " line-del";
556 else if (line.startsWith("@@")) cls += " line-hunk";
557 return <span class={cls}>{line + "\n"}</span>;
558 })}
559 </div>
560 </div>
561 ))}
562 </div>
563 );
564};
565
06d5ffeClaude566export const RepoCard: FC<{ repo: Repository; ownerName: string }> = ({
567 repo,
568 ownerName,
569}) => (
570 <div class="card">
571 <h3>
572 <a href={`/${ownerName}/${repo.name}`}>{repo.name}</a>
573 </h3>
574 {repo.description && <p>{repo.description}</p>}
575 <div class="card-meta">
576 {repo.isPrivate && <span class="badge">Private</span>}
577 <span>{"\u2606"} {repo.starCount}</span>
578 {repo.pushedAt && (
579 <span>Updated {formatRelativeDate(repo.pushedAt.toString())}</span>
580 )}
581 </div>
582 </div>
583);
584
fc1817aClaude585function parseDiff(raw: string): Array<{ path: string; lines: string[] }> {
586 const sections: Array<{ path: string; lines: string[] }> = [];
587 const diffRegex = /^diff --git a\/(.+?) b\/.+$/;
588 let current: { path: string; lines: string[] } | null = null;
589
590 for (const line of raw.split("\n")) {
591 const match = line.match(diffRegex);
592 if (match) {
593 if (current) sections.push(current);
594 current = { path: match[1], lines: [] };
595 continue;
596 }
597 if (current && !line.startsWith("diff --git")) {
598 if (
599 line.startsWith("index ") ||
600 line.startsWith("--- ") ||
601 line.startsWith("+++ ") ||
602 line.startsWith("new file") ||
603 line.startsWith("deleted file") ||
604 line.startsWith("old mode") ||
605 line.startsWith("new mode")
606 ) {
607 continue;
608 }
609 current.lines.push(line);
610 }
611 }
612 if (current) sections.push(current);
613 return sections;
614}
615
616function formatSize(bytes: number): string {
617 if (bytes < 1024) return `${bytes} B`;
618 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
619 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
620}
621
622function formatRelativeDate(dateStr: string): string {
623 const date = new Date(dateStr);
624 const now = new Date();
625 const diffMs = now.getTime() - date.getTime();
626 const diffMins = Math.floor(diffMs / 60000);
627 if (diffMins < 1) return "just now";
628 if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
629 const diffHours = Math.floor(diffMins / 60);
630 if (diffHours < 24)
631 return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
632 const diffDays = Math.floor(diffHours / 24);
633 if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
634 return date.toLocaleDateString("en-US", {
635 month: "short",
636 day: "numeric",
637 year: "numeric",
638 });
639}