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

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

compare.tsxBlame584 lines · 2 contributors
79136bbClaude1/**
2 * Compare view — diff between two branches or commits.
3 * URL: /:owner/:repo/compare/:base...:head
23b3ccaClaude4 *
5 * The picker view and the comparison view both carry the 2026 polish:
6 * hero card with gradient hairline strip + display title, polished
7 * branch-selector cards with a prominent arrow between them, +/- diff
8 * stats with tabular numerals, modernized commit rows (SHA pill +
9 * monospace message + author + age), and a gradient "Create pull
10 * request" CTA. All styling is scoped via `.compare-*` class prefixes
11 * inside an inline <style> block so no other surface is touched.
12 *
13 * No business logic was changed in this polish pass — branch listing,
14 * diff fetching, commit enumeration, and the PR-creation redirect URL
15 * (`/:owner/:repo/pulls/new?base=…&head=…`) are preserved exactly.
79136bbClaude16 */
17
18import { Hono } from "hono";
19import { Layout } from "../views/layout";
f6730d0ccantynz-alt20import { RepoHeader, PageHeader, Badge, Button, sharedComponentStyles } from "../views/components";
ea9ed4cClaude21import { DiffView } from "../views/diff-view";
79136bbClaude22import { IssueNav } from "./issues";
23import {
24 listBranches,
25 repoExists,
26 getRepoPath,
27} from "../git/repository";
28import { softAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import type { GitDiffFile } from "../git/repository";
23b3ccaClaude31import { EmptyState } from "../views/ui";
79136bbClaude32
33const compare = new Hono<AuthEnv>();
34
35compare.use("*", softAuth);
36
23b3ccaClaude37/* ──────────────────────────────────────────────────────────────────────
38 * Inline CSS scoped via `.compare-*` so other surfaces remain untouched.
39 * Tokens come from layout.tsx `:root` for light/dark consistency.
40 * ──────────────────────────────────────────────────────────────────── */
41const COMPARE_STYLES = `
42 /* Branch selector — base ← head */
43 .compare-branches {
44 display: flex;
45 align-items: stretch;
46 gap: 12px;
47 flex-wrap: wrap;
48 margin: 0 0 var(--space-4);
49 }
50 .compare-branch-card {
51 flex: 1 1 240px;
52 min-width: 220px;
53 display: flex;
54 flex-direction: column;
55 gap: 6px;
56 padding: 12px 14px;
57 background: var(--bg-elevated);
58 border: 1px solid var(--border);
59 border-radius: 12px;
60 transition: border-color 140ms ease, box-shadow 160ms ease;
61 }
62 .compare-branch-card:focus-within {
6fd5915Claude63 border-color: rgba(91,110,232,0.55);
64 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
23b3ccaClaude65 }
66 .compare-branch-label {
67 display: flex;
68 align-items: center;
69 gap: 6px;
70 font-size: 11px;
71 font-family: var(--font-mono);
72 letter-spacing: 0.12em;
73 text-transform: uppercase;
74 color: var(--text-muted);
75 font-weight: 600;
76 }
77 .compare-branch-label .compare-branch-icon {
78 color: var(--text-faint);
79 font-size: 12px;
80 }
81 .compare-branch-card.is-base .compare-branch-label strong { color: #ff9d76; }
e589f77ccantynz-alt82 .compare-branch-card.is-head .compare-branch-label strong { color: var(--accent); }
23b3ccaClaude83 .compare-branch-select {
84 appearance: none;
85 -webkit-appearance: none;
86 width: 100%;
87 padding: 8px 32px 8px 10px;
88 background: var(--bg-secondary);
89 border: 1px solid var(--border);
90 border-radius: 8px;
91 color: var(--text-strong);
92 font-size: 13.5px;
93 font-family: var(--font-mono);
94 line-height: 1.4;
95 cursor: pointer;
96 background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
97 background-position: calc(100% - 16px) 14px, calc(100% - 11px) 14px;
98 background-size: 5px 5px;
99 background-repeat: no-repeat;
100 transition: border-color 140ms ease, background-color 140ms ease;
101 }
102 .compare-branch-select:hover { border-color: var(--border-strong); }
6fd5915Claude103 .compare-branch-select:focus { outline: none; border-color: rgba(91,110,232,0.55); }
23b3ccaClaude104 .compare-branch-arrow {
105 flex: 0 0 auto;
106 align-self: center;
107 display: inline-flex; align-items: center; justify-content: center;
108 width: 36px; height: 36px;
109 border-radius: 9999px;
6fd5915Claude110 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.16));
111 border: 1px solid rgba(91,110,232,0.30);
23b3ccaClaude112 color: var(--text-strong);
113 font-size: 16px;
114 line-height: 1;
115 font-weight: 700;
6fd5915Claude116 box-shadow: 0 0 18px -8px rgba(91,110,232,0.45);
23b3ccaClaude117 }
118 @media (max-width: 720px) {
119 .compare-branch-arrow { align-self: flex-start; transform: rotate(90deg); }
120 }
121 .compare-form-actions {
122 display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
123 }
124
125 /* Summary stats — commits ahead / behind */
126 .compare-stats {
127 display: flex;
128 flex-wrap: wrap;
f6730d0ccantynz-alt129 align-items: center;
23b3ccaClaude130 gap: 8px;
131 margin: 0 0 var(--space-4);
132 }
133
134 /* Diff stats bar — +/- counts */
135 .compare-diffstats {
136 display: inline-flex; align-items: center; gap: 10px;
137 padding: 6px 12px;
138 border-radius: 9999px;
139 font-size: 12.5px;
140 font-family: var(--font-mono);
141 font-variant-numeric: tabular-nums;
142 background: var(--bg-secondary);
143 border: 1px solid var(--border);
144 }
145 .compare-diffstats .compare-add { color: var(--green); font-weight: 700; }
146 .compare-diffstats .compare-del { color: var(--red); font-weight: 700; }
147 .compare-diffstats .compare-files-c { color: var(--text-muted); }
148
149 /* Section heading row above commit list / diff */
150 .compare-section-head {
151 display: flex; align-items: center; justify-content: space-between;
152 gap: 12px;
153 margin: 0 0 12px;
154 flex-wrap: wrap;
155 }
156 .compare-section-title {
157 font-size: 11.5px;
158 font-family: var(--font-mono);
159 letter-spacing: 0.14em;
160 text-transform: uppercase;
161 color: var(--text-muted);
162 font-weight: 600;
163 margin: 0;
164 }
165
166 /* Commit list */
167 .compare-commits {
168 display: flex; flex-direction: column;
169 margin: 0 0 var(--space-5);
170 background: var(--bg-elevated);
171 border: 1px solid var(--border);
172 border-radius: 12px;
173 overflow: hidden;
174 }
175 .compare-commit {
176 display: flex; align-items: center; gap: 14px;
177 padding: 12px 16px;
178 border-bottom: 1px solid var(--border);
179 transition: background 120ms ease;
180 }
181 .compare-commit:last-child { border-bottom: none; }
182 .compare-commit:hover { background: var(--bg-hover); }
183 .compare-commit-body { flex: 1; min-width: 0; }
184 .compare-commit-msg {
185 font-size: 14px;
186 color: var(--text-strong);
187 line-height: 1.4;
188 margin: 0 0 4px;
189 overflow: hidden;
190 text-overflow: ellipsis;
191 white-space: nowrap;
192 }
193 .compare-commit-msg a {
194 color: var(--text-strong);
195 text-decoration: none;
196 font-weight: 500;
197 }
198 .compare-commit-msg a:hover { color: var(--accent); }
199 .compare-commit-meta {
200 font-size: 12px;
201 color: var(--text-muted);
202 display: inline-flex; align-items: center; gap: 8px;
203 }
204 .compare-commit-meta .compare-commit-author {
205 color: var(--text);
206 font-weight: 500;
207 }
208 .compare-commit-meta .compare-commit-dot {
209 color: var(--text-faint);
210 }
211 .compare-commit-sha {
212 flex: 0 0 auto;
213 display: inline-flex; align-items: center;
214 padding: 4px 10px;
215 border-radius: 9999px;
216 background: var(--bg-secondary);
217 border: 1px solid var(--border);
218 color: var(--text);
219 font-family: var(--font-mono);
220 font-size: 12px;
221 text-decoration: none;
222 transition: border-color 140ms ease, color 140ms ease;
223 }
224 .compare-commit-sha:hover {
6fd5915Claude225 border-color: rgba(91,110,232,0.45);
23b3ccaClaude226 color: var(--text-link);
227 }
228
229 /* Empty / identical state */
230 .compare-empty {
231 padding: 36px 28px;
232 text-align: center;
233 border: 1px dashed var(--border);
234 border-radius: 12px;
235 background: var(--bg-secondary);
236 color: var(--text-muted);
237 margin: 0 0 var(--space-5);
238 }
239 .compare-empty strong {
240 display: block;
241 color: var(--text-strong);
242 font-size: 15px;
243 margin-bottom: 6px;
244 }
245 .compare-empty p {
246 margin: 0 auto;
247 max-width: 460px;
248 font-size: 13.5px;
249 line-height: 1.55;
250 }
251 .compare-empty .compare-empty-icon {
252 display: inline-flex; align-items: center; justify-content: center;
253 width: 44px; height: 44px;
254 border-radius: 9999px;
6fd5915Claude255 background: linear-gradient(135deg, rgba(91,110,232,0.16), rgba(95,143,160,0.14));
256 border: 1px solid rgba(91,110,232,0.28);
23b3ccaClaude257 color: var(--text-strong);
258 font-size: 18px;
259 margin: 0 auto 12px;
260 }
261
262 @media (max-width: 720px) {
263 .compare-form-actions { width: 100%; }
264 }
265`;
266
267/** Format an ISO date as a short relative string ("3h", "2d", "5w"). */
268function relTime(iso: string): string {
269 const t = Date.parse(iso);
270 if (!Number.isFinite(t)) return "";
271 const diff = Math.max(0, Date.now() - t);
272 const s = Math.floor(diff / 1000);
273 if (s < 60) return `${s}s ago`;
274 const m = Math.floor(s / 60);
275 if (m < 60) return `${m}m ago`;
276 const h = Math.floor(m / 60);
277 if (h < 24) return `${h}h ago`;
278 const d = Math.floor(h / 24);
279 if (d < 7) return `${d}d ago`;
280 const w = Math.floor(d / 7);
281 if (w < 5) return `${w}w ago`;
282 const mo = Math.floor(d / 30);
283 if (mo < 12) return `${mo}mo ago`;
284 const y = Math.floor(d / 365);
285 return `${y}y ago`;
286}
287
79136bbClaude288compare.get("/:owner/:repo/compare/:spec?", async (c) => {
289 const { owner, repo } = c.req.param();
290 const user = c.get("user");
291 const spec = c.req.param("spec");
292
293 if (!(await repoExists(owner, repo))) {
294 return c.html(
295 <Layout title="Not Found" user={user}>
bb0f894Claude296 <EmptyState title="Repository not found" />
79136bbClaude297 </Layout>,
298 404
299 );
300 }
301
302 const branches = await listBranches(owner, repo);
303
304 if (!spec || !spec.includes("...")) {
305 // Show compare picker
306 const defaultBase = branches.includes("main") ? "main" : branches[0] || "";
23b3ccaClaude307 const defaultHead =
308 branches.find((b) => b !== defaultBase) || defaultBase;
79136bbClaude309 return c.html(
310 <Layout title={`Compare — ${owner}/${repo}`} user={user}>
311 <RepoHeader owner={owner} repo={repo} />
312 <IssueNav owner={owner} repo={repo} active="code" />
f6730d0ccantynz-alt313 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
23b3ccaClaude314 <style dangerouslySetInnerHTML={{ __html: COMPARE_STYLES }} />
315
f6730d0ccantynz-alt316 <PageHeader
317 eyebrow={`Compare changes · ${owner}/${repo}`}
318 title="Compare branches."
319 lede="Pick a base and a head branch to see exactly what changed. Open a pull request when the diff looks right."
320 />
23b3ccaClaude321
322 <form method="get" action={`/${owner}/${repo}/compare`}>
323 <div class="compare-branches">
324 <div class="compare-branch-card is-base">
325 <label class="compare-branch-label" for="compare-base">
326 <span class="compare-branch-icon">⇤</span>
327 <strong>Base</strong>
328 <span style="color:var(--text-faint);font-weight:500">
329 · what you merge into
330 </span>
331 </label>
332 <select
333 id="compare-base"
334 name="base"
335 class="compare-branch-select"
336 >
337 {branches.map((b) => (
338 <option value={b} selected={b === defaultBase}>
339 {b}
340 </option>
341 ))}
342 </select>
343 </div>
344
345 <div class="compare-branch-arrow" aria-hidden="true">←</div>
346
347 <div class="compare-branch-card is-head">
348 <label class="compare-branch-label" for="compare-head">
349 <span class="compare-branch-icon">⇥</span>
350 <strong>Head</strong>
351 <span style="color:var(--text-faint);font-weight:500">
352 · what you want to merge
353 </span>
354 </label>
355 <select
356 id="compare-head"
357 name="head"
358 class="compare-branch-select"
359 >
360 {branches.map((b) => (
361 <option value={b} selected={b === defaultHead}>
362 {b}
363 </option>
364 ))}
365 </select>
366 </div>
367 </div>
368
369 <div class="compare-form-actions">
f6730d0ccantynz-alt370 <Button
371 variant="primary"
bb0f894Claude372 type="submit"
2e9136dClaude373 onclick={`this.form.action='/${owner}/${repo}/compare/'+this.form.base.value+'...'+this.form.head.value; return true;`}
bb0f894Claude374 >
23b3ccaClaude375 Compare branches →
f6730d0ccantynz-alt376 </Button>
377 <Button variant="secondary" href={`/${owner}/${repo}`}>
23b3ccaClaude378 Cancel
f6730d0ccantynz-alt379 </Button>
23b3ccaClaude380 </div>
79136bbClaude381 </form>
382 </Layout>
383 );
384 }
385
386 const [base, head] = spec.split("...");
387
388 // Get diff
389 const repoDir = getRepoPath(owner, repo);
390 const proc = Bun.spawn(
391 ["git", "diff", `${base}...${head}`],
392 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
393 );
394 const raw = await new Response(proc.stdout).text();
395 await proc.exited;
396
397 // Get numstat
398 const statProc = Bun.spawn(
399 ["git", "diff", "--numstat", `${base}...${head}`],
400 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
401 );
402 const stat = await new Response(statProc.stdout).text();
403 await statProc.exited;
404
405 const files: GitDiffFile[] = stat
406 .trim()
407 .split("\n")
408 .filter(Boolean)
409 .map((line) => {
410 const [add, del, filePath] = line.split("\t");
411 return {
412 path: filePath,
413 status: "modified",
414 additions: add === "-" ? 0 : parseInt(add, 10),
415 deletions: del === "-" ? 0 : parseInt(del, 10),
416 patch: "",
417 };
418 });
419
420 // Get commits between
421 const logProc = Bun.spawn(
422 [
423 "git",
424 "log",
425 "--format=%H%x00%s%x00%an%x00%aI",
426 `${base}...${head}`,
427 ],
428 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
429 );
430 const logOutput = await new Response(logProc.stdout).text();
431 await logProc.exited;
432
433 const commitsBetween = logOutput
434 .trim()
435 .split("\n")
436 .filter(Boolean)
437 .map((line) => {
438 const [sha, msg, author, date] = line.split("\0");
439 return { sha, message: msg, author, date };
440 });
441
23b3ccaClaude442 // Aggregate diff stats for the badge bar.
443 const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
444 const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
445 const isIdentical = base === head;
446 const hasChanges = commitsBetween.length > 0 || files.length > 0;
447
f6730d0ccantynz-alt448 const cmpTitle = hasChanges
449 ? `${commitsBetween.length} commit${commitsBetween.length !== 1 ? "s" : ""} ahead.`
450 : isIdentical
451 ? "Nothing to compare."
452 : "No changes between branches.";
453 const cmpSuffix = hasChanges
454 ? " — review the diff below, then open a pull request when it looks right."
455 : isIdentical
456 ? " — base and head point to the same ref. Pick different branches to see a diff."
457 : " — these branches diverge but produce no diff. Nothing to merge.";
458 const cmpLede = `${base} ← ${head}${cmpSuffix}`;
459
79136bbClaude460 return c.html(
461 <Layout title={`${base}...${head} — ${owner}/${repo}`} user={user}>
462 <RepoHeader owner={owner} repo={repo} />
463 <IssueNav owner={owner} repo={repo} active="code" />
f6730d0ccantynz-alt464 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
23b3ccaClaude465 <style dangerouslySetInnerHTML={{ __html: COMPARE_STYLES }} />
466
f6730d0ccantynz-alt467 <PageHeader
468 eyebrow={`Comparing · ${owner}/${repo}`}
469 title={cmpTitle}
470 lede={cmpLede}
471 />
23b3ccaClaude472
473 {/* Summary stats row */}
474 {hasChanges && (
475 <div class="compare-stats">
f6730d0ccantynz-alt476 <Badge variant="success">
477 +{commitsBetween.length} commit{commitsBetween.length !== 1 ? "s" : ""} ahead
478 </Badge>
479 <Badge variant="info">
480 {files.length} file{files.length !== 1 ? "s" : ""} changed
481 </Badge>
23b3ccaClaude482 <span class="compare-diffstats">
483 <span class="compare-add">+{totalAdditions}</span>
484 <span class="compare-del">−{totalDeletions}</span>
485 <span class="compare-files-c">lines</span>
486 </span>
f6730d0ccantynz-alt487 <Button
488 variant="primary"
23b3ccaClaude489 href={`/${owner}/${repo}/pulls/new?base=${encodeURIComponent(base)}&head=${encodeURIComponent(head)}`}
490 style="margin-left:auto"
491 >
492 Create pull request →
f6730d0ccantynz-alt493 </Button>
23b3ccaClaude494 </div>
495 )}
496
497 {!hasChanges && (
498 <div class="compare-empty">
499 <div class="compare-empty-icon">≡</div>
500 <strong>
501 {isIdentical
502 ? "Nothing to compare."
503 : "No diff between these branches."}
504 </strong>
505 <p>
506 {isIdentical
507 ? "Base and head are the same branch. Push some commits to a feature branch, then come back here to compare and open a PR."
508 : "These branches diverge but produce no file changes. There's nothing to merge."}
509 </p>
510 <div
511 class="compare-form-actions"
512 style="justify-content:center;margin-top:16px"
513 >
f6730d0ccantynz-alt514 <Button variant="secondary" href={`/${owner}/${repo}/compare`}>
23b3ccaClaude515 ← Pick different branches
f6730d0ccantynz-alt516 </Button>
517 <Button
518 variant="primary"
519 disabled
23b3ccaClaude520 title="Nothing to compare yet"
521 >
522 Create pull request →
f6730d0ccantynz-alt523 </Button>
23b3ccaClaude524 </div>
525 </div>
526 )}
79136bbClaude527
528 {commitsBetween.length > 0 && (
23b3ccaClaude529 <>
530 <div class="compare-section-head">
531 <h2 class="compare-section-title">Commits in this comparison</h2>
f6730d0ccantynz-alt532 <Badge variant="neutral">{commitsBetween.length} total</Badge>
23b3ccaClaude533 </div>
534 <div class="compare-commits">
535 {commitsBetween.map((cm) => (
536 <div class="compare-commit">
537 <div class="compare-commit-body">
538 <div class="compare-commit-msg">
539 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
540 {cm.message}
541 </a>
542 </div>
543 <div class="compare-commit-meta">
544 <span class="compare-commit-author">{cm.author}</span>
545 <span class="compare-commit-dot">·</span>
546 <span>{relTime(cm.date)}</span>
547 </div>
79136bbClaude548 </div>
23b3ccaClaude549 <a
550 href={`/${owner}/${repo}/commit/${cm.sha}`}
551 class="compare-commit-sha"
552 >
553 {cm.sha.slice(0, 7)}
554 </a>
79136bbClaude555 </div>
23b3ccaClaude556 ))}
557 </div>
558 </>
79136bbClaude559 )}
560
23b3ccaClaude561 {files.length > 0 && (
562 <>
563 <div class="compare-section-head">
564 <h2 class="compare-section-title">File changes</h2>
565 <span class="compare-diffstats">
566 <span class="compare-add">+{totalAdditions}</span>
567 <span class="compare-del">−{totalDeletions}</span>
568 <span class="compare-files-c">
569 across {files.length} file{files.length !== 1 ? "s" : ""}
570 </span>
571 </span>
572 </div>
ea9ed4cClaude573 <DiffView
574 raw={raw}
575 files={files}
576 viewFileBase={`/${owner}/${repo}/blob/${head}`}
577 />
23b3ccaClaude578 </>
579 )}
79136bbClaude580 </Layout>
581 );
582});
583
584export default compare;