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

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

web.tsxBlame5720 lines · 3 contributors
fc1817aClaude1/**
2 * Web UI routes — browse repositories, code, commits, diffs.
06d5ffeClaude3 * Now auth-aware with user profiles, repo creation, stars, and syntax highlighting.
fc1817aClaude4 */
5
6import { Hono } from "hono";
79136bbClaude7import { html } from "hono/html";
ebaae0fClaude8import { eq, and, desc, inArray, sql, gte, count } from "drizzle-orm";
06d5ffeClaude9import { db } from "../db";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
8c790e0Claude16 activityFeed,
ebaae0fClaude17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
3951454Claude22} from "../db/schema";
fc1817aClaude23import { Layout } from "../views/layout";
cb5a796Claude24import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude25import {
26 RepoHeader,
27 RepoNav,
28 Breadcrumb,
29 FileTable,
06d5ffeClaude30 RepoCard,
31 BranchSwitcher,
32 HighlightedCode,
33 PlainCode,
8c790e0Claude34 type RecentPush,
fc1817aClaude35} from "../views/components";
ea9ed4cClaude36import { DiffView } from "../views/diff-view";
fc1817aClaude37import {
38 getTree,
39 getBlob,
40 listCommits,
41 getCommit,
42 getCommitFullMessage,
43 getDiff,
44 getReadme,
45 getDefaultBranch,
46 listBranches,
398a10cClaude47 listTags,
fc1817aClaude48 repoExists,
06d5ffeClaude49 initBareRepo,
79136bbClaude50 getBlame,
51 getRawBlob,
52 searchCode,
398a10cClaude53 getRepoPath,
fc1817aClaude54} from "../git/repository";
79136bbClaude55import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude56import { highlightCode } from "../lib/highlight";
91a0204Claude57import { computeHealthScore } from "../lib/health-score";
58import type { HealthScore } from "../lib/health-score";
06d5ffeClaude59import { softAuth, requireAuth } from "../middleware/auth";
60import type { AuthEnv } from "../middleware/auth";
53c9249Claude61import { claudeSemanticSearch } from "../lib/claude-semantic-search";
62import { isAiAvailable } from "../lib/ai-client";
8f50ed0Claude63import { trackByName } from "../lib/traffic";
534f04aClaude64import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude65import { Landing2030Page } from "../views/landing-2030";
52ad8b1Claude66import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude67import {
68 listQueuedAiBuildIssues,
69 listRecentAutoMerges,
70 listRecentAiReviews,
71 countAiReviewsSince,
72 listDemoActivityFeed,
73} from "../lib/demo-activity";
fc1817aClaude74
06d5ffeClaude75const web = new Hono<AuthEnv>();
76
77// Soft auth on all web routes — c.get("user") available but may be null
78web.use("*", softAuth);
fc1817aClaude79
ebaae0fClaude80// ---------------------------------------------------------------------------
81// Repo AI stats — computed once per repo home page load, best-effort.
82// Fast because every WHERE clause is bounded to a 7-day window.
83// ---------------------------------------------------------------------------
84interface RepoAiStats {
85 mergedCount: number;
86 reviewCount: number;
87 securityAlertCount: number;
88 hoursSaved: string;
89}
90
91async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
92 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
93
94 // 1. AI-merged PRs this week: activity_feed entries with action =
95 // 'auto_merge.merged' in the last 7 days for this repo.
96 // This is cheaper than a JOIN on pull_requests and covers both the
97 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
98 const [mergedRow] = await db
99 .select({ n: count() })
100 .from(activityFeed)
101 .where(
102 and(
103 eq(activityFeed.repositoryId, repoId),
104 eq(activityFeed.action, "auto_merge.merged"),
105 gte(activityFeed.createdAt, since)
106 )
107 );
108 const mergedCount = Number(mergedRow?.n ?? 0);
109
110 // 2. AI reviews this week: pr_comments where is_ai_review = true in
111 // the last 7 days, joined through pull_requests to scope to this repo.
112 const [reviewRow] = await db
113 .select({ n: count() })
114 .from(prComments)
115 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
116 .where(
117 and(
118 eq(pullRequests.repositoryId, repoId),
119 eq(prComments.isAiReview, true),
120 gte(prComments.createdAt, since)
121 )
122 );
123 const reviewCount = Number(reviewRow?.n ?? 0);
124
125 // 3. Open security alerts: open issues that carry a label whose name
126 // contains 'security' (case-insensitive) for this repo.
127 const [secRow] = await db
128 .select({ n: count() })
129 .from(issues)
130 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
131 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
132 .where(
133 and(
134 eq(issues.repositoryId, repoId),
135 eq(issues.state, "open"),
136 sql`lower(${labels.name}) like '%security%'`
137 )
138 );
139 const securityAlertCount = Number(secRow?.n ?? 0);
140
141 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
142 const hours = mergedCount * 1.5 + reviewCount * 0.5;
143 const hoursSaved = hours.toFixed(1);
144
145 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
146}
147
8c790e0Claude148/**
149 * Query the most recent push to a repo from the activity_feed table.
150 * Returns a RecentPush if there's been a push in the last 24 hours,
151 * or null otherwise. Used by the RepoHeader live/watch indicator.
152 *
153 * Queries activity_feed where action='push' and targetId holds the commit SHA.
154 */
155async function getRecentPush(repoId: string): Promise<RecentPush | null> {
156 try {
157 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
158 const [row] = await db
159 .select({
160 targetId: activityFeed.targetId,
161 createdAt: activityFeed.createdAt,
162 })
163 .from(activityFeed)
164 .where(
165 and(
166 eq(activityFeed.repositoryId, repoId),
167 eq(activityFeed.action, "push"),
168 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
169 )
170 )
171 .orderBy(desc(activityFeed.createdAt))
172 .limit(1);
173 if (!row || !row.targetId) return null;
174 return {
175 sha: row.targetId,
176 ageMs: Date.now() - new Date(row.createdAt).getTime(),
177 };
178 } catch {
179 // DB unavailable or schema mismatch — degrade gracefully.
180 return null;
181 }
182}
183
efb11c5Claude184/**
185 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
186 *
187 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
188 * scope is route-local and `layout.tsx` is locked. Each polished handler
189 * injects this via a `<style>` tag; the rules are namespaced by surface
190 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
191 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
192 * `.repo-home-*` styling Agent A already shipped.
193 */
194const codeBrowseCss = `
195 /* ───────── shared primitives ───────── */
196 .cb-hairline::before,
197 .new-repo-hero::before,
198 .profile-hero::before,
199 .commits-hero::before,
200 .commit-detail-card::before,
201 .search-hero::before {
202 content: '';
203 position: absolute;
204 top: 0; left: 0; right: 0;
205 height: 2px;
206 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
207 opacity: 0.7;
208 pointer-events: none;
209 }
210 @keyframes cbHeroOrb {
211 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
212 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
213 }
214 @media (prefers-reduced-motion: reduce) {
215 .new-repo-hero-orb,
216 .profile-hero-orb,
217 .commits-hero-orb { animation: none; }
218 }
219
220 /* ───────── new-repo ───────── */
221 .new-repo-hero {
222 position: relative;
223 margin-bottom: var(--space-5);
224 padding: var(--space-5) var(--space-6);
225 background: var(--bg-elevated);
226 border: 1px solid var(--border);
227 border-radius: 16px;
228 overflow: hidden;
229 }
230 .new-repo-hero-orb-wrap {
231 position: absolute;
232 inset: -25% -10% auto auto;
233 width: 360px;
234 height: 360px;
235 pointer-events: none;
236 z-index: 0;
237 }
238 .new-repo-hero-orb {
239 position: absolute;
240 inset: 0;
241 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
242 filter: blur(80px);
243 opacity: 0.7;
244 animation: cbHeroOrb 14s ease-in-out infinite;
245 }
246 .new-repo-hero-inner { position: relative; z-index: 1; }
247 .new-repo-eyebrow {
248 font-size: 12px;
249 font-family: var(--font-mono);
250 color: var(--text-muted);
251 letter-spacing: 0.1em;
252 text-transform: uppercase;
253 margin-bottom: var(--space-2);
254 }
255 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
256 .new-repo-title {
257 font-family: var(--font-display);
258 font-weight: 800;
259 letter-spacing: -0.028em;
260 font-size: clamp(28px, 4vw, 40px);
261 line-height: 1.05;
262 margin: 0 0 var(--space-2);
263 color: var(--text-strong);
264 }
265 .new-repo-sub {
266 font-size: 15px;
267 color: var(--text-muted);
268 margin: 0;
269 line-height: 1.55;
270 max-width: 620px;
271 }
272 .new-repo-form {
273 max-width: 680px;
274 }
275 .new-repo-error {
276 background: rgba(218, 54, 51, 0.12);
277 border: 1px solid rgba(218, 54, 51, 0.35);
278 color: #ffb3b3;
279 padding: 10px 14px;
280 border-radius: 10px;
281 margin-bottom: var(--space-4);
282 font-size: 14px;
283 }
284 .new-repo-form-grid {
285 display: flex;
286 flex-direction: column;
287 gap: var(--space-4);
288 }
289 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
290 .new-repo-label {
291 font-size: 13px;
292 color: var(--text-strong);
293 font-weight: 600;
294 }
295 .new-repo-label-optional {
296 color: var(--text-muted);
297 font-weight: 400;
298 font-size: 12px;
299 }
300 .new-repo-input {
301 appearance: none;
302 width: 100%;
303 background: var(--bg);
304 border: 1px solid var(--border);
305 color: var(--text-strong);
306 border-radius: 10px;
307 padding: 10px 12px;
308 font-size: 14px;
309 font-family: inherit;
310 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
311 }
312 .new-repo-input:focus {
313 outline: none;
314 border-color: var(--accent);
315 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
316 }
317 .new-repo-input-disabled {
318 color: var(--text-muted);
319 background: var(--bg-secondary);
320 cursor: not-allowed;
321 }
322 .new-repo-hint {
323 font-size: 12px;
324 color: var(--text-muted);
325 margin: 4px 0 0;
326 }
327 .new-repo-hint code {
328 font-family: var(--font-mono);
329 font-size: 11.5px;
330 background: var(--bg-secondary);
331 border: 1px solid var(--border);
332 border-radius: 5px;
333 padding: 1px 5px;
334 color: var(--text-strong);
335 }
336 .new-repo-visibility {
337 display: grid;
338 grid-template-columns: 1fr 1fr;
339 gap: var(--space-2);
340 }
341 @media (max-width: 600px) {
342 .new-repo-visibility { grid-template-columns: 1fr; }
343 }
344 .new-repo-vis-card {
345 display: flex;
346 gap: 10px;
347 padding: 14px;
348 background: var(--bg);
349 border: 1px solid var(--border);
350 border-radius: 12px;
351 cursor: pointer;
352 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
353 }
354 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
355 .new-repo-vis-card:has(input:checked) {
356 border-color: rgba(140,109,255,0.55);
357 background: rgba(140,109,255,0.06);
358 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
359 }
360 .new-repo-vis-radio {
361 margin-top: 3px;
362 accent-color: var(--accent);
363 }
364 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
365 .new-repo-vis-label {
366 font-size: 14px;
367 font-weight: 600;
368 color: var(--text-strong);
369 }
370 .new-repo-vis-desc {
371 font-size: 12.5px;
372 color: var(--text-muted);
373 line-height: 1.45;
374 }
375 .new-repo-callout {
376 margin-top: var(--space-2);
377 padding: var(--space-3) var(--space-4);
378 background: var(--accent-gradient-faint, rgba(140,109,255,0.06));
379 border: 1px solid rgba(140,109,255,0.2);
380 border-radius: 12px;
381 }
382 .new-repo-callout-eyebrow {
383 font-size: 11px;
384 font-family: var(--font-mono);
385 text-transform: uppercase;
386 letter-spacing: 0.12em;
387 color: var(--accent);
388 font-weight: 700;
389 margin-bottom: 4px;
390 }
391 .new-repo-callout-body {
392 font-size: 13px;
393 color: var(--text);
394 line-height: 1.5;
395 margin: 0;
396 }
397 .new-repo-callout-body code {
398 font-family: var(--font-mono);
399 font-size: 12px;
400 color: var(--accent);
401 background: rgba(140,109,255,0.1);
402 border-radius: 4px;
403 padding: 1px 5px;
404 }
398a10cClaude405 .new-repo-templates {
406 display: flex;
407 flex-wrap: wrap;
408 gap: 8px;
409 margin-top: 4px;
410 }
411 .new-repo-template-chip {
412 position: relative;
413 display: inline-flex;
414 align-items: center;
415 gap: 6px;
416 padding: 8px 14px;
417 background: var(--bg);
418 border: 1px solid var(--border);
419 border-radius: 999px;
420 font-size: 13px;
421 color: var(--text);
422 cursor: pointer;
423 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
424 }
425 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
426 .new-repo-template-chip:hover {
427 border-color: var(--border-strong, var(--border));
428 color: var(--text-strong);
429 }
430 .new-repo-template-chip:has(input:checked) {
431 border-color: rgba(140,109,255,0.55);
432 background: rgba(140,109,255,0.10);
433 color: var(--text-strong);
434 box-shadow: 0 0 0 1px rgba(140,109,255,0.25);
435 }
436 .new-repo-template-chip-dot {
437 width: 8px; height: 8px;
438 border-radius: 999px;
439 background: var(--text-faint, var(--text-muted));
440 transition: background 140ms ease, box-shadow 140ms ease;
441 }
442 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
443 background: linear-gradient(135deg, #8c6dff, #36c5d6);
444 box-shadow: 0 0 8px rgba(140,109,255,0.6);
445 }
efb11c5Claude446 .new-repo-actions {
447 display: flex;
448 gap: var(--space-2);
449 margin-top: var(--space-2);
398a10cClaude450 align-items: center;
451 }
452 .new-repo-submit {
453 min-width: 180px;
454 border: 1px solid rgba(140,109,255,0.45);
455 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
456 color: #fff;
457 font-weight: 700;
458 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
459 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
460 }
461 .new-repo-submit:hover {
462 transform: translateY(-1px);
463 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
464 filter: brightness(1.06);
465 }
466 .new-repo-submit:focus-visible {
467 outline: 3px solid rgba(140,109,255,0.45);
468 outline-offset: 2px;
efb11c5Claude469 }
470
471 /* ───────── profile ───────── */
472 .profile-hero {
473 position: relative;
474 margin-bottom: var(--space-5);
475 padding: var(--space-5) var(--space-6);
476 background: var(--bg-elevated);
477 border: 1px solid var(--border);
478 border-radius: 16px;
479 overflow: hidden;
480 }
481 .profile-hero-orb-wrap {
482 position: absolute;
483 inset: -25% -10% auto auto;
484 width: 360px;
485 height: 360px;
486 pointer-events: none;
487 z-index: 0;
488 }
489 .profile-hero-orb {
490 position: absolute;
491 inset: 0;
492 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
493 filter: blur(80px);
494 opacity: 0.7;
495 animation: cbHeroOrb 14s ease-in-out infinite;
496 }
497 .profile-hero-inner {
498 position: relative;
499 z-index: 1;
500 display: flex;
501 align-items: flex-start;
502 gap: var(--space-5);
503 }
504 .profile-hero-avatar {
505 flex: 0 0 auto;
506 width: 88px;
507 height: 88px;
508 border-radius: 50%;
509 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
510 color: #fff;
511 display: flex;
512 align-items: center;
513 justify-content: center;
514 font-size: 38px;
515 font-weight: 700;
516 font-family: var(--font-display);
517 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.55);
518 }
519 .profile-hero-text { flex: 1; min-width: 0; }
520 .profile-eyebrow {
521 font-size: 12px;
522 font-family: var(--font-mono);
523 color: var(--text-muted);
524 letter-spacing: 0.1em;
525 text-transform: uppercase;
526 margin-bottom: var(--space-2);
527 }
528 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
529 .profile-name {
530 font-family: var(--font-display);
531 font-weight: 800;
532 letter-spacing: -0.028em;
533 font-size: clamp(28px, 3.6vw, 36px);
534 line-height: 1.05;
535 margin: 0 0 4px;
536 color: var(--text-strong);
537 }
538 .profile-handle {
539 font-family: var(--font-mono);
540 font-size: 13px;
541 color: var(--text-muted);
542 margin-bottom: var(--space-2);
543 }
544 .profile-bio {
545 font-size: 14.5px;
546 color: var(--text);
547 line-height: 1.55;
548 margin: 0 0 var(--space-3);
549 max-width: 640px;
550 }
551 .profile-meta {
552 display: flex;
553 align-items: center;
554 gap: var(--space-4);
555 flex-wrap: wrap;
556 font-size: 13px;
557 }
558 .profile-meta-link {
559 color: var(--text-muted);
560 transition: color var(--t-fast, 0.15s) ease;
561 }
562 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
563 .profile-meta-link strong {
564 color: var(--text-strong);
565 font-weight: 600;
566 font-variant-numeric: tabular-nums;
567 }
568 .profile-follow-form { margin: 0; }
569 @media (max-width: 600px) {
570 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
571 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
572 }
573 .profile-readme {
574 margin-bottom: var(--space-6);
575 background: var(--bg-elevated);
576 border: 1px solid var(--border);
577 border-radius: 12px;
578 overflow: hidden;
579 }
580 .profile-readme-head {
581 display: flex;
582 align-items: center;
583 gap: 8px;
584 padding: 10px 16px;
585 background: var(--bg-secondary);
586 border-bottom: 1px solid var(--border);
587 font-size: 13px;
588 color: var(--text-muted);
589 }
590 .profile-readme-icon { color: var(--accent); font-size: 14px; }
591 .profile-readme-body { padding: var(--space-5) var(--space-6); }
592 .profile-section-head {
593 display: flex;
594 align-items: baseline;
595 gap: var(--space-2);
596 margin-bottom: var(--space-3);
597 }
598 .profile-section-title {
599 font-family: var(--font-display);
600 font-weight: 700;
601 font-size: 20px;
602 letter-spacing: -0.015em;
603 margin: 0;
604 color: var(--text-strong);
605 }
606 .profile-section-count {
607 font-family: var(--font-mono);
608 font-size: 12px;
609 color: var(--text-muted);
610 background: var(--bg-secondary);
611 border: 1px solid var(--border);
612 border-radius: 999px;
613 padding: 2px 10px;
614 font-variant-numeric: tabular-nums;
615 }
616 .profile-empty {
617 display: flex;
618 align-items: center;
619 justify-content: space-between;
620 gap: var(--space-3);
621 padding: var(--space-4) var(--space-5);
622 background: var(--bg-elevated);
623 border: 1px dashed var(--border);
624 border-radius: 12px;
625 }
626 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
627
628 /* ───────── tree (file browser) ───────── */
629 .tree-header {
630 margin-bottom: var(--space-3);
631 display: flex;
632 flex-direction: column;
633 gap: var(--space-2);
634 }
635 .tree-header-row {
636 display: flex;
637 align-items: center;
638 justify-content: space-between;
639 gap: var(--space-3);
640 flex-wrap: wrap;
641 }
642 .tree-header-stats {
643 display: flex;
644 align-items: center;
645 gap: var(--space-3);
646 font-size: 12px;
647 color: var(--text-muted);
648 }
649 .tree-stat strong {
650 color: var(--text-strong);
651 font-weight: 600;
652 font-variant-numeric: tabular-nums;
653 }
654 .tree-stat-link {
655 color: var(--text-muted);
656 padding: 4px 10px;
657 border-radius: 999px;
658 border: 1px solid var(--border);
659 background: var(--bg-elevated);
660 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
661 }
662 .tree-stat-link:hover {
663 color: var(--accent);
664 border-color: rgba(140,109,255,0.45);
665 text-decoration: none;
666 }
667 .tree-breadcrumb-row {
668 font-size: 13px;
669 }
670
671 /* ───────── blob (file viewer) ───────── */
672 .blob-toolbar {
673 margin-bottom: var(--space-3);
674 display: flex;
675 flex-direction: column;
676 gap: var(--space-2);
677 }
678 .blob-breadcrumb { font-size: 13px; }
679 .blob-card {
680 background: var(--bg-elevated);
681 border: 1px solid var(--border);
682 border-radius: 12px;
683 overflow: hidden;
684 }
685 .blob-header-polished {
686 display: flex;
687 align-items: center;
688 justify-content: space-between;
689 gap: var(--space-3);
690 padding: 10px 14px;
691 background: var(--bg-secondary);
692 border-bottom: 1px solid var(--border);
693 }
694 .blob-header-meta {
695 display: flex;
696 align-items: center;
697 gap: var(--space-2);
698 min-width: 0;
699 flex: 1;
700 }
701 .blob-header-icon { font-size: 14px; opacity: 0.85; }
702 .blob-header-name {
703 font-family: var(--font-mono);
704 font-size: 13px;
705 color: var(--text-strong);
706 font-weight: 600;
707 overflow: hidden;
708 text-overflow: ellipsis;
709 white-space: nowrap;
710 }
711 .blob-header-size {
712 font-family: var(--font-mono);
713 font-size: 11.5px;
714 color: var(--text-muted);
715 font-variant-numeric: tabular-nums;
716 border-left: 1px solid var(--border);
717 padding-left: var(--space-2);
718 margin-left: 2px;
719 }
720 .blob-header-actions {
721 display: flex;
722 gap: 6px;
723 flex-shrink: 0;
724 }
725 .blob-pill {
726 display: inline-flex;
727 align-items: center;
728 padding: 4px 12px;
729 font-size: 12px;
730 font-weight: 500;
731 color: var(--text);
732 background: var(--bg-elevated);
733 border: 1px solid var(--border);
734 border-radius: 999px;
735 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
736 }
737 .blob-pill:hover {
738 color: var(--accent);
739 border-color: rgba(140,109,255,0.45);
740 text-decoration: none;
741 background: rgba(140,109,255,0.06);
742 }
743 .blob-pill-accent {
744 color: var(--accent);
745 border-color: rgba(140,109,255,0.35);
746 background: rgba(140,109,255,0.08);
747 }
748 .blob-pill-accent:hover {
749 background: rgba(140,109,255,0.14);
750 }
751 .blob-binary {
752 padding: var(--space-5);
753 color: var(--text-muted);
754 text-align: center;
755 font-size: 13px;
756 background: var(--bg);
757 }
758
759 /* ───────── commits list ───────── */
760 .commits-hero {
761 position: relative;
762 margin-bottom: var(--space-4);
763 padding: var(--space-5) var(--space-6);
764 background: var(--bg-elevated);
765 border: 1px solid var(--border);
766 border-radius: 16px;
767 overflow: hidden;
768 }
769 .commits-hero-orb-wrap {
770 position: absolute;
771 inset: -25% -10% auto auto;
772 width: 320px;
773 height: 320px;
774 pointer-events: none;
775 z-index: 0;
776 }
777 .commits-hero-orb {
778 position: absolute;
779 inset: 0;
780 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
781 filter: blur(80px);
782 opacity: 0.7;
783 animation: cbHeroOrb 14s ease-in-out infinite;
784 }
785 .commits-hero-inner { position: relative; z-index: 1; }
786 .commits-eyebrow {
787 font-size: 12px;
788 font-family: var(--font-mono);
789 color: var(--text-muted);
790 letter-spacing: 0.1em;
791 text-transform: uppercase;
792 margin-bottom: var(--space-2);
793 }
794 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
795 .commits-title {
796 font-family: var(--font-display);
797 font-weight: 800;
798 letter-spacing: -0.025em;
799 font-size: clamp(22px, 3vw, 30px);
800 line-height: 1.15;
801 margin: 0 0 var(--space-2);
802 color: var(--text-strong);
803 }
804 .commits-branch {
805 font-family: var(--font-mono);
806 font-size: 0.7em;
807 color: var(--text);
808 background: var(--bg-secondary);
809 border: 1px solid var(--border);
810 border-radius: 6px;
811 padding: 2px 8px;
812 font-weight: 500;
813 vertical-align: middle;
814 }
815 .commits-sub {
816 font-size: 14px;
817 color: var(--text-muted);
818 margin: 0;
819 line-height: 1.55;
820 max-width: 640px;
821 }
398a10cClaude822 .commits-toolbar {
823 display: flex;
824 justify-content: space-between;
825 align-items: center;
826 flex-wrap: wrap;
827 gap: var(--space-2);
828 margin-bottom: var(--space-3);
829 }
830 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
831 .commits-toolbar-link {
832 display: inline-flex;
833 align-items: center;
834 gap: 6px;
835 padding: 6px 12px;
836 font-size: 12.5px;
837 font-weight: 500;
838 color: var(--text-muted);
839 background: rgba(255,255,255,0.025);
840 border: 1px solid var(--border);
841 border-radius: 8px;
842 text-decoration: none;
843 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
844 }
845 .commits-toolbar-link:hover {
846 border-color: var(--border-strong);
847 color: var(--text-strong);
848 background: rgba(255,255,255,0.04);
849 text-decoration: none;
850 }
efb11c5Claude851 .commits-list-wrap {
852 background: var(--bg-elevated);
853 border: 1px solid var(--border);
398a10cClaude854 border-radius: 14px;
efb11c5Claude855 overflow: hidden;
398a10cClaude856 position: relative;
857 }
858 .commits-list-wrap::before {
859 content: '';
860 position: absolute;
861 top: 0; left: 0; right: 0;
862 height: 2px;
863 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
864 opacity: 0.55;
865 pointer-events: none;
866 }
867 .commits-day-head {
868 display: flex;
869 align-items: center;
870 gap: 10px;
871 padding: 10px 18px;
872 font-size: 11.5px;
873 font-family: var(--font-mono);
874 text-transform: uppercase;
875 letter-spacing: 0.08em;
876 color: var(--text-muted);
877 background: var(--bg-secondary);
878 border-bottom: 1px solid var(--border);
879 }
880 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
881 .commits-day-head-dot {
882 width: 6px; height: 6px;
883 border-radius: 9999px;
884 background: linear-gradient(135deg, #8c6dff, #36c5d6);
885 }
886 .commits-row {
887 display: flex;
888 align-items: flex-start;
889 gap: 14px;
890 padding: 14px 18px;
891 border-bottom: 1px solid var(--border-subtle);
892 transition: background 120ms ease;
893 }
894 .commits-row:last-child { border-bottom: none; }
895 .commits-row:hover { background: rgba(255,255,255,0.022); }
896 .commits-avatar {
897 width: 34px; height: 34px;
898 border-radius: 9999px;
899 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
900 color: #fff;
901 display: inline-flex;
902 align-items: center;
903 justify-content: center;
904 font-family: var(--font-display);
905 font-weight: 700;
906 font-size: 13.5px;
907 flex-shrink: 0;
908 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
909 }
910 .commits-row-body { flex: 1; min-width: 0; }
911 .commits-row-msg {
912 display: flex;
913 align-items: center;
914 gap: 8px;
915 flex-wrap: wrap;
916 }
917 .commits-row-msg a {
918 font-family: var(--font-display);
919 font-weight: 600;
920 font-size: 14px;
921 color: var(--text-strong);
922 text-decoration: none;
923 letter-spacing: -0.005em;
924 }
925 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
926 .commits-row-verified {
927 font-size: 9.5px;
928 padding: 1px 7px;
929 border-radius: 9999px;
930 background: rgba(52,211,153,0.16);
931 color: #6ee7b7;
932 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
933 text-transform: uppercase;
934 letter-spacing: 0.06em;
935 font-weight: 700;
936 }
937 .commits-row-meta {
938 margin-top: 4px;
939 display: flex;
940 align-items: center;
941 gap: 8px;
942 flex-wrap: wrap;
943 font-size: 12.5px;
944 color: var(--text-muted);
945 }
946 .commits-row-meta strong { color: var(--text); font-weight: 600; }
947 .commits-row-meta .sep { opacity: 0.4; }
948 .commits-row-time {
949 font-variant-numeric: tabular-nums;
950 }
951 .commits-row-side {
952 display: flex;
953 align-items: center;
954 gap: 8px;
955 flex-shrink: 0;
956 }
957 .commits-row-sha {
958 display: inline-flex;
959 align-items: center;
960 padding: 4px 10px;
961 border-radius: 9999px;
962 font-family: var(--font-mono);
963 font-size: 11.5px;
964 font-weight: 600;
965 color: var(--text-strong);
966 background: rgba(255,255,255,0.04);
967 border: 1px solid var(--border);
968 text-decoration: none;
969 letter-spacing: 0.04em;
970 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
971 }
972 .commits-row-sha:hover {
973 border-color: rgba(140,109,255,0.55);
974 color: var(--accent);
975 background: rgba(140,109,255,0.08);
976 text-decoration: none;
977 }
978 .commits-row-copy {
979 display: inline-flex;
980 align-items: center;
981 justify-content: center;
982 width: 28px; height: 28px;
983 padding: 0;
984 border-radius: 8px;
985 background: transparent;
986 border: 1px solid var(--border);
987 color: var(--text-muted);
988 cursor: pointer;
989 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude990 }
398a10cClaude991 .commits-row-copy:hover {
992 border-color: var(--border-strong);
993 color: var(--text);
994 background: rgba(255,255,255,0.04);
995 }
996 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude997 /* Push Watch link — eye icon next to the SHA chip */
998 .commits-row-watch {
999 display: inline-flex;
1000 align-items: center;
1001 justify-content: center;
1002 width: 28px;
1003 height: 28px;
1004 border-radius: 6px;
1005 border: 1px solid var(--border);
1006 color: var(--text-muted);
1007 background: transparent;
1008 cursor: pointer;
1009 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1010 text-decoration: none !important;
1011 }
1012 .commits-row-watch:hover {
1013 border-color: rgba(140,109,255,0.45);
1014 color: var(--accent);
1015 background: rgba(140,109,255,0.08);
1016 text-decoration: none !important;
1017 }
efb11c5Claude1018 .commits-empty {
398a10cClaude1019 position: relative;
1020 overflow: hidden;
1021 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1022 text-align: center;
398a10cClaude1023 background: var(--bg-elevated);
1024 border: 1px dashed var(--border-strong);
1025 border-radius: 16px;
1026 }
1027 .commits-empty-orb {
1028 position: absolute;
1029 inset: -40% 30% auto 30%;
1030 height: 280px;
1031 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
1032 filter: blur(70px);
1033 opacity: 0.7;
1034 pointer-events: none;
1035 z-index: 0;
1036 }
1037 .commits-empty-inner { position: relative; z-index: 1; }
1038 .commits-empty-icon {
1039 width: 56px; height: 56px;
1040 border-radius: 9999px;
1041 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
1042 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
1043 display: inline-flex;
1044 align-items: center;
1045 justify-content: center;
1046 color: #c4b5fd;
1047 margin: 0 auto 14px;
1048 }
1049 .commits-empty-title {
1050 font-family: var(--font-display);
1051 font-size: 18px;
1052 font-weight: 700;
1053 margin: 0 0 6px;
1054 color: var(--text-strong);
1055 }
1056 .commits-empty-sub {
1057 margin: 0 auto 0;
1058 font-size: 13.5px;
efb11c5Claude1059 color: var(--text-muted);
398a10cClaude1060 max-width: 420px;
1061 line-height: 1.5;
1062 }
1063
1064 /* ───────── branches list ───────── */
1065 .branches-list {
efb11c5Claude1066 background: var(--bg-elevated);
398a10cClaude1067 border: 1px solid var(--border);
1068 border-radius: 14px;
1069 overflow: hidden;
1070 position: relative;
1071 }
1072 .branches-list::before {
1073 content: '';
1074 position: absolute;
1075 top: 0; left: 0; right: 0;
1076 height: 2px;
1077 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1078 opacity: 0.55;
1079 pointer-events: none;
1080 }
1081 .branches-row {
1082 display: flex;
1083 align-items: center;
1084 gap: var(--space-3);
1085 padding: 14px 18px;
1086 border-bottom: 1px solid var(--border-subtle);
1087 transition: background 120ms ease;
1088 flex-wrap: wrap;
1089 }
1090 .branches-row:last-child { border-bottom: none; }
1091 .branches-row:hover { background: rgba(255,255,255,0.022); }
1092 .branches-row-icon {
1093 width: 32px; height: 32px;
1094 border-radius: 8px;
1095 background: rgba(140,109,255,0.10);
1096 color: #c4b5fd;
1097 display: inline-flex;
1098 align-items: center;
1099 justify-content: center;
1100 flex-shrink: 0;
1101 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1102 }
1103 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1104 .branches-row-name {
1105 display: inline-flex;
1106 align-items: center;
1107 gap: 8px;
1108 flex-wrap: wrap;
1109 }
1110 .branches-row-name a {
1111 font-family: var(--font-mono);
1112 font-size: 13.5px;
1113 color: var(--text-strong);
1114 font-weight: 600;
1115 text-decoration: none;
1116 }
1117 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1118 .branches-row-default {
1119 font-size: 10px;
1120 padding: 2px 8px;
1121 border-radius: 9999px;
1122 background: rgba(140,109,255,0.14);
1123 color: #c4b5fd;
1124 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1125 text-transform: uppercase;
1126 letter-spacing: 0.08em;
1127 font-weight: 700;
1128 font-family: var(--font-mono);
1129 }
1130 .branches-row-meta {
1131 display: flex;
1132 align-items: center;
1133 gap: 8px;
1134 flex-wrap: wrap;
1135 font-size: 12.5px;
1136 color: var(--text-muted);
1137 font-variant-numeric: tabular-nums;
1138 }
1139 .branches-row-meta .sep { opacity: 0.4; }
1140 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1141 .branches-row-side {
1142 display: flex;
1143 align-items: center;
1144 gap: 10px;
1145 flex-shrink: 0;
1146 flex-wrap: wrap;
1147 }
1148 .branches-row-divergence {
1149 display: inline-flex;
1150 align-items: center;
1151 gap: 8px;
1152 font-family: var(--font-mono);
1153 font-size: 11.5px;
1154 color: var(--text-muted);
1155 padding: 4px 10px;
1156 border-radius: 9999px;
1157 background: rgba(255,255,255,0.035);
1158 border: 1px solid var(--border);
1159 font-variant-numeric: tabular-nums;
1160 }
1161 .branches-row-divergence .ahead { color: #6ee7b7; }
1162 .branches-row-divergence .behind { color: #fca5a5; }
1163 .branches-row-actions {
1164 display: flex;
1165 align-items: center;
1166 gap: 6px;
1167 }
1168 .branches-btn {
1169 display: inline-flex;
1170 align-items: center;
1171 gap: 5px;
1172 padding: 6px 12px;
1173 border-radius: 9999px;
1174 font-size: 12px;
1175 font-weight: 600;
1176 text-decoration: none;
1177 border: 1px solid var(--border);
1178 background: transparent;
1179 color: var(--text-muted);
1180 cursor: pointer;
1181 font: inherit;
1182 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1183 }
1184 .branches-btn:hover {
1185 border-color: var(--border-strong);
1186 color: var(--text);
1187 background: rgba(255,255,255,0.04);
1188 text-decoration: none;
1189 }
1190 .branches-btn-danger {
1191 color: #fca5a5;
1192 border-color: rgba(248,113,113,0.30);
1193 }
1194 .branches-btn-danger:hover {
1195 border-style: dashed;
1196 border-color: rgba(248,113,113,0.65);
1197 background: rgba(248,113,113,0.06);
1198 color: #fecaca;
1199 }
1200
1201 /* ───────── tags list ───────── */
1202 .tags-list {
1203 background: var(--bg-elevated);
1204 border: 1px solid var(--border);
1205 border-radius: 14px;
1206 overflow: hidden;
1207 position: relative;
1208 }
1209 .tags-list::before {
1210 content: '';
1211 position: absolute;
1212 top: 0; left: 0; right: 0;
1213 height: 2px;
1214 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1215 opacity: 0.55;
1216 pointer-events: none;
1217 }
1218 .tags-row {
1219 display: flex;
1220 align-items: center;
1221 gap: var(--space-3);
1222 padding: 14px 18px;
1223 border-bottom: 1px solid var(--border-subtle);
1224 transition: background 120ms ease;
1225 flex-wrap: wrap;
1226 }
1227 .tags-row:last-child { border-bottom: none; }
1228 .tags-row:hover { background: rgba(255,255,255,0.022); }
1229 .tags-row-icon {
1230 width: 32px; height: 32px;
1231 border-radius: 8px;
1232 background: rgba(54,197,214,0.10);
1233 color: #67e8f9;
1234 display: inline-flex;
1235 align-items: center;
1236 justify-content: center;
1237 flex-shrink: 0;
1238 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.22);
1239 }
1240 .tags-row-main { flex: 1; min-width: 240px; }
1241 .tags-row-name {
1242 display: inline-flex;
1243 align-items: center;
1244 gap: 8px;
1245 flex-wrap: wrap;
1246 }
1247 .tags-row-version {
1248 display: inline-flex;
1249 align-items: center;
1250 padding: 3px 11px;
1251 border-radius: 9999px;
1252 font-family: var(--font-mono);
1253 font-size: 13px;
1254 font-weight: 700;
1255 color: #67e8f9;
1256 background: rgba(54,197,214,0.12);
1257 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.30);
1258 letter-spacing: 0.01em;
1259 }
1260 .tags-row-meta {
1261 margin-top: 4px;
1262 display: flex;
1263 align-items: center;
1264 gap: 8px;
1265 flex-wrap: wrap;
1266 font-size: 12.5px;
1267 color: var(--text-muted);
1268 font-variant-numeric: tabular-nums;
1269 }
1270 .tags-row-meta .sep { opacity: 0.4; }
1271 .tags-row-sha {
1272 font-family: var(--font-mono);
1273 font-size: 11.5px;
1274 color: var(--text-strong);
1275 padding: 2px 8px;
1276 border-radius: 6px;
1277 background: rgba(255,255,255,0.04);
1278 border: 1px solid var(--border);
1279 text-decoration: none;
1280 letter-spacing: 0.04em;
1281 }
1282 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1283 .tags-row-side {
1284 display: flex;
1285 align-items: center;
1286 gap: 6px;
1287 flex-shrink: 0;
1288 flex-wrap: wrap;
1289 }
1290 .tags-row-link {
1291 display: inline-flex;
1292 align-items: center;
1293 gap: 5px;
1294 padding: 6px 12px;
1295 border-radius: 9999px;
1296 font-size: 12px;
1297 font-weight: 600;
1298 text-decoration: none;
1299 color: var(--text-muted);
1300 background: rgba(255,255,255,0.025);
1301 border: 1px solid var(--border);
1302 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1303 }
1304 .tags-row-link:hover {
1305 border-color: rgba(140,109,255,0.45);
1306 color: var(--text-strong);
1307 background: rgba(140,109,255,0.06);
1308 text-decoration: none;
efb11c5Claude1309 }
1310
1311 /* ───────── commit detail ───────── */
1312 .commit-detail-card {
1313 position: relative;
1314 margin-bottom: var(--space-5);
1315 padding: var(--space-5) var(--space-5);
1316 background: var(--bg-elevated);
1317 border: 1px solid var(--border);
1318 border-radius: 14px;
1319 overflow: hidden;
1320 }
1321 .commit-detail-eyebrow {
1322 display: flex;
1323 align-items: center;
1324 gap: var(--space-2);
1325 font-size: 12px;
1326 font-family: var(--font-mono);
1327 text-transform: uppercase;
1328 letter-spacing: 0.1em;
1329 color: var(--text-muted);
1330 margin-bottom: var(--space-2);
1331 }
1332 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1333 .commit-detail-sha-pill {
1334 font-family: var(--font-mono);
1335 font-size: 11.5px;
1336 color: var(--text-strong);
1337 background: var(--bg-secondary);
1338 border: 1px solid var(--border);
1339 border-radius: 999px;
1340 padding: 2px 10px;
1341 letter-spacing: 0.04em;
1342 }
1343 .commit-detail-verify {
1344 font-size: 10px;
1345 padding: 2px 8px;
1346 border-radius: 999px;
1347 text-transform: uppercase;
1348 letter-spacing: 0.06em;
1349 font-weight: 700;
1350 color: #fff;
1351 }
1352 .commit-detail-verify-ok {
1353 background: linear-gradient(135deg, #2ea043, #34d399);
1354 }
1355 .commit-detail-verify-warn {
1356 background: linear-gradient(135deg, #d29922, #f59e0b);
1357 }
1358 .commit-detail-title {
1359 font-family: var(--font-display);
1360 font-weight: 700;
1361 letter-spacing: -0.018em;
1362 font-size: clamp(20px, 2.5vw, 26px);
1363 line-height: 1.25;
1364 margin: 0 0 var(--space-2);
1365 color: var(--text-strong);
1366 }
1367 .commit-detail-body {
1368 white-space: pre-wrap;
1369 color: var(--text-muted);
1370 font-size: 14px;
1371 line-height: 1.55;
1372 margin: 0 0 var(--space-3);
1373 font-family: var(--font-mono);
1374 background: var(--bg);
1375 border: 1px solid var(--border);
1376 border-radius: 8px;
1377 padding: var(--space-3) var(--space-4);
1378 max-height: 280px;
1379 overflow: auto;
1380 }
1381 .commit-detail-meta {
1382 display: flex;
1383 flex-wrap: wrap;
1384 gap: var(--space-3);
1385 font-size: 13px;
1386 color: var(--text-muted);
1387 margin-bottom: var(--space-3);
1388 }
1389 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1390 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1391 .commit-detail-sha-link {
1392 font-family: var(--font-mono);
1393 font-size: 12.5px;
1394 color: var(--accent);
1395 background: rgba(140,109,255,0.08);
1396 border-radius: 6px;
1397 padding: 1px 6px;
1398 margin-left: 2px;
1399 }
1400 .commit-detail-sha-link:hover { background: rgba(140,109,255,0.16); text-decoration: none; }
1401 .commit-detail-stats {
1402 display: flex;
1403 flex-wrap: wrap;
1404 align-items: center;
1405 gap: var(--space-3);
1406 padding-top: var(--space-3);
1407 border-top: 1px solid var(--border);
1408 font-size: 13px;
1409 color: var(--text-muted);
1410 }
1411 .commit-detail-stat {
1412 display: inline-flex;
1413 align-items: center;
1414 gap: 4px;
1415 font-variant-numeric: tabular-nums;
1416 }
1417 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1418 .commit-detail-stat-add strong { color: #34d399; }
1419 .commit-detail-stat-del strong { color: #f87171; }
1420 .commit-detail-stat-mark {
1421 font-family: var(--font-mono);
1422 font-weight: 700;
1423 font-size: 14px;
1424 }
1425 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1426 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1427 .commit-detail-sha-full {
1428 margin-left: auto;
1429 font-family: var(--font-mono);
1430 font-size: 11.5px;
1431 color: var(--text-faint);
1432 letter-spacing: 0.02em;
1433 overflow: hidden;
1434 text-overflow: ellipsis;
1435 white-space: nowrap;
1436 max-width: 100%;
1437 }
1438 .commit-detail-checks {
1439 margin-top: var(--space-3);
1440 padding-top: var(--space-3);
1441 border-top: 1px solid var(--border);
1442 }
1443 .commit-detail-checks-head {
1444 display: flex;
1445 align-items: center;
1446 gap: var(--space-2);
1447 font-size: 13px;
1448 color: var(--text-muted);
1449 margin-bottom: 8px;
1450 }
1451 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1452 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1453 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1454 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1455 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1456 .commit-detail-check {
1457 font-size: 11px;
1458 padding: 2px 8px;
1459 border-radius: 999px;
1460 color: #fff;
1461 font-weight: 500;
1462 }
398a10cClaude1463 .commit-detail-check a { color: inherit; text-decoration: none; }
1464 .commit-detail-check-success { background: #2ea043; }
1465 .commit-detail-check-pending { background: #d29922; }
1466 .commit-detail-check-failure { background: #da3633; }
1467
1468 /* ───────── blame ───────── */
1469 .blame-head { margin-bottom: var(--space-5); }
1470 .blame-eyebrow {
1471 display: inline-flex;
1472 align-items: center;
1473 gap: 8px;
1474 text-transform: uppercase;
1475 font-family: var(--font-mono);
1476 font-size: 11px;
1477 letter-spacing: 0.16em;
1478 color: var(--text-muted);
1479 font-weight: 600;
1480 margin-bottom: 10px;
1481 }
1482 .blame-eyebrow-dot {
1483 width: 8px; height: 8px;
1484 border-radius: 9999px;
1485 background: linear-gradient(135deg, #8c6dff, #36c5d6);
1486 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1487 }
1488 .blame-title {
1489 font-family: var(--font-display);
1490 font-size: clamp(22px, 3vw, 30px);
1491 font-weight: 800;
1492 letter-spacing: -0.025em;
1493 line-height: 1.15;
1494 margin: 0 0 6px;
1495 color: var(--text-strong);
1496 }
1497 .blame-title code {
1498 font-family: var(--font-mono);
1499 font-size: 0.78em;
1500 color: var(--text-strong);
1501 font-weight: 700;
1502 }
1503 .blame-sub {
1504 margin: 0;
1505 font-size: 14px;
1506 color: var(--text-muted);
1507 line-height: 1.5;
1508 max-width: 700px;
1509 }
efb11c5Claude1510 .blame-toolbar {
398a10cClaude1511 display: flex;
1512 justify-content: space-between;
1513 align-items: center;
1514 gap: var(--space-3);
efb11c5Claude1515 margin-bottom: var(--space-3);
398a10cClaude1516 flex-wrap: wrap;
efb11c5Claude1517 font-size: 13px;
1518 }
398a10cClaude1519 .blame-toolbar-actions { display: flex; gap: 6px; }
1520 .blame-card {
1521 background: var(--bg-elevated);
1522 border: 1px solid var(--border);
1523 border-radius: 14px;
1524 overflow: hidden;
1525 position: relative;
1526 }
1527 .blame-card::before {
1528 content: '';
1529 position: absolute;
1530 top: 0; left: 0; right: 0;
1531 height: 2px;
1532 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1533 opacity: 0.55;
1534 pointer-events: none;
1535 }
efb11c5Claude1536 .blame-header {
1537 display: flex;
1538 align-items: center;
1539 justify-content: space-between;
1540 gap: var(--space-3);
1541 padding: 10px 14px;
1542 background: var(--bg-secondary);
1543 border-bottom: 1px solid var(--border);
1544 }
1545 .blame-header-meta {
1546 display: flex;
1547 align-items: center;
1548 gap: var(--space-2);
1549 min-width: 0;
1550 flex-wrap: wrap;
1551 }
1552 .blame-header-icon { color: var(--accent); font-size: 14px; }
1553 .blame-header-name {
1554 font-family: var(--font-mono);
1555 font-size: 13px;
1556 color: var(--text-strong);
1557 font-weight: 600;
1558 }
1559 .blame-header-tag {
1560 font-size: 10.5px;
1561 text-transform: uppercase;
1562 letter-spacing: 0.08em;
1563 font-family: var(--font-mono);
1564 background: rgba(140,109,255,0.12);
1565 color: var(--accent);
1566 border-radius: 999px;
1567 padding: 2px 8px;
1568 font-weight: 600;
1569 }
1570 .blame-header-stats {
1571 font-family: var(--font-mono);
1572 font-size: 11.5px;
1573 color: var(--text-muted);
1574 font-variant-numeric: tabular-nums;
1575 border-left: 1px solid var(--border);
1576 padding-left: var(--space-2);
1577 }
1578 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1579 .blame-table {
1580 width: 100%;
1581 border-collapse: collapse;
1582 font-family: var(--font-mono);
1583 font-size: 12.5px;
1584 line-height: 1.6;
1585 }
1586 .blame-table tr { border-bottom: 1px solid transparent; }
1587 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1588 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1589 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1590 .blame-gutter {
1591 width: 220px;
1592 min-width: 220px;
1593 padding: 0 12px;
1594 vertical-align: top;
1595 background: rgba(255,255,255,0.012);
1596 border-right: 1px solid var(--border-subtle);
1597 font-variant-numeric: tabular-nums;
1598 color: var(--text-muted);
1599 font-size: 11px;
1600 white-space: nowrap;
1601 overflow: hidden;
1602 text-overflow: ellipsis;
1603 padding-top: 2px;
1604 padding-bottom: 2px;
1605 }
1606 .blame-gutter-inner {
1607 display: inline-flex;
1608 align-items: center;
1609 gap: 7px;
1610 max-width: 100%;
1611 overflow: hidden;
1612 }
1613 .blame-gutter-sha {
1614 font-family: var(--font-mono);
1615 font-size: 10.5px;
1616 font-weight: 600;
1617 color: #c4b5fd;
1618 background: rgba(140,109,255,0.10);
1619 padding: 1px 7px;
1620 border-radius: 9999px;
1621 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.22);
1622 text-decoration: none;
1623 letter-spacing: 0.04em;
1624 flex-shrink: 0;
1625 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1626 }
1627 .blame-gutter-sha:hover {
1628 background: rgba(140,109,255,0.22);
1629 color: #ddd6fe;
1630 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.50);
1631 text-decoration: none;
1632 }
1633 .blame-gutter-author {
1634 color: var(--text);
1635 overflow: hidden;
1636 text-overflow: ellipsis;
1637 white-space: nowrap;
1638 font-size: 11px;
1639 font-family: var(--font-sans, inherit);
1640 }
1641 .blame-line-num {
1642 width: 1%;
1643 min-width: 50px;
1644 padding: 0 12px;
1645 text-align: right;
1646 color: var(--text-faint);
1647 user-select: none;
1648 border-right: 1px solid var(--border-subtle);
1649 font-variant-numeric: tabular-nums;
1650 }
1651 .blame-line-content {
1652 padding: 0 14px;
1653 white-space: pre;
1654 color: var(--text);
1655 transition: background 120ms ease;
1656 }
efb11c5Claude1657
1658 /* ───────── search ───────── */
1659 .search-hero {
1660 position: relative;
1661 margin-bottom: var(--space-4);
1662 padding: var(--space-5) var(--space-6);
1663 background: var(--bg-elevated);
1664 border: 1px solid var(--border);
1665 border-radius: 16px;
1666 overflow: hidden;
1667 }
1668 .search-eyebrow {
1669 font-size: 12px;
1670 font-family: var(--font-mono);
1671 color: var(--text-muted);
1672 letter-spacing: 0.1em;
1673 text-transform: uppercase;
1674 margin-bottom: var(--space-2);
1675 }
1676 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1677 .search-title {
1678 font-family: var(--font-display);
1679 font-weight: 800;
1680 letter-spacing: -0.025em;
1681 font-size: clamp(22px, 3vw, 30px);
1682 line-height: 1.15;
1683 margin: 0 0 var(--space-3);
1684 color: var(--text-strong);
1685 }
1686 .search-form {
1687 display: flex;
1688 gap: var(--space-2);
1689 align-items: stretch;
1690 }
1691 .search-input-wrap {
1692 position: relative;
1693 flex: 1;
1694 display: flex;
1695 align-items: center;
1696 }
1697 .search-input-icon {
1698 position: absolute;
1699 left: 12px;
1700 color: var(--text-muted);
1701 font-size: 15px;
1702 pointer-events: none;
1703 }
1704 .search-input {
1705 appearance: none;
1706 width: 100%;
1707 padding: 10px 12px 10px 34px;
1708 background: var(--bg);
1709 border: 1px solid var(--border);
1710 border-radius: 10px;
1711 color: var(--text-strong);
1712 font-size: 14px;
1713 font-family: inherit;
1714 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1715 }
1716 .search-input:focus {
1717 outline: none;
1718 border-color: var(--accent);
1719 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1720 }
1721 .search-submit { min-width: 96px; }
1722 .search-results-head {
1723 display: flex;
1724 align-items: baseline;
1725 gap: var(--space-2);
1726 margin-bottom: var(--space-3);
1727 font-size: 13px;
1728 color: var(--text-muted);
1729 }
1730 .search-results-count strong {
1731 color: var(--text-strong);
1732 font-weight: 600;
1733 font-variant-numeric: tabular-nums;
1734 }
1735 .search-results-q { color: var(--text-strong); font-weight: 600; }
1736 .search-results-head code {
1737 font-family: var(--font-mono);
1738 font-size: 12px;
1739 background: var(--bg-secondary);
1740 border: 1px solid var(--border);
1741 border-radius: 5px;
1742 padding: 1px 6px;
1743 color: var(--text);
1744 }
1745 .search-empty {
1746 padding: var(--space-5) var(--space-6);
1747 background: var(--bg-elevated);
1748 border: 1px dashed var(--border);
1749 border-radius: 12px;
1750 color: var(--text-muted);
1751 font-size: 14px;
1752 }
1753 .search-empty strong { color: var(--text-strong); }
1754 .search-results {
1755 display: flex;
1756 flex-direction: column;
1757 gap: var(--space-3);
1758 }
1759 .search-file-head {
1760 display: flex;
1761 align-items: center;
1762 justify-content: space-between;
1763 gap: var(--space-2);
1764 }
1765 .search-file-link {
1766 font-family: var(--font-mono);
1767 font-size: 13px;
1768 color: var(--text-strong);
1769 font-weight: 600;
1770 }
1771 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1772 .search-file-count {
1773 font-family: var(--font-mono);
1774 font-size: 11.5px;
1775 color: var(--text-muted);
1776 font-variant-numeric: tabular-nums;
1777 }
1778`;
1779
fc1817aClaude1780// Home page
06d5ffeClaude1781web.get("/", async (c) => {
1782 const user = c.get("user");
1783
1784 if (user) {
0316dbbClaude1785 return c.redirect("/dashboard");
06d5ffeClaude1786 }
1787
8e9f1d9Claude1788 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1789 let publicStats: PublicStats | null = null;
8e9f1d9Claude1790 try {
1791 const [repoRow] = await db
1792 .select({ n: sql<number>`count(*)::int` })
1793 .from(repositories)
1794 .where(eq(repositories.isPrivate, false));
1795 const [userRow] = await db
1796 .select({ n: sql<number>`count(*)::int` })
1797 .from(users);
1798 stats = {
1799 publicRepos: Number(repoRow?.n ?? 0),
1800 users: Number(userRow?.n ?? 0),
1801 };
1802 } catch {
1803 stats = undefined;
1804 }
1805
52ad8b1Claude1806 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1807 try {
1808 publicStats = await computePublicStats();
1809 } catch {
1810 publicStats = null;
1811 }
1812
534f04aClaude1813 // Block M1 — initial SSR snapshot for the live-now demo feed.
1814 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1815 // in try/catch so a freak module-level explosion can't take down /.
1816 let liveFeed: LandingLiveFeed | null = null;
1817 try {
1818 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1819 listQueuedAiBuildIssues(3),
1820 listRecentAutoMerges(3, 24),
1821 listRecentAiReviews(3, 24),
1822 countAiReviewsSince(24),
1823 listDemoActivityFeed(10),
1824 ]);
1825 liveFeed = {
1826 queued: queued.map((i) => ({
1827 repo: i.repo,
1828 number: i.number,
1829 title: i.title,
1830 createdAt: i.createdAt,
1831 })),
1832 merges: merges.map((m) => ({
1833 repo: m.repo,
1834 number: m.number,
1835 title: m.title,
1836 mergedAt: m.mergedAt,
1837 })),
1838 reviews: reviewList.map((r) => ({
1839 repo: r.repo,
1840 prNumber: r.prNumber,
1841 commentSnippet: r.commentSnippet,
1842 createdAt: r.createdAt,
1843 })),
1844 reviewCount,
1845 feed: feed.map((e) => ({
1846 kind: e.kind,
1847 repo: e.repo,
1848 ref: e.ref,
1849 at: e.at,
1850 })),
1851 };
1852 } catch {
1853 liveFeed = null;
1854 }
1855
29924bcClaude1856 // 2030 reboot — the public landing is a self-contained light marketing
1857 // document (its own shell + design system), rendered directly so it never
1858 // inherits the dark app Layout. `publicStats` / `liveFeed` remain computed
1859 // above for the legacy LandingPage and other surfaces; the new page uses the
1860 // headline counters from `stats`.
1861 void publicStats;
1862 void liveFeed;
1863 void LandingPage;
1864 return c.html("<!DOCTYPE html>" + String(<Landing2030Page stats={stats} />));
fc1817aClaude1865});
1866
06d5ffeClaude1867// New repository form
1868web.get("/new", requireAuth, (c) => {
1869 const user = c.get("user")!;
1870 const error = c.req.query("error");
1871
1872 return c.html(
1873 <Layout title="New repository" user={user}>
efb11c5Claude1874 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1875 <div class="new-repo-hero">
1876 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1877 <div class="new-repo-hero-orb" />
1878 </div>
1879 <div class="new-repo-hero-inner">
1880 <div class="new-repo-eyebrow">
1881 <strong>Create</strong> · {user.username}
1882 </div>
1883 <h1 class="new-repo-title">
1884 Spin up a <span class="gradient-text">repository</span>.
1885 </h1>
1886 <p class="new-repo-sub">
1887 Push your first commit, and Gluecron wires up gate checks, AI review,
1888 and auto-merge from the moment your branch lands.
1889 </p>
1890 </div>
1891 </div>
06d5ffeClaude1892 <div class="new-repo-form">
efb11c5Claude1893 {error && (
1894 <div class="new-repo-error" role="alert">
1895 {decodeURIComponent(error)}
1896 </div>
1897 )}
1898 <form method="post" action="/new" class="new-repo-form-grid">
1899 <div class="new-repo-row">
1900 <label class="new-repo-label">Owner</label>
1901 <input
1902 type="text"
1903 value={user.username}
1904 disabled
1905 aria-label="Owner"
1906 class="new-repo-input new-repo-input-disabled"
1907 />
06d5ffeClaude1908 </div>
efb11c5Claude1909 <div class="new-repo-row">
1910 <label class="new-repo-label" for="name">
1911 Repository name
1912 </label>
06d5ffeClaude1913 <input
1914 type="text"
1915 id="name"
1916 name="name"
1917 required
1918 pattern="^[a-zA-Z0-9._-]+$"
1919 placeholder="my-project"
1920 autocomplete="off"
efb11c5Claude1921 class="new-repo-input"
06d5ffeClaude1922 />
efb11c5Claude1923 <p class="new-repo-hint">
1924 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
1925 <code>{user.username}/&lt;name&gt;</code>.
1926 </p>
06d5ffeClaude1927 </div>
efb11c5Claude1928 <div class="new-repo-row">
1929 <label class="new-repo-label" for="description">
1930 Description{" "}
1931 <span class="new-repo-label-optional">(optional)</span>
1932 </label>
06d5ffeClaude1933 <input
1934 type="text"
1935 id="description"
1936 name="description"
1937 placeholder="A short description of your repository"
efb11c5Claude1938 class="new-repo-input"
06d5ffeClaude1939 />
1940 </div>
efb11c5Claude1941 <div class="new-repo-row">
1942 <span class="new-repo-label">Visibility</span>
1943 <div class="new-repo-visibility">
1944 <label class="new-repo-vis-card">
1945 <input
1946 type="radio"
1947 name="visibility"
1948 value="public"
1949 checked
1950 class="new-repo-vis-radio"
1951 />
1952 <span class="new-repo-vis-body">
1953 <span class="new-repo-vis-label">Public</span>
1954 <span class="new-repo-vis-desc">
1955 Anyone can see this repository. You choose who can commit.
1956 </span>
1957 </span>
1958 </label>
1959 <label class="new-repo-vis-card">
1960 <input
1961 type="radio"
1962 name="visibility"
1963 value="private"
1964 class="new-repo-vis-radio"
1965 />
1966 <span class="new-repo-vis-body">
1967 <span class="new-repo-vis-label">Private</span>
1968 <span class="new-repo-vis-desc">
1969 Only you (and collaborators you invite) can see this
1970 repository.
1971 </span>
1972 </span>
1973 </label>
1974 </div>
1975 </div>
398a10cClaude1976 <div class="new-repo-row">
1977 <span class="new-repo-label">
1978 Starter content{" "}
1979 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
1980 </span>
1981 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
1982 <label class="new-repo-template-chip">
1983 <input type="radio" name="starter" value="empty" checked />
1984 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1985 Empty
1986 </label>
1987 <label class="new-repo-template-chip">
1988 <input type="radio" name="starter" value="readme" />
1989 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1990 README
1991 </label>
1992 <label class="new-repo-template-chip">
1993 <input type="radio" name="starter" value="readme-mit" />
1994 <span class="new-repo-template-chip-dot" aria-hidden="true" />
1995 README + MIT
1996 </label>
1997 <label class="new-repo-template-chip">
1998 <input type="radio" name="starter" value="node" />
1999 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2000 Node + .gitignore
2001 </label>
2002 </div>
2003 <p class="new-repo-hint">
2004 Just a UI hint — push your own commits to fill the repo.
2005 </p>
2006 </div>
44f1a02Claude2007 <div class="new-repo-row">
2008 <label class="new-repo-label" for="data_region">
2009 Data region
2010 </label>
2011 <select
2012 id="data_region"
2013 name="data_region"
2014 class="new-repo-input"
2015 style="cursor: pointer;"
2016 >
2017 <option value="us" selected>US (default)</option>
2018 <option value="eu">EU (Frankfurt)</option>
2019 </select>
2020 <p class="new-repo-hint">
2021 EU data residency requires a{" "}
2022 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2023 Pro plan or higher
2024 </a>
2025 . Repositories cannot be moved between regions after creation.
2026 </p>
2027 </div>
efb11c5Claude2028 <div class="new-repo-callout">
2029 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2030 <p class="new-repo-callout-body">
2031 Every push is gate-checked and reviewed by Claude automatically.
2032 Label an issue <code>ai-build</code> and Gluecron will open the PR
2033 for you.
2034 </p>
2035 </div>
2036 <div class="new-repo-actions">
398a10cClaude2037 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2038 Create repository
2039 </button>
2040 <a href="/dashboard" class="btn new-repo-cancel">
2041 Cancel
2042 </a>
06d5ffeClaude2043 </div>
2044 </form>
2045 </div>
2046 </Layout>
2047 );
2048});
2049
2050web.post("/new", requireAuth, async (c) => {
2051 const user = c.get("user")!;
2052 const body = await c.req.parseBody();
2053 const name = String(body.name || "").trim();
2054 const description = String(body.description || "").trim();
2055 const isPrivate = body.visibility === "private";
44f1a02Claude2056 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2057
2058 if (!name) {
2059 return c.redirect("/new?error=Repository+name+is+required");
2060 }
2061
c63b860Claude2062 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2063 // outage never blocks repo creation.
2064 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2065 const gate = await checkRepoCreateAllowed(user.id);
2066 if (!gate.ok) {
2067 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2068 }
2069
06d5ffeClaude2070 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2071 return c.redirect("/new?error=Invalid+repository+name");
2072 }
2073
2074 if (await repoExists(user.username, name)) {
2075 return c.redirect("/new?error=Repository+already+exists");
2076 }
2077
2078 const diskPath = await initBareRepo(user.username, name);
2079
3ef4c9dClaude2080 const [newRepo] = await db
2081 .insert(repositories)
2082 .values({
2083 name,
2084 ownerId: user.id,
2085 description: description || null,
2086 isPrivate,
2087 diskPath,
44f1a02Claude2088 dataRegion,
3ef4c9dClaude2089 })
2090 .returning();
2091
2092 if (newRepo) {
2093 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2094 await bootstrapRepository({
2095 repositoryId: newRepo.id,
2096 ownerUserId: user.id,
2097 defaultBranch: "main",
2098 });
2099 }
06d5ffeClaude2100
2101 return c.redirect(`/${user.username}/${name}`);
2102});
2103
2104// User profile
fc1817aClaude2105web.get("/:owner", async (c) => {
06d5ffeClaude2106 const { owner: ownerName } = c.req.param();
2107 const user = c.get("user");
2108
2109 // Avoid clashing with fixed routes
2110 if (
2111 ["login", "register", "logout", "new", "settings", "api"].includes(
2112 ownerName
2113 )
2114 ) {
2115 return c.notFound();
2116 }
2117
2118 let ownerUser;
2119 try {
2120 const [found] = await db
2121 .select()
2122 .from(users)
2123 .where(eq(users.username, ownerName))
2124 .limit(1);
2125 ownerUser = found;
2126 } catch {
2127 // DB not available — check if repos exist on disk
2128 ownerUser = null;
2129 }
2130
2131 // Even without DB, show repos if they exist on disk
2132 let repos: any[] = [];
2133 if (ownerUser) {
2134 const allRepos = await db
2135 .select()
2136 .from(repositories)
2137 .where(eq(repositories.ownerId, ownerUser.id))
2138 .orderBy(desc(repositories.updatedAt));
2139
2140 // Show public repos to everyone, private only to owner
2141 repos =
2142 user?.id === ownerUser.id
2143 ? allRepos
2144 : allRepos.filter((r) => !r.isPrivate);
2145 }
2146
7aa8b99Claude2147 // Block J4 — follow counts + viewer's follow state
2148 let followState = {
2149 followers: 0,
2150 following: 0,
2151 viewerFollows: false,
2152 };
2153 if (ownerUser) {
2154 try {
2155 const { followCounts, isFollowing } = await import("../lib/follows");
2156 const counts = await followCounts(ownerUser.id);
2157 followState.followers = counts.followers;
2158 followState.following = counts.following;
2159 if (user && user.id !== ownerUser.id) {
2160 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2161 }
2162 } catch {
2163 // DB hiccup — fall back to zeros.
2164 }
2165 }
2166 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2167
d412586Claude2168 // Block J5 — profile README. Render owner/owner repo's README on the
2169 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2170 // back to "<user>/.github" for org-style profile repos.
2171 let profileReadmeHtml: string | null = null;
2172 try {
2173 const candidates = [ownerName, ".github"];
2174 for (const rname of candidates) {
2175 if (await repoExists(ownerName, rname)) {
2176 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2177 const md = await getReadme(ownerName, rname, ref);
2178 if (md) {
2179 profileReadmeHtml = renderMarkdown(md);
2180 break;
2181 }
2182 }
2183 }
2184 } catch {
2185 profileReadmeHtml = null;
2186 }
2187
efb11c5Claude2188 const displayName = ownerUser?.displayName || ownerName;
2189 const memberSince = ownerUser?.createdAt
2190 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2191 : null;
2192
fc1817aClaude2193 return c.html(
06d5ffeClaude2194 <Layout title={ownerName} user={user}>
efb11c5Claude2195 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2196 <div class="profile-hero">
2197 <div class="profile-hero-orb-wrap" aria-hidden="true">
2198 <div class="profile-hero-orb" />
06d5ffeClaude2199 </div>
efb11c5Claude2200 <div class="profile-hero-inner">
2201 <div class="profile-hero-avatar" aria-hidden="true">
2202 {displayName[0].toUpperCase()}
2203 </div>
2204 <div class="profile-hero-text">
2205 <div class="profile-eyebrow">
2206 <strong>Developer</strong>
2207 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2208 <>
2209 {" "}· Joined{" "}
2210 {memberSince.toLocaleDateString("en-US", {
2211 month: "short",
2212 year: "numeric",
2213 })}
2214 </>
2215 )}
2216 </div>
2217 <h1 class="profile-name">
2218 <span class="gradient-text">{displayName}</span>
2219 </h1>
2220 <div class="profile-handle">@{ownerName}</div>
2221 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2222 <div class="profile-meta">
2223 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2224 <strong>{followState.followers}</strong> follower
2225 {followState.followers === 1 ? "" : "s"}
2226 </a>
2227 <a href={`/${ownerName}/following`} class="profile-meta-link">
2228 <strong>{followState.following}</strong> following
2229 </a>
2230 <a href={`/${ownerName}`} class="profile-meta-link">
2231 <strong>{repos.length}</strong> repo
2232 {repos.length === 1 ? "" : "s"}
2233 </a>
2234 {canFollow && (
2235 <form
2236 method="post"
2237 action={`/${ownerName}/${
2238 followState.viewerFollows ? "unfollow" : "follow"
2239 }`}
2240 class="profile-follow-form"
7aa8b99Claude2241 >
efb11c5Claude2242 <button
2243 type="submit"
2244 class={`btn ${
2245 followState.viewerFollows ? "" : "btn-primary"
2246 } btn-sm`}
2247 >
2248 {followState.viewerFollows ? "Unfollow" : "Follow"}
2249 </button>
2250 </form>
2251 )}
2252 </div>
7aa8b99Claude2253 </div>
06d5ffeClaude2254 </div>
2255 </div>
d412586Claude2256 {profileReadmeHtml && (
efb11c5Claude2257 <div class="profile-readme">
2258 <div class="profile-readme-head">
2259 <span class="profile-readme-icon">{"☰"}</span>
2260 <span>{ownerName}/{ownerName} README.md</span>
2261 </div>
2262 <div
2263 class="markdown-body profile-readme-body"
2264 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2265 />
2266 </div>
d412586Claude2267 )}
efb11c5Claude2268 <div class="profile-section-head">
2269 <h2 class="profile-section-title">Repositories</h2>
2270 <span class="profile-section-count">{repos.length}</span>
2271 </div>
06d5ffeClaude2272 {repos.length === 0 ? (
efb11c5Claude2273 <div class="profile-empty">
2274 <p class="profile-empty-text">
2275 No repositories yet
2276 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2277 </p>
2278 {user?.id === ownerUser?.id && (
2279 <a href="/new" class="btn btn-primary btn-sm">
2280 + Create your first
2281 </a>
2282 )}
2283 </div>
06d5ffeClaude2284 ) : (
2285 <div class="card-grid">
2286 {repos.map((repo) => (
2287 <RepoCard repo={repo} ownerName={ownerName} />
2288 ))}
2289 </div>
2290 )}
fc1817aClaude2291 </Layout>
2292 );
2293});
2294
06d5ffeClaude2295// Star/unstar a repo
2296web.post("/:owner/:repo/star", requireAuth, async (c) => {
2297 const { owner: ownerName, repo: repoName } = c.req.param();
2298 const user = c.get("user")!;
2299
2300 try {
2301 const [ownerUser] = await db
2302 .select()
2303 .from(users)
2304 .where(eq(users.username, ownerName))
2305 .limit(1);
2306 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2307
2308 const [repo] = await db
2309 .select()
2310 .from(repositories)
2311 .where(
2312 and(
2313 eq(repositories.ownerId, ownerUser.id),
2314 eq(repositories.name, repoName)
2315 )
2316 )
2317 .limit(1);
2318 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2319
2320 // Toggle star
2321 const [existing] = await db
2322 .select()
2323 .from(stars)
2324 .where(
2325 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2326 )
2327 .limit(1);
2328
2329 if (existing) {
2330 await db.delete(stars).where(eq(stars.id, existing.id));
2331 await db
2332 .update(repositories)
2333 .set({ starCount: Math.max(0, repo.starCount - 1) })
2334 .where(eq(repositories.id, repo.id));
2335 } else {
2336 await db.insert(stars).values({
2337 userId: user.id,
2338 repositoryId: repo.id,
2339 });
2340 await db
2341 .update(repositories)
2342 .set({ starCount: repo.starCount + 1 })
2343 .where(eq(repositories.id, repo.id));
2344 }
2345 } catch {
2346 // DB error — ignore
2347 }
2348
2349 return c.redirect(`/${ownerName}/${repoName}`);
2350});
2351
fc1817aClaude2352// Repository overview — file tree at HEAD
2353web.get("/:owner/:repo", async (c) => {
2354 const { owner, repo } = c.req.param();
06d5ffeClaude2355 const user = c.get("user");
fc1817aClaude2356
f1dc7c7Claude2357 // ── Loading skeleton (flag-gated) ──
2358 // Renders an SSR'd shell with file-tree + README placeholders when
2359 // `?skeleton=1` is set. Lets the user see the page structure before
2360 // git ops finish. Behind a flag for now so we never flash before the
2361 // real content lands.
2362 if (c.req.query("skeleton") === "1") {
2363 return c.html(
2364 <Layout title={`${owner}/${repo}`} user={user}>
2365 <style
2366 dangerouslySetInnerHTML={{
2367 __html: `
2368 .repo-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: repoSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
2369 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2370 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2371 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2372 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2373 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2374 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2375 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2376 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2377 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2378 .repo-skel-readme { height: 320px; border-radius: 12px; }
2379 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2380 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2381 `,
2382 }}
2383 />
2384 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2385 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2386 <div class="repo-skel-grid" aria-hidden="true">
2387 <div>
2388 <div class="repo-skel repo-skel-branch" />
2389 <div class="repo-skel-tree">
2390 {Array.from({ length: 8 }).map(() => (
2391 <div class="repo-skel repo-skel-tree-row" />
2392 ))}
2393 </div>
2394 <div class="repo-skel repo-skel-readme" />
2395 </div>
2396 <aside class="repo-skel-side">
2397 <div class="repo-skel repo-skel-side-card" />
2398 <div class="repo-skel repo-skel-side-card" />
2399 </aside>
2400 </div>
2401 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
2402 Loading {owner}/{repo}…
2403 </span>
2404 </Layout>
2405 );
2406 }
2407
8f50ed0Claude2408 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2409 trackByName(owner, repo, "view", {
2410 userId: user?.id || null,
2411 path: `/${owner}/${repo}`,
2412 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2413 userAgent: c.req.header("user-agent") || null,
2414 referer: c.req.header("referer") || null,
a28cedeClaude2415 }).catch((err) => {
2416 console.warn(
2417 `[web] view tracking failed for ${owner}/${repo}:`,
2418 err instanceof Error ? err.message : err
2419 );
2420 });
8f50ed0Claude2421
fc1817aClaude2422 if (!(await repoExists(owner, repo))) {
2423 return c.html(
06d5ffeClaude2424 <Layout title="Not Found" user={user}>
fc1817aClaude2425 <div class="empty-state">
2426 <h2>Repository not found</h2>
2427 <p>
2428 {owner}/{repo} does not exist.
2429 </p>
2430 </div>
2431 </Layout>,
2432 404
2433 );
2434 }
2435
05b973eClaude2436 // Parallelize all independent operations
2437 const [defaultBranch, branches] = await Promise.all([
2438 getDefaultBranch(owner, repo).then((b) => b || "main"),
2439 listBranches(owner, repo),
2440 ]);
2441 const [tree, starInfo] = await Promise.all([
2442 getTree(owner, repo, defaultBranch),
2443 // Star info fetched in parallel with tree
2444 (async () => {
2445 try {
2446 const [ownerUser] = await db
2447 .select()
2448 .from(users)
2449 .where(eq(users.username, owner))
2450 .limit(1);
71cd5ecClaude2451 if (!ownerUser)
2452 return {
2453 starCount: 0,
2454 starred: false,
2455 archived: false,
2456 isTemplate: false,
544d842Claude2457 forkCount: 0,
2458 description: null as string | null,
2459 pushedAt: null as Date | null,
2460 createdAt: null as Date | null,
cb5a796Claude2461 repoId: null as string | null,
2462 repoOwnerId: null as string | null,
71cd5ecClaude2463 };
05b973eClaude2464 const [repoRow] = await db
2465 .select()
2466 .from(repositories)
2467 .where(
2468 and(
2469 eq(repositories.ownerId, ownerUser.id),
2470 eq(repositories.name, repo)
2471 )
06d5ffeClaude2472 )
05b973eClaude2473 .limit(1);
71cd5ecClaude2474 if (!repoRow)
2475 return {
2476 starCount: 0,
2477 starred: false,
2478 archived: false,
2479 isTemplate: false,
544d842Claude2480 forkCount: 0,
2481 description: null as string | null,
2482 pushedAt: null as Date | null,
2483 createdAt: null as Date | null,
cb5a796Claude2484 repoId: null as string | null,
2485 repoOwnerId: null as string | null,
71cd5ecClaude2486 };
05b973eClaude2487 let starred = false;
06d5ffeClaude2488 if (user) {
2489 const [star] = await db
2490 .select()
2491 .from(stars)
2492 .where(
2493 and(
2494 eq(stars.userId, user.id),
2495 eq(stars.repositoryId, repoRow.id)
2496 )
2497 )
2498 .limit(1);
2499 starred = !!star;
2500 }
71cd5ecClaude2501 return {
2502 starCount: repoRow.starCount,
2503 starred,
2504 archived: repoRow.isArchived,
2505 isTemplate: repoRow.isTemplate,
544d842Claude2506 forkCount: repoRow.forkCount,
2507 description: repoRow.description as string | null,
2508 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
2509 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude2510 repoId: repoRow.id as string,
2511 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude2512 };
05b973eClaude2513 } catch {
71cd5ecClaude2514 return {
2515 starCount: 0,
2516 starred: false,
2517 archived: false,
2518 isTemplate: false,
544d842Claude2519 forkCount: 0,
2520 description: null as string | null,
2521 pushedAt: null as Date | null,
2522 createdAt: null as Date | null,
cb5a796Claude2523 repoId: null as string | null,
2524 repoOwnerId: null as string | null,
71cd5ecClaude2525 };
06d5ffeClaude2526 }
05b973eClaude2527 })(),
2528 ]);
544d842Claude2529 const {
2530 starCount,
2531 starred,
2532 archived,
2533 isTemplate,
2534 forkCount,
2535 description,
2536 pushedAt,
2537 createdAt,
cb5a796Claude2538 repoId,
2539 repoOwnerId,
544d842Claude2540 } = starInfo;
2541
91a0204Claude2542 // Health score badge — fire-and-forget, best-effort. If the DB call fails
2543 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
2544 // and the badge simply doesn't render.
2545 let healthScore: HealthScore | null = null;
2546 if (repoId) {
2547 try {
2548 healthScore = await computeHealthScore(repoId);
2549 } catch {
2550 // swallow — badge is optional
2551 }
2552 }
2553
cb5a796Claude2554 // Pending-comments banner data (lazy + best-effort). Only the repo
2555 // owner sees the banner, so non-owner views skip the DB hit entirely.
2556 let repoHomePendingCount = 0;
2557 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
2558 try {
2559 const { countPendingForRepo } = await import(
2560 "../lib/comment-moderation"
2561 );
2562 repoHomePendingCount = await countPendingForRepo(repoId);
2563 } catch {
2564 /* swallow */
2565 }
2566 }
2567
8c790e0Claude2568 // Push Watch discoverability — fetch the most recent push within 24 h.
2569 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
2570 const recentPush: RecentPush | null = repoId
2571 ? await getRecentPush(repoId)
2572 : null;
2573
ebaae0fClaude2574 // AI stats strip — best-effort, fail-open to zero values.
2575 let aiStats: RepoAiStats = {
2576 mergedCount: 0,
2577 reviewCount: 0,
2578 securityAlertCount: 0,
2579 hoursSaved: "0.0",
2580 };
2581 if (repoId) {
2582 try {
2583 aiStats = await getRepoAiStats(repoId);
2584 } catch {
2585 /* swallow — show zero values */
2586 }
2587 }
2588
544d842Claude2589 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
2590 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
2591 const repoHomeCss = `
2592 .repo-home-hero {
2593 position: relative;
2594 margin-bottom: var(--space-5);
2595 padding: var(--space-5) var(--space-6);
2596 background: var(--bg-elevated);
2597 border: 1px solid var(--border);
2598 border-radius: 16px;
2599 overflow: hidden;
2600 }
2601 .repo-home-hero::before {
2602 content: '';
2603 position: absolute;
2604 top: 0; left: 0; right: 0;
2605 height: 2px;
2606 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2607 opacity: 0.7;
2608 pointer-events: none;
2609 }
2610 .repo-home-hero-orb-wrap {
2611 position: absolute;
2612 inset: -25% -10% auto auto;
2613 width: 360px;
2614 height: 360px;
2615 pointer-events: none;
2616 z-index: 0;
2617 }
2618 .repo-home-hero-orb {
2619 position: absolute;
2620 inset: 0;
2621 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
2622 filter: blur(80px);
2623 opacity: 0.7;
2624 animation: repoHomeOrb 14s ease-in-out infinite;
2625 }
2626 @keyframes repoHomeOrb {
2627 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
2628 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
2629 }
2630 @media (prefers-reduced-motion: reduce) {
2631 .repo-home-hero-orb { animation: none; }
2632 }
2633 .repo-home-hero-inner {
2634 position: relative;
2635 z-index: 1;
2636 }
2637 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
2638 .repo-home-eyebrow {
2639 font-size: 12px;
2640 font-family: var(--font-mono);
2641 color: var(--text-muted);
2642 letter-spacing: 0.1em;
2643 text-transform: uppercase;
2644 margin-bottom: var(--space-2);
2645 }
2646 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
2647 .repo-home-description {
2648 font-size: 15px;
2649 line-height: 1.55;
2650 color: var(--text);
2651 margin: 0;
2652 max-width: 720px;
2653 }
2654 .repo-home-description-empty {
2655 font-size: 14px;
2656 color: var(--text-muted);
2657 font-style: italic;
2658 margin: 0;
2659 }
2660 .repo-home-stat-row {
2661 display: flex;
2662 flex-wrap: wrap;
2663 gap: var(--space-4);
2664 margin-top: var(--space-3);
2665 font-size: 13px;
2666 color: var(--text-muted);
2667 }
2668 .repo-home-stat {
2669 display: inline-flex;
2670 align-items: center;
2671 gap: 6px;
2672 }
2673 .repo-home-stat strong {
2674 color: var(--text-strong);
2675 font-weight: 600;
2676 font-variant-numeric: tabular-nums;
2677 }
2678 .repo-home-stat .repo-home-stat-icon {
2679 color: var(--text-faint);
2680 font-size: 14px;
2681 line-height: 1;
2682 }
2683 .repo-home-stat a {
2684 color: var(--text-muted);
2685 transition: color var(--t-fast) var(--ease);
2686 }
2687 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
2688
2689 /* Two-column layout: file tree + sidebar */
2690 .repo-home-grid {
2691 display: grid;
2692 grid-template-columns: minmax(0, 1fr) 280px;
2693 gap: var(--space-5);
2694 align-items: start;
2695 }
2696 @media (max-width: 960px) {
2697 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
2698 }
2699 .repo-home-main { min-width: 0; }
2700
2701 /* Sidebar card */
2702 .repo-home-side {
2703 display: flex;
2704 flex-direction: column;
2705 gap: var(--space-4);
2706 }
2707 .repo-home-side-card {
2708 background: var(--bg-elevated);
2709 border: 1px solid var(--border);
2710 border-radius: 12px;
2711 padding: var(--space-4);
2712 }
2713 .repo-home-side-title {
2714 font-size: 11px;
2715 font-family: var(--font-mono);
2716 letter-spacing: 0.12em;
2717 text-transform: uppercase;
2718 color: var(--text-muted);
2719 margin: 0 0 var(--space-3);
2720 font-weight: 600;
2721 }
2722 .repo-home-side-row {
2723 display: flex;
2724 justify-content: space-between;
2725 align-items: center;
2726 gap: var(--space-2);
2727 font-size: 13px;
2728 padding: 6px 0;
2729 border-top: 1px solid var(--border);
2730 }
2731 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
2732 .repo-home-side-key {
2733 color: var(--text-muted);
2734 display: inline-flex;
2735 align-items: center;
2736 gap: 6px;
2737 }
2738 .repo-home-side-val {
2739 color: var(--text-strong);
2740 font-weight: 500;
2741 font-variant-numeric: tabular-nums;
2742 max-width: 60%;
2743 text-align: right;
2744 overflow: hidden;
2745 text-overflow: ellipsis;
2746 white-space: nowrap;
2747 }
2748 .repo-home-side-val a { color: var(--text-strong); }
2749 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
2750
2751 /* Clone / Code tabs */
2752 .repo-home-clone {
2753 background: var(--bg-elevated);
2754 border: 1px solid var(--border);
2755 border-radius: 12px;
2756 overflow: hidden;
2757 }
2758 .repo-home-clone-tabs {
2759 display: flex;
2760 gap: 0;
2761 background: var(--bg-secondary);
2762 border-bottom: 1px solid var(--border);
2763 padding: 0 var(--space-2);
2764 }
2765 .repo-home-clone-tab {
2766 appearance: none;
2767 background: transparent;
2768 border: 0;
2769 border-bottom: 2px solid transparent;
2770 padding: 9px 12px;
2771 font-size: 12px;
2772 font-weight: 500;
2773 color: var(--text-muted);
2774 cursor: pointer;
2775 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2776 font-family: inherit;
2777 margin-bottom: -1px;
2778 }
2779 .repo-home-clone-tab:hover { color: var(--text-strong); }
2780 .repo-home-clone-tab[aria-selected="true"] {
2781 color: var(--text-strong);
2782 border-bottom-color: var(--accent);
2783 }
2784 .repo-home-clone-body {
2785 padding: var(--space-3);
2786 display: flex;
2787 align-items: center;
2788 gap: var(--space-2);
2789 }
2790 .repo-home-clone-input {
2791 flex: 1;
2792 min-width: 0;
2793 font-family: var(--font-mono);
2794 font-size: 12px;
2795 background: var(--bg);
2796 border: 1px solid var(--border);
2797 border-radius: 8px;
2798 padding: 8px 10px;
2799 color: var(--text-strong);
2800 overflow: hidden;
2801 text-overflow: ellipsis;
2802 white-space: nowrap;
2803 }
2804 .repo-home-clone-copy {
2805 appearance: none;
2806 background: var(--bg-secondary);
2807 border: 1px solid var(--border);
2808 color: var(--text-strong);
2809 border-radius: 8px;
2810 padding: 8px 12px;
2811 font-size: 12px;
2812 font-weight: 600;
2813 cursor: pointer;
2814 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
2815 font-family: inherit;
2816 }
2817 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
2818 .repo-home-clone-pane { display: none; }
2819 .repo-home-clone-pane[data-active="true"] { display: flex; }
2820
2821 /* README card */
2822 .repo-home-readme {
2823 margin-top: var(--space-5);
2824 background: var(--bg-elevated);
2825 border: 1px solid var(--border);
2826 border-radius: 12px;
2827 overflow: hidden;
2828 }
2829 .repo-home-readme-head {
2830 display: flex;
2831 align-items: center;
2832 gap: 8px;
2833 padding: 10px 16px;
2834 background: var(--bg-secondary);
2835 border-bottom: 1px solid var(--border);
2836 font-size: 13px;
2837 color: var(--text-muted);
2838 }
2839 .repo-home-readme-head .repo-home-readme-icon {
2840 color: var(--accent);
2841 font-size: 14px;
2842 }
2843 .repo-home-readme-body {
2844 padding: var(--space-5) var(--space-6);
2845 }
2846
2847 /* Empty-state CTA */
2848 .repo-home-empty {
2849 position: relative;
2850 margin-top: var(--space-4);
2851 background: var(--bg-elevated);
2852 border: 1px solid var(--border);
2853 border-radius: 16px;
2854 padding: var(--space-6);
2855 overflow: hidden;
2856 }
2857 .repo-home-empty::before {
2858 content: '';
2859 position: absolute;
2860 top: 0; left: 0; right: 0;
2861 height: 2px;
2862 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2863 opacity: 0.7;
2864 pointer-events: none;
2865 }
2866 .repo-home-empty-eyebrow {
2867 font-size: 12px;
2868 font-family: var(--font-mono);
2869 color: var(--accent);
2870 letter-spacing: 0.12em;
2871 text-transform: uppercase;
2872 margin-bottom: var(--space-2);
2873 font-weight: 600;
2874 }
2875 .repo-home-empty-title {
2876 font-family: var(--font-display);
2877 font-weight: 800;
2878 letter-spacing: -0.025em;
2879 font-size: clamp(22px, 3vw, 30px);
2880 line-height: 1.1;
2881 margin: 0 0 var(--space-2);
2882 color: var(--text-strong);
2883 }
2884 .repo-home-empty-sub {
2885 color: var(--text-muted);
2886 font-size: 14px;
2887 line-height: 1.55;
2888 max-width: 640px;
2889 margin: 0 0 var(--space-4);
2890 }
2891 .repo-home-empty-snippet {
2892 background: var(--bg);
2893 border: 1px solid var(--border);
2894 border-radius: 10px;
2895 padding: var(--space-3) var(--space-4);
2896 font-family: var(--font-mono);
2897 font-size: 12.5px;
2898 line-height: 1.7;
2899 color: var(--text-strong);
2900 overflow-x: auto;
2901 white-space: pre;
2902 margin: 0;
2903 }
2904 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
2905 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
2906
91a0204Claude2907 /* Health score badge */
2908 .repo-health-badge {
2909 display: inline-flex;
2910 align-items: center;
2911 gap: 5px;
2912 padding: 3px 10px;
2913 border-radius: 999px;
2914 font-size: 11.5px;
2915 font-weight: 700;
2916 font-family: var(--font-mono);
2917 text-decoration: none;
2918 border: 1px solid transparent;
2919 transition: filter 120ms ease, opacity 120ms ease;
2920 vertical-align: middle;
2921 }
2922 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
2923 .repo-health-badge-elite {
2924 background: rgba(52,211,153,0.15);
2925 color: #6ee7b7;
2926 border-color: rgba(52,211,153,0.30);
2927 }
2928 .repo-health-badge-strong {
2929 background: rgba(96,165,250,0.15);
2930 color: #93c5fd;
2931 border-color: rgba(96,165,250,0.30);
2932 }
2933 .repo-health-badge-improving {
2934 background: rgba(251,191,36,0.15);
2935 color: #fde68a;
2936 border-color: rgba(251,191,36,0.30);
2937 }
2938 .repo-health-badge-needs-attention {
2939 background: rgba(248,113,113,0.15);
2940 color: #fca5a5;
2941 border-color: rgba(248,113,113,0.30);
2942 }
2943
2944 /* Three-option empty-state panel */
2945 .repo-empty-options {
2946 display: grid;
2947 grid-template-columns: repeat(3, 1fr);
2948 gap: var(--space-3);
2949 margin-top: var(--space-5);
2950 }
2951 @media (max-width: 760px) {
2952 .repo-empty-options { grid-template-columns: 1fr; }
2953 }
2954 .repo-empty-option {
2955 position: relative;
2956 background: var(--bg-elevated);
2957 border: 1px solid var(--border);
2958 border-radius: 14px;
2959 padding: var(--space-4) var(--space-4);
2960 display: flex;
2961 flex-direction: column;
2962 gap: var(--space-2);
2963 overflow: hidden;
2964 transition: border-color 140ms ease, background 140ms ease;
2965 }
2966 .repo-empty-option:hover {
2967 border-color: rgba(140,109,255,0.45);
2968 background: rgba(140,109,255,0.04);
2969 }
2970 .repo-empty-option::before {
2971 content: '';
2972 position: absolute;
2973 top: 0; left: 0; right: 0;
2974 height: 2px;
2975 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2976 opacity: 0.55;
2977 pointer-events: none;
2978 }
2979 .repo-empty-option-label {
2980 font-size: 10px;
2981 font-family: var(--font-mono);
2982 font-weight: 700;
2983 letter-spacing: 0.12em;
2984 text-transform: uppercase;
2985 color: var(--accent);
2986 margin-bottom: 2px;
2987 }
2988 .repo-empty-option-title {
2989 font-family: var(--font-display);
2990 font-weight: 700;
2991 font-size: 16px;
2992 letter-spacing: -0.01em;
2993 color: var(--text-strong);
2994 margin: 0;
2995 }
2996 .repo-empty-option-sub {
2997 font-size: 13px;
2998 color: var(--text-muted);
2999 line-height: 1.5;
3000 margin: 0;
3001 flex: 1;
3002 }
3003 .repo-empty-option-snippet {
3004 background: var(--bg);
3005 border: 1px solid var(--border);
3006 border-radius: 8px;
3007 padding: var(--space-2) var(--space-3);
3008 font-family: var(--font-mono);
3009 font-size: 11.5px;
3010 line-height: 1.75;
3011 color: var(--text-strong);
3012 overflow-x: auto;
3013 white-space: pre;
3014 margin: var(--space-1) 0 0;
3015 }
3016 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3017 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3018 .repo-empty-option-cta {
3019 display: inline-flex;
3020 align-items: center;
3021 gap: 6px;
3022 margin-top: auto;
3023 padding-top: var(--space-2);
3024 font-size: 13px;
3025 font-weight: 600;
3026 color: var(--accent);
3027 text-decoration: none;
3028 transition: color 120ms ease;
3029 }
3030 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3031
544d842Claude3032 @media (max-width: 720px) {
3033 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3034 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3035 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3036 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3037 .repo-home-side-val { max-width: 55%; }
3038 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3039 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3040 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3041 }
ebaae0fClaude3042
3043 /* AI stats strip */
3044 .repo-ai-stats-strip {
3045 display: flex;
3046 align-items: center;
3047 gap: 6px;
3048 margin-top: 12px;
3049 margin-bottom: 4px;
3050 font-size: 12px;
3051 color: var(--text-muted);
3052 flex-wrap: wrap;
3053 }
3054 .repo-ai-stats-strip a {
3055 color: var(--text-muted);
3056 text-decoration: underline;
3057 text-decoration-color: transparent;
3058 transition: color 120ms ease, text-decoration-color 120ms ease;
3059 }
3060 .repo-ai-stats-strip a:hover {
3061 color: var(--accent);
3062 text-decoration-color: currentColor;
3063 }
3064 .repo-ai-stats-sep {
3065 opacity: 0.4;
3066 user-select: none;
3067 }
544d842Claude3068 `;
3069 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3070 // SSH URL — port-aware:
3071 // Standard port 22 → git@host:owner/repo.git
3072 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3073 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3074 try {
3075 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3076 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3077 ? host
3078 : "localhost";
3079 const sshPort = config.sshPort;
3080 if (sshPort === 22) {
3081 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3082 } else {
3083 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3084 }
3085 } catch {
3086 // Fall through to default.
3087 }
3088 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3089 const formatRelative = (date: Date | null): string => {
3090 if (!date) return "never";
3091 const ms = Date.now() - date.getTime();
3092 const s = Math.max(0, Math.round(ms / 1000));
3093 if (s < 60) return "just now";
3094 const m = Math.round(s / 60);
3095 if (m < 60) return `${m} min ago`;
3096 const h = Math.round(m / 60);
3097 if (h < 24) return `${h}h ago`;
3098 const d = Math.round(h / 24);
3099 if (d < 30) return `${d}d ago`;
3100 const mo = Math.round(d / 30);
3101 if (mo < 12) return `${mo}mo ago`;
3102 const y = Math.round(d / 365);
3103 return `${y}y ago`;
3104 };
06d5ffeClaude3105
fc1817aClaude3106 if (tree.length === 0) {
5acce80Claude3107 const repoOgDesc = description
3108 ? `${owner}/${repo} on Gluecron — ${description}`
3109 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3110 return c.html(
5acce80Claude3111 <Layout
3112 title={`${owner}/${repo}`}
3113 user={user}
3114 description={repoOgDesc}
3115 ogTitle={`${owner}/${repo} — Gluecron`}
3116 ogDescription={repoOgDesc}
3117 twitterCard="summary"
3118 >
544d842Claude3119 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3120 <style dangerouslySetInnerHTML={{ __html: `
3121 .empty-options-grid {
3122 display: grid;
3123 grid-template-columns: repeat(3, 1fr);
3124 gap: var(--space-4);
3125 margin-top: var(--space-4);
3126 }
3127 @media (max-width: 800px) {
3128 .empty-options-grid { grid-template-columns: 1fr; }
3129 }
3130 .empty-option-card {
3131 position: relative;
3132 background: var(--bg-elevated);
3133 border: 1px solid var(--border);
3134 border-radius: 14px;
3135 padding: var(--space-5);
3136 display: flex;
3137 flex-direction: column;
3138 gap: var(--space-3);
3139 overflow: hidden;
3140 transition: border-color 140ms ease, box-shadow 140ms ease;
3141 }
3142 .empty-option-card:hover {
3143 border-color: rgba(140,109,255,0.45);
3144 box-shadow: 0 8px 24px -8px rgba(140,109,255,0.18);
3145 }
3146 .empty-option-card::before {
3147 content: '';
3148 position: absolute;
3149 top: 0; left: 0; right: 0;
3150 height: 2px;
3151 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
3152 opacity: 0.55;
3153 pointer-events: none;
3154 }
3155 .empty-option-label {
3156 font-size: 10.5px;
3157 font-family: var(--font-mono);
3158 text-transform: uppercase;
3159 letter-spacing: 0.14em;
3160 color: var(--accent);
3161 font-weight: 700;
3162 }
3163 .empty-option-title {
3164 font-family: var(--font-display);
3165 font-weight: 700;
3166 font-size: 17px;
3167 letter-spacing: -0.01em;
3168 color: var(--text-strong);
3169 margin: 0;
3170 line-height: 1.25;
3171 }
3172 .empty-option-body {
3173 font-size: 13px;
3174 color: var(--text-muted);
3175 line-height: 1.55;
3176 margin: 0;
3177 flex: 1;
3178 }
3179 .empty-option-snippet {
3180 background: var(--bg);
3181 border: 1px solid var(--border);
3182 border-radius: 8px;
3183 padding: var(--space-3) var(--space-4);
3184 font-family: var(--font-mono);
3185 font-size: 11.5px;
3186 line-height: 1.75;
3187 color: var(--text-strong);
3188 overflow-x: auto;
3189 white-space: pre;
3190 margin: 0;
3191 }
3192 .empty-option-snippet .ec { color: var(--text-faint); }
3193 .empty-option-snippet .em { color: var(--accent); }
3194 .empty-option-cta {
3195 display: inline-flex;
3196 align-items: center;
3197 gap: 6px;
3198 padding: 8px 16px;
3199 font-size: 13px;
3200 font-weight: 600;
3201 color: var(--text-strong);
3202 background: var(--bg-secondary);
3203 border: 1px solid var(--border);
3204 border-radius: 9999px;
3205 text-decoration: none;
3206 align-self: flex-start;
3207 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3208 }
3209 .empty-option-cta:hover {
3210 border-color: rgba(140,109,255,0.55);
3211 background: rgba(140,109,255,0.08);
3212 color: var(--accent);
3213 text-decoration: none;
3214 }
3215 .empty-option-cta-accent {
3216 background: linear-gradient(135deg, #8c6dff, #36c5d6);
3217 border-color: transparent;
3218 color: #fff;
3219 }
3220 .empty-option-cta-accent:hover {
3221 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3222 border-color: transparent;
3223 color: #fff;
3224 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.55);
3225 }
3226 ` }} />
544d842Claude3227 <div class="repo-home-hero">
3228 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3229 <div class="repo-home-hero-orb" />
3230 </div>
3231 <div class="repo-home-hero-inner">
3232 <div class="repo-home-eyebrow">
3233 <strong>Repository</strong> · {owner}
3234 </div>
91a0204Claude3235 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3236 <RepoHeader
3237 owner={owner}
3238 repo={repo}
3239 starCount={starCount}
3240 starred={starred}
3241 forkCount={forkCount}
3242 currentUser={user?.username}
3243 archived={archived}
3244 isTemplate={isTemplate}
8c790e0Claude3245 recentPush={recentPush}
91a0204Claude3246 />
3247 {healthScore && (() => {
3248 const gradeLabel: Record<string, string> = {
3249 elite: "Elite", strong: "Strong",
3250 improving: "Improving", "needs-attention": "Needs Attention",
3251 };
3252 return (
3253 <a
3254 href={`/${owner}/${repo}/insights/health`}
3255 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3256 title={`Health score: ${healthScore!.total}/100`}
3257 >
3258 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3259 </a>
3260 );
3261 })()}
3262 </div>
544d842Claude3263 {description ? (
3264 <p class="repo-home-description">{description}</p>
3265 ) : (
3266 <p class="repo-home-description-empty">
3267 No description yet — push a README to tell the world what this
3268 ships.
3269 </p>
3270 )}
3271 </div>
3272 </div>
fc1817aClaude3273 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3274 <div style="margin-top:var(--space-4)">
3275 <div style="font-size:12px;font-family:var(--font-mono);color:var(--accent);letter-spacing:0.12em;text-transform:uppercase;font-weight:600;margin-bottom:var(--space-2)">
3276 Getting started
3277 </div>
544d842Claude3278 <h2 class="repo-home-empty-title">
91a0204Claude3279 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3280 </h2>
3281 <p class="repo-home-empty-sub">
91a0204Claude3282 Choose how you want to kick things off. Every push wires up gate
3283 checks and AI review automatically.
544d842Claude3284 </p>
91a0204Claude3285 <div class="empty-options-grid">
3286 <div class="empty-option-card">
3287 <div class="empty-option-label">Option A</div>
3288 <h3 class="empty-option-title">Push your first commit</h3>
3289 <p class="empty-option-body">
3290 Wire up an existing project in seconds. Run these commands from
3291 your project directory.
3292 </p>
3293 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3294<span class="em">git remote add</span> origin {cloneHttpsUrl}
3295<span class="em">git branch</span> -M main
3296<span class="em">git push</span> -u origin main</pre>
3297 </div>
3298 <div class="empty-option-card">
3299 <div class="empty-option-label">Option B</div>
3300 <h3 class="empty-option-title">Import from GitHub</h3>
3301 <p class="empty-option-body">
3302 Mirror an existing GitHub repository here in one click. Gluecron
3303 syncs the full history and branches.
3304 </p>
3305 <a href="/import" class="empty-option-cta">
3306 Import repository
3307 </a>
3308 </div>
3309 <div class="empty-option-card">
3310 <div class="empty-option-label">Option C</div>
3311 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3312 <p class="empty-option-body">
3313 Let AI write your first feature. Describe what you want to build
3314 and Gluecron opens a pull request with the code.
3315 </p>
3316 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3317 Let AI write your first feature
3318 </a>
3319 </div>
3320 </div>
fc1817aClaude3321 </div>
3322 </Layout>
3323 );
3324 }
3325
3326 const readme = await getReadme(owner, repo, defaultBranch);
3327
544d842Claude3328 // Sidebar facts — derived from data we already have.
3329 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3330 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3331
5acce80Claude3332 const repoOgDesc = description
3333 ? `${owner}/${repo} on Gluecron — ${description}`
3334 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3335
fc1817aClaude3336 return c.html(
5acce80Claude3337 <Layout
3338 title={`${owner}/${repo}`}
3339 user={user}
3340 description={repoOgDesc}
3341 ogTitle={`${owner}/${repo} — Gluecron`}
3342 ogDescription={repoOgDesc}
3343 twitterCard="summary"
3344 >
544d842Claude3345 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
3346 <div class="repo-home-hero">
3347 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3348 <div class="repo-home-hero-orb" />
3349 </div>
3350 <div class="repo-home-hero-inner">
3351 <div class="repo-home-eyebrow">
3352 <strong>Repository</strong> · {owner}
3353 </div>
91a0204Claude3354 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3355 <RepoHeader
3356 owner={owner}
3357 repo={repo}
3358 starCount={starCount}
3359 starred={starred}
3360 forkCount={forkCount}
3361 currentUser={user?.username}
3362 archived={archived}
3363 isTemplate={isTemplate}
8c790e0Claude3364 recentPush={recentPush}
91a0204Claude3365 />
3366 {healthScore && (() => {
3367 const gradeLabel: Record<string, string> = {
3368 elite: "Elite", strong: "Strong",
3369 improving: "Improving", "needs-attention": "Needs Attention",
3370 };
3371 return (
3372 <a
3373 href={`/${owner}/${repo}/insights/health`}
3374 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3375 title={`Health score: ${healthScore!.total}/100`}
3376 >
3377 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3378 </a>
3379 );
3380 })()}
3381 </div>
544d842Claude3382 {description ? (
3383 <p class="repo-home-description">{description}</p>
3384 ) : (
3385 <p class="repo-home-description-empty">
3386 No description yet.
3387 </p>
3388 )}
3389 <div class="repo-home-stat-row" aria-label="Repository stats">
3390 <span class="repo-home-stat" title="Default branch">
3391 <span class="repo-home-stat-icon">{"⎇"}</span>
3392 <strong>{defaultBranch}</strong>
3393 </span>
3394 <a
3395 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3396 class="repo-home-stat"
3397 title="Browse all branches"
3398 >
3399 <span class="repo-home-stat-icon">{"⊢"}</span>
3400 <strong>{branches.length}</strong>{" "}
3401 branch{branches.length === 1 ? "" : "es"}
3402 </a>
3403 <span class="repo-home-stat" title="Top-level entries">
3404 <span class="repo-home-stat-icon">{"■"}</span>
3405 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3406 {dirCount > 0 && (
3407 <>
3408 {" · "}
3409 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3410 </>
3411 )}
3412 </span>
3413 {pushedAt && (
3414 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3415 <span class="repo-home-stat-icon">{"↻"}</span>
3416 Updated <strong>{formatRelative(pushedAt)}</strong>
3417 </span>
3418 )}
3419 </div>
3420 </div>
3421 </div>
71cd5ecClaude3422 {isTemplate && user && user.username !== owner && (
3423 <div
3424 class="panel"
dc26881CC LABS App3425 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3426 >
3427 <div style="font-size:13px">
3428 <strong>Template repository.</strong> Create a new repository from
3429 this template's files.
3430 </div>
3431 <form
001af43Claude3432 method="post"
71cd5ecClaude3433 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3434 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3435 >
3436 <input
3437 type="text"
3438 name="name"
3439 placeholder="new-repo-name"
3440 required
2c3ba6ecopilot-swe-agent[bot]3441 aria-label="New repository name"
71cd5ecClaude3442 style="width:200px"
3443 />
3444 <button type="submit" class="btn btn-primary">
3445 Use this template
3446 </button>
3447 </form>
3448 </div>
3449 )}
fc1817aClaude3450 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3451 <RepoHomePendingBanner
3452 owner={owner}
3453 repo={repo}
3454 count={repoHomePendingCount}
3455 />
c6018a5Claude3456 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
3457 row sits just below the nav as a slim CTA strip. Scoped under
3458 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
3459 <style
3460 dangerouslySetInnerHTML={{
3461 __html: `
3462 .repo-ai-cta-row {
3463 display: flex;
3464 flex-wrap: wrap;
3465 gap: 8px;
3466 margin: 12px 0 18px;
3467 padding: 10px 14px;
3468 background: linear-gradient(135deg, rgba(140,109,255,0.06), rgba(54,197,214,0.04));
3469 border: 1px solid var(--border);
3470 border-radius: 10px;
3471 position: relative;
3472 overflow: hidden;
3473 }
3474 .repo-ai-cta-row::before {
3475 content: '';
3476 position: absolute;
3477 top: 0; left: 0; right: 0;
3478 height: 1px;
3479 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
3480 opacity: 0.7;
3481 pointer-events: none;
3482 }
3483 .repo-ai-cta-label {
3484 font-size: 11px;
3485 font-weight: 600;
3486 letter-spacing: 0.06em;
3487 text-transform: uppercase;
3488 color: var(--accent);
3489 align-self: center;
3490 padding-right: 8px;
3491 border-right: 1px solid var(--border);
3492 margin-right: 4px;
3493 }
3494 .repo-ai-cta {
3495 display: inline-flex;
3496 align-items: center;
3497 gap: 6px;
3498 padding: 5px 10px;
3499 font-size: 12.5px;
3500 font-weight: 500;
3501 color: var(--text);
3502 background: var(--bg);
3503 border: 1px solid var(--border);
3504 border-radius: 7px;
3505 text-decoration: none;
3506 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3507 }
3508 .repo-ai-cta:hover {
3509 border-color: rgba(140,109,255,0.45);
3510 color: var(--text-strong);
3511 background: rgba(140,109,255,0.06);
3512 text-decoration: none;
3513 }
3514 .repo-ai-cta-icon {
3515 opacity: 0.75;
3516 font-size: 12px;
3517 }
3518 @media (max-width: 640px) {
3519 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
3520 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
3521 }
3522 `,
3523 }}
3524 />
3525 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
3526 <span class="repo-ai-cta-label">AI surfaces</span>
3527 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
3528 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
3529 Chat
3530 </a>
3531 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
3532 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
3533 Previews
3534 </a>
3535 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
3536 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
3537 Migrations
3538 </a>
53c9249Claude3539 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude3540 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude3541 AI Search
c6018a5Claude3542 </a>
3543 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
3544 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
3545 AI release notes
3546 </a>
3646bfeClaude3547 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
3548 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
3549 Dev environment
3550 </a>
c6018a5Claude3551 </nav>
544d842Claude3552 <div class="repo-home-grid">
3553 <div class="repo-home-main">
3554 <BranchSwitcher
3555 owner={owner}
3556 repo={repo}
3557 currentRef={defaultBranch}
3558 branches={branches}
3559 pathType="tree"
3560 />
3561 <FileTable
3562 entries={tree}
3563 owner={owner}
3564 repo={repo}
3565 ref={defaultBranch}
3566 path=""
3567 />
ebaae0fClaude3568 {/* AI stats strip — one-line summary of AI value for this repo */}
3569 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
3570 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3571 <span>{"⚡"}</span>
3572 {aiStats.mergedCount > 0 ? (
3573 <a href={`/${owner}/${repo}/pulls?state=merged`}>
3574 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
3575 </a>
3576 ) : (
3577 <span>0 AI merges this week</span>
3578 )}
3579 <span class="repo-ai-stats-sep">{"·"}</span>
3580 <span>Saved ~{aiStats.hoursSaved} hrs</span>
3581 <span class="repo-ai-stats-sep">{"·"}</span>
3582 {aiStats.securityAlertCount > 0 ? (
3583 <a href={`/${owner}/${repo}/issues?label=security`}>
3584 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
3585 </a>
3586 ) : (
3587 <span>0 open security alerts</span>
3588 )}
3589 </div>
3590 ) : (
3591 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
3592 <span>{"⚡"}</span>
3593 <span>AI is watching — no activity this week</span>
3594 </div>
3595 )}
544d842Claude3596 {readme && (() => {
3597 const readmeHtml = renderMarkdown(readme);
3598 return (
3599 <div class="repo-home-readme">
3600 <div class="repo-home-readme-head">
3601 <span class="repo-home-readme-icon">{"☰"}</span>
3602 <span>README.md</span>
3603 </div>
3604 <style>{markdownCss}</style>
3605 <div class="markdown-body repo-home-readme-body">
3606 {html([readmeHtml] as unknown as TemplateStringsArray)}
3607 </div>
3608 </div>
3609 );
3610 })()}
3611 </div>
3612 <aside class="repo-home-side" aria-label="Repository details">
3613 <div class="repo-home-clone">
3614 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
3615 <button
3616 type="button"
3617 class="repo-home-clone-tab"
3618 role="tab"
3619 aria-selected="true"
3620 data-pane="https"
3621 data-repo-home-clone-tab
3622 >
3623 HTTPS
3624 </button>
3625 <button
3626 type="button"
3627 class="repo-home-clone-tab"
3628 role="tab"
3629 aria-selected="false"
3630 data-pane="ssh"
3631 data-repo-home-clone-tab
3632 >
3633 SSH
3634 </button>
3635 <button
3636 type="button"
3637 class="repo-home-clone-tab"
3638 role="tab"
3639 aria-selected="false"
3640 data-pane="cli"
3641 data-repo-home-clone-tab
3642 >
3643 CLI
3644 </button>
3645 </div>
3646 <div
3647 class="repo-home-clone-pane"
3648 data-pane="https"
3649 data-active="true"
3650 role="tabpanel"
3651 >
3652 <div class="repo-home-clone-body">
3653 <input
3654 class="repo-home-clone-input"
3655 type="text"
3656 value={cloneHttpsUrl}
3657 readonly
3658 aria-label="HTTPS clone URL"
3659 data-repo-home-clone-input
3660 />
3661 <button
3662 type="button"
3663 class="repo-home-clone-copy"
3664 data-repo-home-copy={cloneHttpsUrl}
3665 >
3666 Copy
3667 </button>
3668 </div>
3669 </div>
3670 <div
3671 class="repo-home-clone-pane"
3672 data-pane="ssh"
3673 data-active="false"
3674 role="tabpanel"
3675 >
3676 <div class="repo-home-clone-body">
3677 <input
3678 class="repo-home-clone-input"
3679 type="text"
3680 value={cloneSshUrl}
3681 readonly
3682 aria-label="SSH clone URL"
3683 data-repo-home-clone-input
3684 />
3685 <button
3686 type="button"
3687 class="repo-home-clone-copy"
3688 data-repo-home-copy={cloneSshUrl}
3689 >
3690 Copy
3691 </button>
3692 </div>
3693 </div>
3694 <div
3695 class="repo-home-clone-pane"
3696 data-pane="cli"
3697 data-active="false"
3698 role="tabpanel"
3699 >
3700 <div class="repo-home-clone-body">
3701 <input
3702 class="repo-home-clone-input"
3703 type="text"
3704 value={cloneCliCmd}
3705 readonly
3706 aria-label="Gluecron CLI clone command"
3707 data-repo-home-clone-input
3708 />
3709 <button
3710 type="button"
3711 class="repo-home-clone-copy"
3712 data-repo-home-copy={cloneCliCmd}
3713 >
3714 Copy
3715 </button>
3716 </div>
79136bbClaude3717 </div>
fc1817aClaude3718 </div>
544d842Claude3719 <div class="repo-home-side-card">
3720 <h3 class="repo-home-side-title">About</h3>
3721 <div class="repo-home-side-row">
3722 <span class="repo-home-side-key">Default branch</span>
3723 <span class="repo-home-side-val">{defaultBranch}</span>
3724 </div>
3725 <div class="repo-home-side-row">
3726 <span class="repo-home-side-key">Branches</span>
3727 <span class="repo-home-side-val">
3728 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
3729 {branches.length}
3730 </a>
3731 </span>
3732 </div>
3733 <div class="repo-home-side-row">
3734 <span class="repo-home-side-key">Stars</span>
3735 <span class="repo-home-side-val">{starCount}</span>
3736 </div>
3737 <div class="repo-home-side-row">
3738 <span class="repo-home-side-key">Forks</span>
3739 <span class="repo-home-side-val">{forkCount}</span>
3740 </div>
3741 {pushedAt && (
3742 <div class="repo-home-side-row">
3743 <span class="repo-home-side-key">Last push</span>
3744 <span class="repo-home-side-val">
3745 {formatRelative(pushedAt)}
3746 </span>
3747 </div>
3748 )}
3749 {createdAt && (
3750 <div class="repo-home-side-row">
3751 <span class="repo-home-side-key">Created</span>
3752 <span class="repo-home-side-val">
3753 {formatRelative(createdAt)}
3754 </span>
3755 </div>
3756 )}
3757 {(archived || isTemplate) && (
3758 <div class="repo-home-side-row">
3759 <span class="repo-home-side-key">State</span>
3760 <span class="repo-home-side-val">
3761 {archived ? "Archived" : "Template"}
3762 </span>
3763 </div>
3764 )}
3765 </div>
f1dc38bClaude3766 {/* Claude AI — quick-access card for authenticated users */}
3767 {user && (
3768 <div class="repo-home-side-card" style="margin-top:12px">
3769 <h3 class="repo-home-side-title" style="margin-bottom:10px">
3770 ✨ Claude AI
3771 </h3>
3772 <a
3773 href={`/${owner}/${repo}/claude`}
3774 style="display:block;background:#1f6feb;color:#fff;text-align:center;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-bottom:8px"
3775 >
3776 Claude Code sessions
3777 </a>
3778 <a
3779 href={`/${owner}/${repo}/ask`}
3780 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
3781 >
3782 Ask AI about this repo
3783 </a>
3784 </div>
3785 )}
544d842Claude3786 </aside>
3787 </div>
3788 <script
3789 dangerouslySetInnerHTML={{
3790 __html: `
3791 (function(){
3792 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
3793 tabs.forEach(function(tab){
3794 tab.addEventListener('click', function(){
3795 var target = tab.getAttribute('data-pane');
3796 tabs.forEach(function(t){
3797 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
3798 });
3799 var panes = document.querySelectorAll('.repo-home-clone-pane');
3800 panes.forEach(function(p){
3801 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
3802 });
3803 });
3804 });
3805 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
3806 copyBtns.forEach(function(btn){
3807 btn.addEventListener('click', function(){
3808 var text = btn.getAttribute('data-repo-home-copy') || '';
3809 var done = function(){
3810 var prev = btn.textContent;
3811 btn.textContent = 'Copied';
3812 setTimeout(function(){ btn.textContent = prev; }, 1200);
3813 };
3814 if (navigator.clipboard && navigator.clipboard.writeText) {
3815 navigator.clipboard.writeText(text).then(done, done);
3816 } else {
3817 var ta = document.createElement('textarea');
3818 ta.value = text;
3819 document.body.appendChild(ta);
3820 ta.select();
3821 try { document.execCommand('copy'); } catch (e) {}
3822 document.body.removeChild(ta);
3823 done();
3824 }
3825 });
3826 });
3827 })();
3828 `,
3829 }}
3830 />
fc1817aClaude3831 </Layout>
3832 );
3833});
3834
3835// Browse tree at ref/path
3836web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
3837 const { owner, repo } = c.req.param();
06d5ffeClaude3838 const user = c.get("user");
fc1817aClaude3839 const refAndPath = c.req.param("ref");
3840
3841 const branches = await listBranches(owner, repo);
3842 let ref = "";
3843 let treePath = "";
3844
3845 for (const branch of branches) {
3846 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
3847 ref = branch;
3848 treePath = refAndPath.slice(branch.length + 1);
3849 break;
3850 }
3851 }
3852
3853 if (!ref) {
3854 const slashIdx = refAndPath.indexOf("/");
3855 if (slashIdx === -1) {
3856 ref = refAndPath;
3857 } else {
3858 ref = refAndPath.slice(0, slashIdx);
3859 treePath = refAndPath.slice(slashIdx + 1);
3860 }
3861 }
3862
3863 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude3864 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3865 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude3866
3867 return c.html(
06d5ffeClaude3868 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude3869 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3870 <RepoHeader owner={owner} repo={repo} />
3871 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3872 <div class="tree-header">
3873 <div class="tree-header-row">
3874 <BranchSwitcher
3875 owner={owner}
3876 repo={repo}
3877 currentRef={ref}
3878 branches={branches}
3879 pathType="tree"
3880 subPath={treePath}
3881 />
3882 <div class="tree-header-stats">
3883 <span class="tree-stat" title="Entries in this directory">
3884 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3885 {dirCount > 0 && (
3886 <>
3887 {" · "}
3888 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3889 </>
3890 )}
3891 </span>
3892 <a
3893 href={`/${owner}/${repo}/search`}
3894 class="tree-stat tree-stat-link"
3895 title="Search code in this repository"
3896 >
3897 {"⌕"} Search
3898 </a>
3899 </div>
3900 </div>
3901 <div class="tree-breadcrumb-row">
3902 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
3903 </div>
3904 </div>
fc1817aClaude3905 <FileTable
3906 entries={tree}
3907 owner={owner}
3908 repo={repo}
3909 ref={ref}
3910 path={treePath}
3911 />
3912 </Layout>
3913 );
3914});
3915
06d5ffeClaude3916// View file blob with syntax highlighting
fc1817aClaude3917web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
3918 const { owner, repo } = c.req.param();
06d5ffeClaude3919 const user = c.get("user");
fc1817aClaude3920 const refAndPath = c.req.param("ref");
3921
3922 const branches = await listBranches(owner, repo);
3923 let ref = "";
3924 let filePath = "";
3925
3926 for (const branch of branches) {
3927 if (refAndPath.startsWith(branch + "/")) {
3928 ref = branch;
3929 filePath = refAndPath.slice(branch.length + 1);
3930 break;
3931 }
3932 }
3933
3934 if (!ref) {
3935 const slashIdx = refAndPath.indexOf("/");
3936 if (slashIdx === -1) return c.text("Not found", 404);
3937 ref = refAndPath.slice(0, slashIdx);
3938 filePath = refAndPath.slice(slashIdx + 1);
3939 }
3940
3941 const blob = await getBlob(owner, repo, ref, filePath);
3942 if (!blob) {
3943 return c.html(
06d5ffeClaude3944 <Layout title="Not Found" user={user}>
fc1817aClaude3945 <div class="empty-state">
3946 <h2>File not found</h2>
3947 </div>
3948 </Layout>,
3949 404
3950 );
3951 }
3952
06d5ffeClaude3953 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude3954 const lineCount = blob.isBinary
3955 ? 0
3956 : (blob.content.endsWith("\n")
3957 ? blob.content.split("\n").length - 1
3958 : blob.content.split("\n").length);
3959 const formatBytes = (n: number): string => {
3960 if (n < 1024) return `${n} B`;
3961 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
3962 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
3963 };
fc1817aClaude3964
3965 return c.html(
06d5ffeClaude3966 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude3967 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude3968 <RepoHeader owner={owner} repo={repo} />
3969 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude3970 <div class="blob-toolbar">
3971 <BranchSwitcher
3972 owner={owner}
3973 repo={repo}
3974 currentRef={ref}
3975 branches={branches}
3976 pathType="blob"
3977 subPath={filePath}
3978 />
3979 <div class="blob-breadcrumb">
3980 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
3981 </div>
3982 </div>
3983 <div class="blob-view blob-card">
3984 <div class="blob-header blob-header-polished">
3985 <div class="blob-header-meta">
3986 <span class="blob-header-icon" aria-hidden="true">
3987 {"📄"}
3988 </span>
3989 <span class="blob-header-name">{fileName}</span>
3990 <span class="blob-header-size">
3991 {formatBytes(blob.size)}
3992 {!blob.isBinary && (
3993 <>
3994 {" · "}
3995 {lineCount} line{lineCount === 1 ? "" : "s"}
3996 </>
3997 )}
3998 </span>
3999 </div>
4000 <div class="blob-header-actions">
4001 <a
4002 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4003 class="blob-pill"
4004 >
79136bbClaude4005 Raw
4006 </a>
efb11c5Claude4007 <a
4008 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4009 class="blob-pill"
4010 >
79136bbClaude4011 Blame
4012 </a>
efb11c5Claude4013 <a
4014 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4015 class="blob-pill"
4016 >
16b325cClaude4017 History
4018 </a>
0074234Claude4019 {user && (
efb11c5Claude4020 <a
4021 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4022 class="blob-pill blob-pill-accent"
4023 >
0074234Claude4024 Edit
4025 </a>
4026 )}
efb11c5Claude4027 </div>
fc1817aClaude4028 </div>
4029 {blob.isBinary ? (
efb11c5Claude4030 <div class="blob-binary">
fc1817aClaude4031 Binary file not shown.
4032 </div>
06d5ffeClaude4033 ) : (() => {
4034 const { html: highlighted, language } = highlightCode(
4035 blob.content,
4036 fileName
4037 );
4038 if (language) {
4039 return (
4040 <HighlightedCode
4041 highlightedHtml={highlighted}
efb11c5Claude4042 lineCount={lineCount}
06d5ffeClaude4043 />
4044 );
4045 }
4046 const lines = blob.content.split("\n");
4047 if (lines[lines.length - 1] === "") lines.pop();
4048 return <PlainCode lines={lines} />;
4049 })()}
fc1817aClaude4050 </div>
4051 </Layout>
4052 );
4053});
4054
398a10cClaude4055// ─── Branches list ────────────────────────────────────────────────────────
4056// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4057// author + relative time + ahead/behind vs the default branch. No DB. All
4058// data comes from git plumbing; failures degrade gracefully (counts omitted).
4059web.get("/:owner/:repo/branches", async (c) => {
4060 const { owner, repo } = c.req.param();
4061 const user = c.get("user");
4062
4063 if (!(await repoExists(owner, repo))) return c.notFound();
4064
4065 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4066 const branches = await listBranches(owner, repo);
4067 const repoDir = getRepoPath(owner, repo);
4068
4069 type BranchRow = {
4070 name: string;
4071 isDefault: boolean;
4072 sha: string;
4073 subject: string;
4074 author: string;
4075 date: string;
4076 ahead: number;
4077 behind: number;
4078 };
4079
4080 const runGit = async (args: string[]): Promise<string> => {
4081 try {
4082 const proc = Bun.spawn(["git", ...args], {
4083 cwd: repoDir,
4084 stdout: "pipe",
4085 stderr: "pipe",
4086 });
4087 const out = await new Response(proc.stdout).text();
4088 await proc.exited;
4089 return out;
4090 } catch {
4091 return "";
4092 }
4093 };
4094
4095 const meta = await runGit([
4096 "for-each-ref",
4097 "--sort=-committerdate",
4098 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4099 "refs/heads/",
4100 ]);
4101 const metaByName: Record<
4102 string,
4103 { sha: string; subject: string; author: string; date: string }
4104 > = {};
4105 for (const line of meta.split("\n").filter(Boolean)) {
4106 const [name, sha, subject, author, date] = line.split("\0");
4107 metaByName[name] = { sha, subject, author, date };
4108 }
4109
4110 const branchOrder = [...branches].sort((a, b) => {
4111 if (a === defaultBranch) return -1;
4112 if (b === defaultBranch) return 1;
4113 const aDate = metaByName[a]?.date || "";
4114 const bDate = metaByName[b]?.date || "";
4115 return bDate.localeCompare(aDate);
4116 });
4117
4118 const rows: BranchRow[] = [];
4119 for (const name of branchOrder) {
4120 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4121 let ahead = 0;
4122 let behind = 0;
4123 if (name !== defaultBranch && metaByName[defaultBranch]) {
4124 const out = await runGit([
4125 "rev-list",
4126 "--left-right",
4127 "--count",
4128 `${defaultBranch}...${name}`,
4129 ]);
4130 const parts = out.trim().split(/\s+/);
4131 if (parts.length === 2) {
4132 behind = parseInt(parts[0], 10) || 0;
4133 ahead = parseInt(parts[1], 10) || 0;
4134 }
4135 }
4136 rows.push({
4137 name,
4138 isDefault: name === defaultBranch,
4139 sha: m.sha,
4140 subject: m.subject,
4141 author: m.author,
4142 date: m.date,
4143 ahead,
4144 behind,
4145 });
4146 }
4147
4148 const relative = (iso: string): string => {
4149 if (!iso) return "—";
4150 const d = new Date(iso);
4151 if (Number.isNaN(d.getTime())) return "—";
4152 const diff = Date.now() - d.getTime();
4153 const m = Math.floor(diff / 60000);
4154 if (m < 1) return "just now";
4155 if (m < 60) return `${m} min ago`;
4156 const h = Math.floor(m / 60);
4157 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4158 const dd = Math.floor(h / 24);
4159 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4160 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4161 };
4162
4163 const success = c.req.query("success");
4164 const error = c.req.query("error");
4165
4166 return c.html(
4167 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4168 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4169 <RepoHeader owner={owner} repo={repo} />
4170 <RepoNav owner={owner} repo={repo} active="code" />
4171 <div
4172 class="branches-wrap"
eed4684Claude4173 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4174 >
4175 <header class="branches-head" style="margin-bottom:var(--space-5)">
4176 <div
4177 class="branches-eyebrow"
4178 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
4179 >
4180 <span
4181 class="branches-eyebrow-dot"
4182 aria-hidden="true"
4183 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4184 />
4185 Repository · Branches
4186 </div>
4187 <h1
4188 class="branches-title"
4189 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
4190 >
4191 <span class="gradient-text">{rows.length}</span>{" "}
4192 branch{rows.length === 1 ? "" : "es"}
4193 </h1>
4194 <p
4195 class="branches-sub"
4196 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4197 >
4198 All work-in-progress lines for{" "}
4199 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4200 counts are relative to{" "}
4201 <code style="font-size:12.5px">{defaultBranch}</code>.
4202 </p>
4203 </header>
4204
4205 {success && (
4206 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(52,211,153,0.40);background:rgba(52,211,153,0.08);color:#bbf7d0">
4207 {decodeURIComponent(success)}
4208 </div>
4209 )}
4210 {error && (
4211 <div style="margin-bottom:var(--space-4);padding:10px 14px;border-radius:10px;font-size:13.5px;border:1px solid rgba(248,113,113,0.40);background:rgba(248,113,113,0.08);color:#fecaca">
4212 {decodeURIComponent(error)}
4213 </div>
4214 )}
4215
4216 {rows.length === 0 ? (
4217 <div class="commits-empty">
4218 <div class="commits-empty-orb" aria-hidden="true" />
4219 <div class="commits-empty-inner">
4220 <div class="commits-empty-icon" aria-hidden="true">
4221 <svg
4222 width="22"
4223 height="22"
4224 viewBox="0 0 24 24"
4225 fill="none"
4226 stroke="currentColor"
4227 stroke-width="2"
4228 stroke-linecap="round"
4229 stroke-linejoin="round"
4230 >
4231 <line x1="6" y1="3" x2="6" y2="15" />
4232 <circle cx="18" cy="6" r="3" />
4233 <circle cx="6" cy="18" r="3" />
4234 <path d="M18 9a9 9 0 0 1-9 9" />
4235 </svg>
4236 </div>
4237 <h3 class="commits-empty-title">No branches yet</h3>
4238 <p class="commits-empty-sub">
4239 Push your first commit to create the default branch.
4240 </p>
4241 </div>
4242 </div>
4243 ) : (
4244 <div class="branches-list">
4245 {rows.map((r) => (
4246 <div class="branches-row">
4247 <div class="branches-row-icon" aria-hidden="true">
4248 <svg
4249 width="14"
4250 height="14"
4251 viewBox="0 0 24 24"
4252 fill="none"
4253 stroke="currentColor"
4254 stroke-width="2"
4255 stroke-linecap="round"
4256 stroke-linejoin="round"
4257 >
4258 <line x1="6" y1="3" x2="6" y2="15" />
4259 <circle cx="18" cy="6" r="3" />
4260 <circle cx="6" cy="18" r="3" />
4261 <path d="M18 9a9 9 0 0 1-9 9" />
4262 </svg>
4263 </div>
4264 <div class="branches-row-main">
4265 <div class="branches-row-name">
4266 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4267 {r.isDefault && (
4268 <span
4269 class="branches-row-default"
4270 title="Default branch"
4271 >
4272 Default
4273 </span>
4274 )}
4275 </div>
4276 <div class="branches-row-meta">
4277 {r.subject ? (
4278 <>
4279 <strong>{r.author || "—"}</strong>
4280 <span class="sep">·</span>
4281 <span
4282 title={r.date ? new Date(r.date).toISOString() : ""}
4283 >
4284 updated {relative(r.date)}
4285 </span>
4286 {r.sha && (
4287 <>
4288 <span class="sep">·</span>
4289 <a
4290 href={`/${owner}/${repo}/commit/${r.sha}`}
4291 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4292 >
4293 {r.sha.slice(0, 7)}
4294 </a>
4295 </>
4296 )}
4297 </>
4298 ) : (
4299 <span>No commit metadata</span>
4300 )}
4301 </div>
4302 </div>
4303 <div class="branches-row-side">
4304 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4305 <span
4306 class="branches-row-divergence"
4307 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4308 >
4309 <span class="ahead">{r.ahead} ahead</span>
4310 <span style="opacity:0.4">|</span>
4311 <span class="behind">{r.behind} behind</span>
4312 </span>
4313 )}
4314 <div class="branches-row-actions">
4315 <a
4316 href={`/${owner}/${repo}/commits/${r.name}`}
4317 class="branches-btn"
4318 title="View commits on this branch"
4319 >
4320 Commits
4321 </a>
4322 {!r.isDefault &&
4323 user &&
4324 user.username === owner && (
4325 <form
4326 method="post"
4327 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4328 style="margin:0"
4329 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4330 >
4331 <button
4332 type="submit"
4333 class="branches-btn branches-btn-danger"
4334 >
4335 Delete
4336 </button>
4337 </form>
4338 )}
4339 </div>
4340 </div>
4341 </div>
4342 ))}
4343 </div>
4344 )}
4345 </div>
4346 </Layout>
4347 );
4348});
4349
4350// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4351// that are not merged into the default branch — matches the explicit
4352// confirmation on the row's delete button.
4353web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4354 const { owner, repo } = c.req.param();
4355 const branchName = decodeURIComponent(c.req.param("name"));
4356 const user = c.get("user")!;
4357
4358 // Owner-only check (mirrors collaborators.tsx pattern).
4359 const [ownerRow] = await db
4360 .select()
4361 .from(users)
4362 .where(eq(users.username, owner))
4363 .limit(1);
4364 if (!ownerRow || ownerRow.id !== user.id) {
4365 return c.redirect(
4366 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4367 );
4368 }
4369
4370 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4371 if (branchName === defaultBranch) {
4372 return c.redirect(
4373 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4374 );
4375 }
4376
4377 const branches = await listBranches(owner, repo);
4378 if (!branches.includes(branchName)) {
4379 return c.redirect(
4380 `/${owner}/${repo}/branches?error=Branch+not+found`
4381 );
4382 }
4383
4384 try {
4385 const repoDir = getRepoPath(owner, repo);
4386 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4387 cwd: repoDir,
4388 stdout: "pipe",
4389 stderr: "pipe",
4390 });
4391 await proc.exited;
4392 if (proc.exitCode !== 0) {
4393 return c.redirect(
4394 `/${owner}/${repo}/branches?error=Delete+failed`
4395 );
4396 }
4397 } catch {
4398 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4399 }
4400
4401 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4402});
4403
4404// ─── Tags list ────────────────────────────────────────────────────────────
4405// Pulls from `listTags` (sorted newest first). For each tag we look up an
4406// associated release row so the "View release" CTA can deep-link directly
4407// without making the user hunt for it.
4408web.get("/:owner/:repo/tags", async (c) => {
4409 const { owner, repo } = c.req.param();
4410 const user = c.get("user");
4411
4412 if (!(await repoExists(owner, repo))) return c.notFound();
4413
4414 const tags = await listTags(owner, repo);
4415
4416 // Map tags -> releases. Best-effort; releases table may not exist in
4417 // every test setup, so any error falls through with an empty set.
4418 const tagsWithReleases = new Set<string>();
4419 try {
4420 const [ownerRow] = await db
4421 .select()
4422 .from(users)
4423 .where(eq(users.username, owner))
4424 .limit(1);
4425 if (ownerRow) {
4426 const [repoRow] = await db
4427 .select()
4428 .from(repositories)
4429 .where(
4430 and(
4431 eq(repositories.ownerId, ownerRow.id),
4432 eq(repositories.name, repo)
4433 )
4434 )
4435 .limit(1);
4436 if (repoRow) {
4437 // Raw SQL so we don't need to import the releases schema here.
4438 const result = await db.execute(
4439 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4440 );
4441 const rows: any[] = (result as any).rows || (result as any) || [];
4442 for (const row of rows) {
4443 const tag = row?.tag;
4444 if (typeof tag === "string") tagsWithReleases.add(tag);
4445 }
4446 }
4447 }
4448 } catch {
4449 // No releases table or DB error — leave set empty.
4450 }
4451
4452 const relative = (iso: string): string => {
4453 if (!iso) return "—";
4454 const d = new Date(iso);
4455 if (Number.isNaN(d.getTime())) return "—";
4456 const diff = Date.now() - d.getTime();
4457 const m = Math.floor(diff / 60000);
4458 if (m < 1) return "just now";
4459 if (m < 60) return `${m} min ago`;
4460 const h = Math.floor(m / 60);
4461 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4462 const dd = Math.floor(h / 24);
4463 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4464 return d.toLocaleDateString("en-US", {
4465 month: "short",
4466 day: "numeric",
4467 year: "numeric",
4468 });
4469 };
4470
4471 return c.html(
4472 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
4473 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4474 <RepoHeader owner={owner} repo={repo} />
4475 <RepoNav owner={owner} repo={repo} active="code" />
4476 <div
4477 class="tags-wrap"
eed4684Claude4478 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4479 >
4480 <header class="tags-head" style="margin-bottom:var(--space-5)">
4481 <div
4482 class="tags-eyebrow"
4483 style="display:inline-flex;align-items:center;gap:8px;text-transform:uppercase;font-family:var(--font-mono);font-size:11px;letter-spacing:0.16em;color:var(--text-muted);font-weight:600;margin-bottom:10px"
4484 >
4485 <span
4486 class="tags-eyebrow-dot"
4487 aria-hidden="true"
4488 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#8c6dff,#36c5d6);box-shadow:0 0 0 3px rgba(140,109,255,0.18)"
4489 />
4490 Repository · Tags
4491 </div>
4492 <h1
4493 class="tags-title"
4494 style="font-family:var(--font-display);font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:-0.028em;line-height:1.1;margin:0 0 6px;color:var(--text-strong)"
4495 >
4496 <span class="gradient-text">{tags.length}</span>{" "}
4497 tag{tags.length === 1 ? "" : "s"}
4498 </h1>
4499 <p
4500 class="tags-sub"
4501 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4502 >
4503 Named points in the history — typically releases, milestones,
4504 or shipped versions. Click a tag to browse the tree at that
4505 revision.
4506 </p>
4507 </header>
4508
4509 {tags.length === 0 ? (
4510 <div class="commits-empty">
4511 <div class="commits-empty-orb" aria-hidden="true" />
4512 <div class="commits-empty-inner">
4513 <div class="commits-empty-icon" aria-hidden="true">
4514 <svg
4515 width="22"
4516 height="22"
4517 viewBox="0 0 24 24"
4518 fill="none"
4519 stroke="currentColor"
4520 stroke-width="2"
4521 stroke-linecap="round"
4522 stroke-linejoin="round"
4523 >
4524 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
4525 <line x1="7" y1="7" x2="7.01" y2="7" />
4526 </svg>
4527 </div>
4528 <h3 class="commits-empty-title">No tags yet</h3>
4529 <p class="commits-empty-sub">
4530 Tag a commit to mark a release or milestone. From the CLI:{" "}
4531 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
4532 </p>
4533 </div>
4534 </div>
4535 ) : (
4536 <div class="tags-list">
4537 {tags.map((t) => {
4538 const hasRelease = tagsWithReleases.has(t.name);
4539 return (
4540 <div class="tags-row">
4541 <div class="tags-row-icon" aria-hidden="true">
4542 <svg
4543 width="14"
4544 height="14"
4545 viewBox="0 0 24 24"
4546 fill="none"
4547 stroke="currentColor"
4548 stroke-width="2"
4549 stroke-linecap="round"
4550 stroke-linejoin="round"
4551 >
4552 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
4553 <line x1="7" y1="7" x2="7.01" y2="7" />
4554 </svg>
4555 </div>
4556 <div class="tags-row-main">
4557 <div class="tags-row-name">
4558 <a
4559 href={`/${owner}/${repo}/tree/${t.name}`}
4560 style="text-decoration:none"
4561 >
4562 <span class="tags-row-version">{t.name}</span>
4563 </a>
4564 </div>
4565 <div class="tags-row-meta">
4566 <span
4567 title={t.date ? new Date(t.date).toISOString() : ""}
4568 >
4569 Tagged {relative(t.date)}
4570 </span>
4571 <span class="sep">·</span>
4572 <a
4573 href={`/${owner}/${repo}/commit/${t.sha}`}
4574 class="tags-row-sha"
4575 title={t.sha}
4576 >
4577 {t.sha.slice(0, 7)}
4578 </a>
4579 </div>
4580 </div>
4581 <div class="tags-row-side">
4582 <a
4583 href={`/${owner}/${repo}/tree/${t.name}`}
4584 class="tags-row-link"
4585 >
4586 Browse files
4587 </a>
4588 {hasRelease && (
4589 <a
4590 href={`/${owner}/${repo}/releases/tag/${t.name}`}
4591 class="tags-row-link"
4592 style="color:#67e8f9;border-color:rgba(54,197,214,0.35);background:rgba(54,197,214,0.06)"
4593 >
4594 View release
4595 </a>
4596 )}
4597 </div>
4598 </div>
4599 );
4600 })}
4601 </div>
4602 )}
4603 </div>
4604 </Layout>
4605 );
4606});
4607
fc1817aClaude4608// Commit log
4609web.get("/:owner/:repo/commits/:ref?", async (c) => {
4610 const { owner, repo } = c.req.param();
06d5ffeClaude4611 const user = c.get("user");
fc1817aClaude4612 const ref =
4613 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude4614 const branches = await listBranches(owner, repo);
fc1817aClaude4615
4616 const commits = await listCommits(owner, repo, ref, 50);
4617
3951454Claude4618 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude4619 // Also resolve repoId here so we can query the recent push for the
4620 // Push Watch live/watch indicator in the header.
3951454Claude4621 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude4622 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude4623 try {
4624 const [ownerRow] = await db
4625 .select()
4626 .from(users)
4627 .where(eq(users.username, owner))
4628 .limit(1);
4629 if (ownerRow) {
4630 const [repoRow] = await db
4631 .select()
4632 .from(repositories)
4633 .where(
4634 and(
4635 eq(repositories.ownerId, ownerRow.id),
4636 eq(repositories.name, repo)
4637 )
4638 )
4639 .limit(1);
8c790e0Claude4640 if (repoRow) {
4641 // Fetch verifications and recent push in parallel.
4642 const [verRows, rp] = await Promise.all([
4643 commits.length > 0
4644 ? db
4645 .select()
4646 .from(commitVerifications)
4647 .where(
4648 and(
4649 eq(commitVerifications.repositoryId, repoRow.id),
4650 inArray(
4651 commitVerifications.commitSha,
4652 commits.map((c) => c.sha)
4653 )
4654 )
4655 )
4656 : Promise.resolve([]),
4657 getRecentPush(repoRow.id),
4658 ]);
4659 for (const r of verRows) {
3951454Claude4660 verifications[r.commitSha] = {
4661 verified: r.verified,
4662 reason: r.reason,
4663 };
4664 }
8c790e0Claude4665 commitsPageRecentPush = rp;
3951454Claude4666 }
4667 }
4668 } catch {
4669 // DB unavailable — skip the badges gracefully.
4670 }
4671
fc1817aClaude4672 return c.html(
06d5ffeClaude4673 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude4674 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude4675 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude4676 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude4677 <div class="commits-hero">
4678 <div class="commits-hero-orb-wrap" aria-hidden="true">
4679 <div class="commits-hero-orb" />
fc1817aClaude4680 </div>
efb11c5Claude4681 <div class="commits-hero-inner">
4682 <div class="commits-eyebrow">
4683 <strong>History</strong> · {owner}/{repo}
4684 </div>
4685 <h1 class="commits-title">
4686 <span class="gradient-text">{commits.length}</span> recent commit
4687 {commits.length === 1 ? "" : "s"} on{" "}
4688 <span class="commits-branch">{ref}</span>
4689 </h1>
4690 <p class="commits-sub">
4691 Browse the project's history. Click any commit to see the full
4692 diff, AI review notes, and signature status.
4693 </p>
4694 </div>
4695 </div>
4696 <div class="commits-toolbar">
4697 <BranchSwitcher
3951454Claude4698 owner={owner}
4699 repo={repo}
efb11c5Claude4700 currentRef={ref}
4701 branches={branches}
4702 pathType="commits"
3951454Claude4703 />
398a10cClaude4704 <div class="commits-toolbar-actions">
4705 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
4706 {"⊢"} Branches
4707 </a>
4708 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
4709 {"#"} Tags
4710 </a>
4711 </div>
efb11c5Claude4712 </div>
4713 {commits.length === 0 ? (
4714 <div class="commits-empty">
398a10cClaude4715 <div class="commits-empty-orb" aria-hidden="true" />
4716 <div class="commits-empty-inner">
4717 <div class="commits-empty-icon" aria-hidden="true">
4718 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
4719 <circle cx="12" cy="12" r="4" />
4720 <line x1="1.05" y1="12" x2="7" y2="12" />
4721 <line x1="17.01" y1="12" x2="22.96" y2="12" />
4722 </svg>
4723 </div>
4724 <h3 class="commits-empty-title">No commits yet</h3>
4725 <p class="commits-empty-sub">
4726 This branch is empty. Push your first commit, or use the
4727 web editor to create a file.
4728 </p>
4729 </div>
efb11c5Claude4730 </div>
4731 ) : (
4732 <div class="commits-list-wrap">
398a10cClaude4733 {(() => {
4734 // Group commits by day for the section headers.
4735 const dayLabel = (iso: string): string => {
4736 const d = new Date(iso);
4737 if (Number.isNaN(d.getTime())) return "Unknown";
4738 const today = new Date();
4739 const yesterday = new Date(today);
4740 yesterday.setDate(today.getDate() - 1);
4741 const sameDay = (a: Date, b: Date) =>
4742 a.getFullYear() === b.getFullYear() &&
4743 a.getMonth() === b.getMonth() &&
4744 a.getDate() === b.getDate();
4745 if (sameDay(d, today)) return "Today";
4746 if (sameDay(d, yesterday)) return "Yesterday";
4747 return d.toLocaleDateString("en-US", {
4748 month: "long",
4749 day: "numeric",
4750 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
4751 });
4752 };
4753 const relative = (iso: string): string => {
4754 const d = new Date(iso);
4755 if (Number.isNaN(d.getTime())) return "";
4756 const diff = Date.now() - d.getTime();
4757 const m = Math.floor(diff / 60000);
4758 if (m < 1) return "just now";
4759 if (m < 60) return `${m} min ago`;
4760 const h = Math.floor(m / 60);
4761 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4762 const dd = Math.floor(h / 24);
4763 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4764 return d.toLocaleDateString("en-US", {
4765 month: "short",
4766 day: "numeric",
4767 });
4768 };
4769 const initial = (name: string): string =>
4770 (name || "?").trim().charAt(0).toUpperCase() || "?";
4771 // Build groups preserving order.
4772 const groups: Array<{ label: string; items: typeof commits }> = [];
4773 let lastLabel = "";
4774 for (const cm of commits) {
4775 const label = dayLabel(cm.date);
4776 if (label !== lastLabel) {
4777 groups.push({ label, items: [] });
4778 lastLabel = label;
4779 }
4780 groups[groups.length - 1].items.push(cm);
4781 }
4782 return groups.map((g) => (
4783 <>
4784 <div class="commits-day-head">
4785 <span class="commits-day-head-dot" aria-hidden="true" />
4786 Commits on {g.label}
4787 </div>
4788 {g.items.map((cm) => {
4789 const v = verifications[cm.sha];
4790 return (
4791 <div class="commits-row">
4792 <div class="commits-avatar" aria-hidden="true">
4793 {initial(cm.author)}
4794 </div>
4795 <div class="commits-row-body">
4796 <div class="commits-row-msg">
4797 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
4798 {cm.message}
4799 </a>
4800 {v?.verified && (
4801 <span
4802 class="commits-row-verified"
4803 title="Signed with a registered key"
4804 >
4805 Verified
4806 </span>
4807 )}
4808 </div>
4809 <div class="commits-row-meta">
4810 <strong>{cm.author}</strong>
4811 <span class="sep">·</span>
4812 <span
4813 class="commits-row-time"
4814 title={new Date(cm.date).toISOString()}
4815 >
4816 committed {relative(cm.date)}
4817 </span>
4818 {cm.parentShas.length > 1 && (
4819 <>
4820 <span class="sep">·</span>
4821 <span>merge of {cm.parentShas.length} parents</span>
4822 </>
4823 )}
4824 </div>
4825 </div>
4826 <div class="commits-row-side">
4827 <a
4828 href={`/${owner}/${repo}/commit/${cm.sha}`}
4829 class="commits-row-sha"
4830 title={cm.sha}
4831 >
4832 {cm.sha.slice(0, 7)}
4833 </a>
8c790e0Claude4834 <a
4835 href={`/${owner}/${repo}/push/${cm.sha}`}
4836 class="commits-row-watch"
4837 title="Watch gate + deploy results for this push"
4838 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
4839 >
4840 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4841 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
4842 <circle cx="12" cy="12" r="3" />
4843 </svg>
4844 </a>
398a10cClaude4845 <button
4846 type="button"
4847 class="commits-row-copy"
4848 data-copy-sha={cm.sha}
4849 title="Copy full SHA"
4850 aria-label={`Copy SHA ${cm.sha}`}
4851 >
4852 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
4853 <path fill="currentColor" d="M4 1.5h6A1.5 1.5 0 0 1 11.5 3v1h-1V3a.5.5 0 0 0-.5-.5H4a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h1v1H4A1.5 1.5 0 0 1 2.5 11V3A1.5 1.5 0 0 1 4 1.5Z" />
4854 <path fill="currentColor" d="M6 5.5h6A1.5 1.5 0 0 1 13.5 7v6A1.5 1.5 0 0 1 12 14.5H6A1.5 1.5 0 0 1 4.5 13V7A1.5 1.5 0 0 1 6 5.5Zm0 1A.5.5 0 0 0 5.5 7v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V7a.5.5 0 0 0-.5-.5H6Z" />
4855 </svg>
4856 </button>
4857 </div>
4858 </div>
4859 );
4860 })}
4861 </>
4862 ));
4863 })()}
efb11c5Claude4864 </div>
fc1817aClaude4865 )}
398a10cClaude4866 <script
4867 dangerouslySetInnerHTML={{
4868 __html: `
4869 (function(){
4870 document.addEventListener('click', function(e){
4871 var t = e.target; if (!t) return;
4872 var btn = t.closest && t.closest('[data-copy-sha]');
4873 if (!btn) return;
4874 e.preventDefault();
4875 var sha = btn.getAttribute('data-copy-sha') || '';
4876 if (!navigator.clipboard) return;
4877 navigator.clipboard.writeText(sha).then(function(){
4878 btn.classList.add('is-copied');
4879 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
4880 }).catch(function(){});
4881 });
4882 })();
4883 `,
4884 }}
4885 />
fc1817aClaude4886 </Layout>
4887 );
4888});
4889
4890// Single commit with diff
4891web.get("/:owner/:repo/commit/:sha", async (c) => {
4892 const { owner, repo, sha } = c.req.param();
06d5ffeClaude4893 const user = c.get("user");
fc1817aClaude4894
05b973eClaude4895 // Fetch commit, full message, and diff in parallel
4896 const [commit, fullMessage, diffResult] = await Promise.all([
4897 getCommit(owner, repo, sha),
4898 getCommitFullMessage(owner, repo, sha),
4899 getDiff(owner, repo, sha),
4900 ]);
fc1817aClaude4901 if (!commit) {
4902 return c.html(
06d5ffeClaude4903 <Layout title="Not Found" user={user}>
fc1817aClaude4904 <div class="empty-state">
4905 <h2>Commit not found</h2>
4906 </div>
4907 </Layout>,
4908 404
4909 );
4910 }
4911
3951454Claude4912 // Block J3 — try to verify this commit's signature.
4913 let verification:
4914 | { verified: boolean; reason: string; signatureType: string | null }
4915 | null = null;
0cdfd89Claude4916 // Block J8 — external CI commit statuses rollup.
4917 let statusCombined:
4918 | {
4919 state: "pending" | "success" | "failure";
4920 total: number;
4921 contexts: Array<{
4922 context: string;
4923 state: string;
4924 description: string | null;
4925 targetUrl: string | null;
4926 }>;
4927 }
4928 | null = null;
3951454Claude4929 try {
4930 const [ownerRow] = await db
4931 .select()
4932 .from(users)
4933 .where(eq(users.username, owner))
4934 .limit(1);
4935 if (ownerRow) {
4936 const [repoRow] = await db
4937 .select()
4938 .from(repositories)
4939 .where(
4940 and(
4941 eq(repositories.ownerId, ownerRow.id),
4942 eq(repositories.name, repo)
4943 )
4944 )
4945 .limit(1);
4946 if (repoRow) {
4947 const { verifyCommit } = await import("../lib/signatures");
4948 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
4949 verification = {
4950 verified: v.verified,
4951 reason: v.reason,
4952 signatureType: v.signatureType,
4953 };
0cdfd89Claude4954 try {
4955 const { combinedStatus } = await import("../lib/commit-statuses");
4956 const combined = await combinedStatus(repoRow.id, commit.sha);
4957 if (combined.total > 0) {
4958 statusCombined = {
4959 state: combined.state as any,
4960 total: combined.total,
4961 contexts: combined.contexts.map((c) => ({
4962 context: c.context,
4963 state: c.state,
4964 description: c.description,
4965 targetUrl: c.targetUrl,
4966 })),
4967 };
4968 }
4969 } catch {
4970 statusCombined = null;
4971 }
3951454Claude4972 }
4973 }
4974 } catch {
4975 verification = null;
4976 }
4977
05b973eClaude4978 const { files, raw } = diffResult;
fc1817aClaude4979
efb11c5Claude4980 // Diff stats: count additions / deletions across all files for the
4981 // header summary bar. Computed here from the parsed diff so we don't
4982 // touch the DiffView component.
4983 let additions = 0;
4984 let deletions = 0;
4985 for (const f of files) {
4986 const hunks = (f as any).hunks as Array<any> | undefined;
4987 if (Array.isArray(hunks)) {
4988 for (const h of hunks) {
4989 const lines = (h?.lines || []) as Array<any>;
4990 for (const ln of lines) {
4991 const t = ln?.type || ln?.kind;
4992 if (t === "add" || t === "added" || t === "+") additions += 1;
4993 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
4994 deletions += 1;
4995 }
4996 }
4997 }
4998 }
4999 // Fall back: scan raw if file-level counting yielded zero (it's just a
5000 // header polish — never let a parsing miss break the page).
5001 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5002 for (const line of raw.split("\n")) {
5003 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5004 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5005 }
5006 }
5007 const fileCount = files.length;
5008
fc1817aClaude5009 return c.html(
06d5ffeClaude5010 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5011 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5012 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5013 <div class="commit-detail-card">
5014 <div class="commit-detail-eyebrow">
5015 <strong>Commit</strong>
5016 <span class="commit-detail-sha-pill" title={commit.sha}>
5017 {commit.sha.slice(0, 7)}
5018 </span>
3951454Claude5019 {verification && verification.reason !== "unsigned" && (
5020 <span
efb11c5Claude5021 class={`commit-detail-verify ${
3951454Claude5022 verification.verified
efb11c5Claude5023 ? "commit-detail-verify-ok"
5024 : "commit-detail-verify-warn"
3951454Claude5025 }`}
5026 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5027 >
5028 {verification.verified ? "Verified" : verification.reason}
5029 </span>
5030 )}
fc1817aClaude5031 </div>
efb11c5Claude5032 <h1 class="commit-detail-title">{commit.message}</h1>
5033 {fullMessage !== commit.message && (
5034 <pre class="commit-detail-body">{fullMessage}</pre>
5035 )}
5036 <div class="commit-detail-meta">
5037 <span class="commit-detail-author">
5038 <strong>{commit.author}</strong> committed on{" "}
5039 {new Date(commit.date).toLocaleDateString("en-US", {
5040 month: "long",
5041 day: "numeric",
5042 year: "numeric",
5043 })}
5044 </span>
fc1817aClaude5045 {commit.parentShas.length > 0 && (
efb11c5Claude5046 <span class="commit-detail-parents">
5047 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5048 {commit.parentShas.map((p, idx) => (
5049 <>
5050 {idx > 0 && " "}
5051 <a
5052 href={`/${owner}/${repo}/commit/${p}`}
5053 class="commit-detail-sha-link"
5054 >
5055 {p.slice(0, 7)}
5056 </a>
5057 </>
fc1817aClaude5058 ))}
5059 </span>
5060 )}
5061 </div>
efb11c5Claude5062 <div class="commit-detail-stats">
5063 <span class="commit-detail-stat">
5064 <strong>{fileCount}</strong>{" "}
5065 file{fileCount === 1 ? "" : "s"} changed
5066 </span>
5067 <span class="commit-detail-stat commit-detail-stat-add">
5068 <span class="commit-detail-stat-mark">+</span>
5069 <strong>{additions}</strong>
5070 </span>
5071 <span class="commit-detail-stat commit-detail-stat-del">
5072 <span class="commit-detail-stat-mark">−</span>
5073 <strong>{deletions}</strong>
5074 </span>
5075 <span class="commit-detail-sha-full" title="Full SHA">
5076 {commit.sha}
5077 </span>
5078 </div>
0cdfd89Claude5079 {statusCombined && (
efb11c5Claude5080 <div class="commit-detail-checks">
5081 <div class="commit-detail-checks-head">
5082 <strong>Checks</strong>
5083 <span class="commit-detail-checks-summary">
5084 {statusCombined.total} total ·{" "}
5085 <span
5086 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5087 >
5088 {statusCombined.state}
5089 </span>
0cdfd89Claude5090 </span>
efb11c5Claude5091 </div>
5092 <div class="commit-detail-check-row">
0cdfd89Claude5093 {statusCombined.contexts.map((cx) => (
5094 <span
efb11c5Claude5095 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5096 title={cx.description || cx.context}
5097 >
5098 {cx.targetUrl ? (
efb11c5Claude5099 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5100 {cx.context}: {cx.state}
5101 </a>
5102 ) : (
5103 <>
5104 {cx.context}: {cx.state}
5105 </>
5106 )}
5107 </span>
5108 ))}
5109 </div>
5110 </div>
5111 )}
fc1817aClaude5112 </div>
ea9ed4cClaude5113 <DiffView
5114 raw={raw}
5115 files={files}
5116 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5117 />
fc1817aClaude5118 </Layout>
5119 );
5120});
5121
79136bbClaude5122// Raw file download
5123web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5124 const { owner, repo } = c.req.param();
5125 const refAndPath = c.req.param("ref");
5126
5127 const branches = await listBranches(owner, repo);
5128 let ref = "";
5129 let filePath = "";
5130
5131 for (const branch of branches) {
5132 if (refAndPath.startsWith(branch + "/")) {
5133 ref = branch;
5134 filePath = refAndPath.slice(branch.length + 1);
5135 break;
5136 }
5137 }
5138
5139 if (!ref) {
5140 const slashIdx = refAndPath.indexOf("/");
5141 if (slashIdx === -1) return c.text("Not found", 404);
5142 ref = refAndPath.slice(0, slashIdx);
5143 filePath = refAndPath.slice(slashIdx + 1);
5144 }
5145
5146 const data = await getRawBlob(owner, repo, ref, filePath);
5147 if (!data) return c.text("Not found", 404);
5148
5149 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5150 return new Response(data as BodyInit, {
79136bbClaude5151 headers: {
5152 "Content-Type": "application/octet-stream",
5153 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5154 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5155 },
5156 });
5157});
5158
5159// Blame view
5160web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5161 const { owner, repo } = c.req.param();
5162 const user = c.get("user");
5163 const refAndPath = c.req.param("ref");
5164
5165 const branches = await listBranches(owner, repo);
5166 let ref = "";
5167 let filePath = "";
5168
5169 for (const branch of branches) {
5170 if (refAndPath.startsWith(branch + "/")) {
5171 ref = branch;
5172 filePath = refAndPath.slice(branch.length + 1);
5173 break;
5174 }
5175 }
5176
5177 if (!ref) {
5178 const slashIdx = refAndPath.indexOf("/");
5179 if (slashIdx === -1) return c.text("Not found", 404);
5180 ref = refAndPath.slice(0, slashIdx);
5181 filePath = refAndPath.slice(slashIdx + 1);
5182 }
5183
5184 const blameLines = await getBlame(owner, repo, ref, filePath);
5185 if (blameLines.length === 0) {
5186 return c.html(
5187 <Layout title="Not Found" user={user}>
5188 <div class="empty-state">
5189 <h2>File not found</h2>
5190 </div>
5191 </Layout>,
5192 404
5193 );
5194 }
5195
5196 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5197 // Unique contributors (by author) tracked once for the header chip.
5198 const blameAuthors = new Set<string>();
5199 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5200
5201 return c.html(
5202 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5203 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5204 <RepoHeader owner={owner} repo={repo} />
5205 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5206 <header class="blame-head">
5207 <div class="blame-eyebrow">
5208 <span class="blame-eyebrow-dot" aria-hidden="true" />
5209 Blame · Line-by-line history
5210 </div>
5211 <h1 class="blame-title">
5212 <code>{fileName}</code>
5213 </h1>
5214 <p class="blame-sub">
5215 Each line is annotated with the commit that last touched it.
5216 Click any SHA to jump to that commit and see the surrounding
5217 change.
5218 </p>
5219 </header>
efb11c5Claude5220 <div class="blame-toolbar">
5221 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5222 </div>
398a10cClaude5223 <div class="blame-card">
5224 <div class="blame-header">
efb11c5Claude5225 <div class="blame-header-meta">
5226 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5227 <span class="blame-header-name">{fileName}</span>
5228 <span class="blame-header-tag">Blame</span>
5229 <span class="blame-header-stats">
5230 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5231 {blameAuthors.size} contributor
5232 {blameAuthors.size === 1 ? "" : "s"}
5233 </span>
5234 </div>
5235 <div class="blame-header-actions">
5236 <a
5237 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5238 class="blob-pill"
5239 >
5240 Normal view
5241 </a>
5242 <a
5243 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5244 class="blob-pill"
5245 >
5246 Raw
5247 </a>
5248 </div>
79136bbClaude5249 </div>
398a10cClaude5250 <div style="overflow-x:auto">
5251 <table class="blame-table">
79136bbClaude5252 <tbody>
5253 {blameLines.map((line, i) => {
5254 const showInfo =
5255 i === 0 || blameLines[i - 1].sha !== line.sha;
5256 return (
398a10cClaude5257 <tr class={showInfo ? "blame-row-first" : ""}>
5258 <td class="blame-gutter">
79136bbClaude5259 {showInfo && (
398a10cClaude5260 <span class="blame-gutter-inner">
79136bbClaude5261 <a
5262 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5263 class="blame-gutter-sha"
5264 title={`Commit ${line.sha}`}
79136bbClaude5265 >
5266 {line.sha.slice(0, 7)}
398a10cClaude5267 </a>
5268 <span class="blame-gutter-author" title={line.author}>
5269 {line.author}
5270 </span>
5271 </span>
79136bbClaude5272 )}
5273 </td>
398a10cClaude5274 <td class="blame-line-num">{line.lineNum}</td>
5275 <td class="blame-line-content">{line.content}</td>
79136bbClaude5276 </tr>
5277 );
5278 })}
5279 </tbody>
5280 </table>
5281 </div>
5282 </div>
5283 </Layout>
5284 );
5285});
5286
53c9249Claude5287// Search — keyword + optional Claude semantic mode
79136bbClaude5288web.get("/:owner/:repo/search", async (c) => {
5289 const { owner, repo } = c.req.param();
5290 const user = c.get("user");
5291 const q = c.req.query("q") || "";
53c9249Claude5292 const aiAvailable = isAiAvailable();
5293 // Default to semantic when Claude is available and no explicit mode set.
5294 const modeParam = c.req.query("mode");
5295 const mode: "semantic" | "keyword" =
5296 modeParam === "keyword"
5297 ? "keyword"
5298 : modeParam === "semantic"
5299 ? "semantic"
5300 : aiAvailable
5301 ? "semantic"
5302 : "keyword";
5303 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5304
5305 if (!(await repoExists(owner, repo))) return c.notFound();
5306
5307 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5308
5309 // Keyword results (always available as fallback / when in keyword mode)
5310 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5311 // Semantic results (Claude-powered)
5312 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5313 let semanticHits: SemanticHit[] = [];
5314 let semanticMode: "semantic" | "keyword" = "semantic";
5315 let quotaExceeded = false;
79136bbClaude5316
5317 if (q.trim()) {
53c9249Claude5318 if (isSemantic) {
5319 // Resolve repo DB id for caching
5320 const [ownerRow] = await db
5321 .select({ id: users.id })
5322 .from(users)
5323 .where(eq(users.username, owner))
5324 .limit(1);
5325 const [repoRow] = ownerRow
5326 ? await db
5327 .select({ id: repositories.id })
5328 .from(repositories)
5329 .where(
5330 and(
5331 eq(repositories.ownerId, ownerRow.id),
5332 eq(repositories.name, repo)
5333 )
5334 )
5335 .limit(1)
5336 : [];
5337
5338 const rateLimitKey = user
5339 ? `user:${user.id}`
5340 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5341
5342 const searchResult = await claudeSemanticSearch(
5343 owner,
5344 repo,
5345 repoRow?.id ?? `${owner}/${repo}`,
5346 q.trim(),
5347 { branch: defaultBranch, rateLimitKey }
5348 );
5349 semanticHits = searchResult.results;
5350 semanticMode = searchResult.mode;
5351 quotaExceeded = searchResult.quotaExceeded;
5352 } else {
5353 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5354 }
79136bbClaude5355 }
5356
53c9249Claude5357 const aiSearchCss = `
5358 /* ─── Semantic search mode toggle ─── */
5359 .search-mode-bar {
5360 display: flex;
5361 align-items: center;
5362 gap: 8px;
5363 margin-bottom: 14px;
5364 flex-wrap: wrap;
5365 }
5366 .search-mode-label {
5367 font-size: 12.5px;
5368 color: var(--text-muted);
5369 font-weight: 500;
5370 }
5371 .search-mode-toggle {
5372 display: inline-flex;
5373 background: var(--bg-elevated);
5374 border: 1px solid var(--border);
5375 border-radius: 9999px;
5376 padding: 3px;
5377 gap: 2px;
5378 }
5379 .search-mode-btn {
5380 display: inline-flex;
5381 align-items: center;
5382 gap: 5px;
5383 padding: 5px 12px;
5384 border-radius: 9999px;
5385 font-size: 12.5px;
5386 font-weight: 500;
5387 color: var(--text-muted);
5388 text-decoration: none;
5389 transition: color 120ms ease, background 120ms ease;
5390 cursor: pointer;
5391 border: none;
5392 background: none;
5393 font: inherit;
5394 }
5395 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5396 .search-mode-btn.active {
5397 background: rgba(140,109,255,0.15);
5398 color: var(--text-strong);
5399 }
5400 .search-mode-ai-badge {
5401 display: inline-flex;
5402 align-items: center;
5403 gap: 4px;
5404 padding: 2px 8px;
5405 border-radius: 9999px;
5406 font-size: 11px;
5407 font-weight: 600;
5408 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.15));
5409 color: #c4b5fd;
5410 border: 1px solid rgba(140,109,255,0.30);
5411 margin-left: 2px;
5412 }
5413 .search-quota-warn {
5414 font-size: 12.5px;
5415 color: var(--text-muted);
5416 padding: 6px 12px;
5417 background: rgba(255,200,0,0.06);
5418 border: 1px solid rgba(255,200,0,0.20);
5419 border-radius: 8px;
5420 }
5421
5422 /* ─── Semantic result cards ─── */
5423 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5424 .sem-result {
5425 padding: 14px 16px;
5426 background: var(--bg-elevated);
5427 border: 1px solid var(--border);
5428 border-radius: 12px;
5429 transition: border-color 120ms ease;
5430 }
5431 .sem-result:hover { border-color: var(--border-strong); }
5432 .sem-result-head {
5433 display: flex;
5434 align-items: flex-start;
5435 justify-content: space-between;
5436 gap: 10px;
5437 flex-wrap: wrap;
5438 margin-bottom: 6px;
5439 }
5440 .sem-result-path {
5441 font-family: var(--font-mono);
5442 font-size: 13px;
5443 font-weight: 600;
5444 color: var(--text-strong);
5445 text-decoration: none;
5446 word-break: break-all;
5447 }
5448 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
5449 .sem-result-conf {
5450 display: inline-flex;
5451 align-items: center;
5452 gap: 4px;
5453 padding: 2px 9px;
5454 border-radius: 9999px;
5455 font-size: 11.5px;
5456 font-weight: 600;
5457 white-space: nowrap;
5458 flex-shrink: 0;
5459 }
5460 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
5461 .sem-conf-possible { background: rgba(140,109,255,0.12); color: #b69dff; border: 1px solid rgba(140,109,255,0.30); }
5462 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
5463 .sem-result-reason {
5464 font-size: 12.5px;
5465 color: var(--text-muted);
5466 margin-bottom: 8px;
5467 line-height: 1.5;
5468 }
5469 .sem-result-snippet {
5470 margin: 0;
5471 padding: 10px 12px;
5472 background: rgba(0,0,0,0.22);
5473 border: 1px solid var(--border);
5474 border-radius: 8px;
5475 font-family: var(--font-mono);
5476 font-size: 12px;
5477 line-height: 1.55;
5478 color: var(--text);
5479 overflow-x: auto;
5480 white-space: pre-wrap;
5481 word-break: break-word;
5482 max-height: 240px;
5483 overflow-y: auto;
5484 }
5485 `;
5486
5487 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
5488 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
5489
79136bbClaude5490 return c.html(
5491 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude5492 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude5493 <RepoHeader owner={owner} repo={repo} />
5494 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude5495 <div class="search-hero">
5496 <div class="search-eyebrow">
5497 <strong>Search</strong> · {owner}/{repo}
5498 </div>
5499 <h1 class="search-title">
53c9249Claude5500 {isSemantic ? (
5501 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
5502 ) : (
5503 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
5504 )}
efb11c5Claude5505 </h1>
5506 <form
5507 method="get"
5508 action={`/${owner}/${repo}/search`}
5509 class="search-form"
5510 role="search"
5511 >
53c9249Claude5512 <input type="hidden" name="mode" value={mode} />
efb11c5Claude5513 <div class="search-input-wrap">
5514 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
5515 <input
5516 type="text"
5517 name="q"
5518 value={q}
53c9249Claude5519 placeholder={
5520 isSemantic
5521 ? "Ask a question or describe what you're looking for…"
5522 : "Search code on the default branch…"
5523 }
efb11c5Claude5524 aria-label="Search code"
5525 class="search-input"
5526 autocomplete="off"
5527 autofocus
5528 />
5529 </div>
5530 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude5531 Search
5532 </button>
efb11c5Claude5533 </form>
5534 </div>
53c9249Claude5535
5536 {/* Mode toggle */}
5537 <div class="search-mode-bar">
5538 <span class="search-mode-label">Mode:</span>
5539 <div class="search-mode-toggle" role="group" aria-label="Search mode">
5540 {aiAvailable ? (
5541 <a
5542 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
5543 class={`search-mode-btn${isSemantic ? " active" : ""}`}
5544 aria-pressed={isSemantic ? "true" : "false"}
5545 >
5546 {"✨"} Semantic AI
5547 <span class="search-mode-ai-badge">AI-powered</span>
5548 </a>
5549 ) : null}
5550 <a
5551 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
5552 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
5553 aria-pressed={!isSemantic ? "true" : "false"}
5554 >
5555 {"⌕"} Keyword
5556 </a>
5557 </div>
5558 {isSemantic && aiAvailable && (
5559 <span style="font-size:12px;color:var(--text-muted)">
5560 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude5561 </span>
53c9249Claude5562 )}
5563 {quotaExceeded && (
5564 <span class="search-quota-warn">
5565 Semantic search quota reached — showing keyword results
efb11c5Claude5566 </span>
53c9249Claude5567 )}
5568 </div>
5569
5570 {/* ─── Semantic results ─── */}
5571 {isSemantic && q && (
5572 <>
5573 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
5574 <div class="search-quota-warn" style="margin-bottom:10px">
5575 Falling back to keyword search — Claude found no relevant files.
5576 </div>
5577 )}
5578 {semanticHits.length === 0 ? (
5579 <div class="search-empty">
5580 <p>
5581 No matches for <strong>"{q}"</strong>.{" "}
5582 Try a different phrasing or{" "}
5583 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
5584 switch to keyword search
5585 </a>.
5586 </p>
5587 </div>
5588 ) : (
5589 <>
5590 <div class="search-results-head">
5591 <span class="search-results-count">
5592 <strong>{semanticHits.length}</strong> result
5593 {semanticHits.length !== 1 ? "s" : ""}
5594 </span>
5595 <span class="search-results-query">
5596 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
5597 <span class="search-results-q">"{q}"</span>
5598 </span>
5599 </div>
5600 <div class="sem-results">
5601 {semanticHits.map((h) => {
5602 const confClass =
5603 h.confidence > 0.7
5604 ? "sem-conf-strong"
5605 : h.confidence > 0.4
5606 ? "sem-conf-possible"
5607 : "sem-conf-weak";
5608 const confLabel =
5609 h.confidence > 0.7
5610 ? "Strong match"
5611 : h.confidence > 0.4
5612 ? "Possible match"
5613 : "Weak match";
5614 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
5615 const preview =
5616 h.snippet.length > 800
5617 ? h.snippet.slice(0, 800) + "\n…"
5618 : h.snippet;
5619 return (
5620 <div class="sem-result">
5621 <div class="sem-result-head">
5622 <a href={href} class="sem-result-path">
5623 {h.file}
5624 {h.lineNumber ? (
5625 <span style="color:var(--text-muted);font-weight:500">
5626 :{h.lineNumber}
5627 </span>
5628 ) : null}
5629 </a>
5630 <span class={`sem-result-conf ${confClass}`}>
5631 {confLabel}
5632 </span>
5633 </div>
5634 {h.reason && (
5635 <p class="sem-result-reason">{h.reason}</p>
5636 )}
5637 {preview && (
5638 <pre class="sem-result-snippet">{preview}</pre>
5639 )}
5640 </div>
5641 );
5642 })}
5643 </div>
5644 </>
5645 )}
5646 </>
79136bbClaude5647 )}
53c9249Claude5648
5649 {/* ─── Keyword results ─── */}
5650 {!isSemantic && q && (
5651 <>
5652 <div class="search-results-head">
5653 <span class="search-results-count">
5654 <strong>{keywordResults.length}</strong> result
5655 {keywordResults.length !== 1 ? "s" : ""}
5656 </span>
5657 <span class="search-results-query">
5658 for <span class="search-results-q">"{q}"</span> on{" "}
5659 <code>{defaultBranch}</code>
5660 </span>
5661 </div>
5662 {keywordResults.length === 0 ? (
5663 <div class="search-empty">
5664 <p>
5665 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
5666 {aiAvailable && (
5667 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
5668 try AI semantic search
79136bbClaude5669 </a>
53c9249Claude5670 )}.
5671 </p>
5672 </div>
5673 ) : (
5674 <div class="search-results">
5675 {(() => {
5676 const grouped: Record<
5677 string,
5678 Array<{ lineNum: number; line: string }>
5679 > = {};
5680 for (const r of keywordResults) {
5681 if (!grouped[r.file]) grouped[r.file] = [];
5682 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
5683 }
5684 return Object.entries(grouped).map(([file, matches]) => (
5685 <div class="search-file diff-file">
5686 <div class="search-file-head diff-file-header">
5687 <a
5688 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
5689 class="search-file-link"
5690 >
5691 {file}
5692 </a>
5693 <span class="search-file-count">
5694 {matches.length} match{matches.length === 1 ? "" : "es"}
5695 </span>
5696 </div>
5697 <div class="blob-code">
5698 <table>
5699 <tbody>
5700 {matches.map((m) => (
5701 <tr>
5702 <td class="line-num">{m.lineNum}</td>
5703 <td class="line-content">{m.line}</td>
5704 </tr>
5705 ))}
5706 </tbody>
5707 </table>
5708 </div>
5709 </div>
5710 ));
5711 })()}
5712 </div>
5713 )}
5714 </>
5715 )}
79136bbClaude5716 </Layout>
5717 );
5718});
5719
fc1817aClaude5720export default web;