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

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

languages.tsxBlame279 lines · 1 contributor
07efa08Claude1/**
2 * Block J30 — Repository language breakdown.
3 *
4 * GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]
5 *
6 * Renders a GitHub-parity language breakdown: a stacked percentage bar +
7 * a per-language table (bytes, files, share). Uses `listTreeRecursive`
8 * against the repo's default branch (or an explicit `ref`) to get file
9 * sizes, then runs the pure `buildLanguageReport` helper.
10 *
11 * Defaults: vendored/generated files (node_modules, dist, lockfiles, etc.)
12 * are excluded. Pass `?vendored=1` to include them.
13 *
14 * softAuth + try/catch-wrapped resolveRepo — never 500s.
15 */
16
17import { Hono } from "hono";
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { getDefaultBranch, listTreeRecursive } from "../git/repository";
26import {
27 buildLanguageReport,
28 formatBytes,
29 formatPercent,
30 type LanguageFileEntry,
31 type LanguageReport,
32} from "../lib/language-stats";
33
34const languageRoutes = new Hono<AuthEnv>();
35
36languageRoutes.use("*", softAuth);
37
38async function resolveRepo(ownerName: string, repoName: string) {
39 try {
40 const [owner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!owner) return null;
46 const [repo] = await db
47 .select()
48 .from(repositories)
49 .where(
50 and(
51 eq(repositories.ownerId, owner.id),
52 eq(repositories.name, repoName)
53 )
54 )
55 .limit(1);
56 if (!repo) return null;
57 return { owner, repo };
58 } catch {
59 return null;
60 }
61}
62
63function parseFold(raw: string | undefined): number {
64 if (!raw) return 0;
65 const n = Number(raw);
66 if (!Number.isFinite(n) || n <= 0 || n >= 100) return 0;
67 return n;
68}
69
70/** Minimal ref sanity check — branches/tags shouldn't contain whitespace or `..`. */
71function sanitiseRef(raw: string | undefined): string | null {
72 if (!raw) return null;
73 const trimmed = raw.trim();
74 if (!trimmed || trimmed.length > 200) return null;
75 if (/\.\./.test(trimmed)) return null;
76 if (/[\s~^:?*[\\]/.test(trimmed)) return null;
77 return trimmed;
78}
79
80languageRoutes.get("/:owner/:repo/languages", async (c) => {
81 const { owner: ownerName, repo: repoName } = c.req.param();
82 const user = c.get("user");
83
84 const includeVendored = c.req.query("vendored") === "1";
85 const foldUnderPercent = parseFold(c.req.query("fold"));
86 const refParam = sanitiseRef(c.req.query("ref"));
87
88 const resolved = await resolveRepo(ownerName, repoName);
89 if (!resolved) {
90 return c.html(
91 <Layout title="Not Found" user={user}>
92 <div class="empty-state">
93 <h2>Repository not found</h2>
94 </div>
95 </Layout>,
96 404
97 );
98 }
99
100 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
101 return c.html(
102 <Layout title="Not Found" user={user}>
103 <div class="empty-state">
104 <h2>Repository not found</h2>
105 </div>
106 </Layout>,
107 404
108 );
109 }
110
111 let ref: string | null = refParam;
112 if (!ref) {
113 try {
114 ref = await getDefaultBranch(ownerName, repoName);
115 } catch {
116 ref = null;
117 }
118 }
119
120 let entries: LanguageFileEntry[] = [];
121 if (ref) {
122 try {
123 entries = await listTreeRecursive(ownerName, repoName, ref);
124 } catch {
125 entries = [];
126 }
127 }
128
129 const report: LanguageReport = buildLanguageReport({
130 entries,
131 ignoreVendored: !includeVendored,
132 foldUnderPercent,
133 });
134
135 const empty = report.buckets.length === 0;
136
137 return c.html(
138 <Layout title={`Languages — ${ownerName}/${repoName}`} user={user}>
139 <RepoHeader owner={ownerName} repo={repoName} />
140 <div style="max-width: 920px">
141 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
142 <h2 style="margin: 0">Languages</h2>
143 <form
144 method="GET"
145 action={`/${ownerName}/${repoName}/languages`}
146 style="display: flex; gap: 10px; align-items: center; font-size: 12px"
147 >
148 <label style="display: flex; align-items: center; gap: 4px">
149 <input
150 type="checkbox"
151 name="vendored"
152 value="1"
153 checked={includeVendored}
154 onchange="this.form.submit()"
155 />
156 Include vendored
157 </label>
158 <label style="display: flex; align-items: center; gap: 4px">
159 Fold &lt;
160 <select
161 name="fold"
162 onchange="this.form.submit()"
163 style="padding: 2px 6px; font-size: 12px"
164 >
165 {[0, 0.5, 1, 2, 5].map((n) => (
166 <option
167 value={String(n)}
168 selected={Math.abs(n - foldUnderPercent) < 0.001}
169 >
170 {n === 0 ? "Off" : `${n}%`}
171 </option>
172 ))}
173 </select>
174 </label>
175 {refParam ? (
176 <input type="hidden" name="ref" value={refParam} />
177 ) : null}
178 </form>
179 </div>
180 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
181 {ref ? (
182 <>
183 Analyzed <code>{ref}</code> —{" "}
184 <strong>{report.countedFiles.toLocaleString()}</strong> of{" "}
185 {report.totalFiles.toLocaleString()} files (
186 {formatBytes(report.totalBytes)} total).{" "}
187 {includeVendored
188 ? "Including vendored + lock files."
189 : "Vendored directories and lock files excluded."}
190 </>
191 ) : (
192 <>No default branch detected — repository may be empty.</>
193 )}
194 </p>
195
196 {empty ? (
197 <div class="empty-state">
198 <h3>No classifiable files</h3>
199 <p>
200 {ref
201 ? "Either the repo is empty, only contains vendored files, or the file types aren't recognised."
202 : "Push some code to see the language breakdown."}
203 </p>
204 </div>
205 ) : (
206 <>
207 <div
208 style="display: flex; width: 100%; height: 12px; border-radius: 6px; overflow: hidden; margin-bottom: 6px; background: var(--bg-secondary)"
209 aria-label="Language breakdown"
210 role="img"
211 >
212 {report.buckets.map((b) => (
213 <div
214 title={`${b.language} — ${formatPercent(b.percent)}`}
215 style={`width: ${b.percent.toFixed(4)}%; background: ${b.color}; height: 100%`}
216 />
217 ))}
218 </div>
219 <div style="display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 20px; font-size: 12px">
220 {report.buckets.map((b) => (
221 <span style="display: inline-flex; align-items: center; gap: 6px">
222 <span
223 style={`display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: ${b.color}`}
224 />
225 <strong>{b.language}</strong>
226 <span style="color: var(--text-muted)">
227 {formatPercent(b.percent)}
228 </span>
229 </span>
230 ))}
231 </div>
232
233 <table style="width: 100%; border-collapse: collapse">
234 <thead>
235 <tr>
236 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
237 Language
238 </th>
239 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
240 Files
241 </th>
242 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
243 Size
244 </th>
245 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
246 Share
247 </th>
248 </tr>
249 </thead>
250 <tbody>
251 {report.buckets.map((b) => (
252 <tr>
253 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
254 <span
255 style={`display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${b.color}; margin-right: 8px; vertical-align: middle`}
256 />
257 {b.language}
258 </td>
259 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
260 {b.fileCount.toLocaleString()}
261 </td>
262 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
263 {formatBytes(b.bytes)}
264 </td>
265 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
266 {formatPercent(b.percent)}
267 </td>
268 </tr>
269 ))}
270 </tbody>
271 </table>
272 </>
273 )}
274 </div>
275 </Layout>
276 );
277});
278
279export default languageRoutes;