Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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.tsxBlame6284 lines · 5 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";
a74f4edccanty labs10import { fireWebhooks } from "./webhooks";
ea52715copilot-swe-agent[bot]11import { config } from "../lib/config";
3951454Claude12import {
13 users,
14 repositories,
15 stars,
16 commitVerifications,
8c790e0Claude17 activityFeed,
ebaae0fClaude18 pullRequests,
19 prComments,
20 issues,
21 labels,
22 issueLabels,
641aa42Claude23 repoOnboardingData,
3951454Claude24} from "../db/schema";
fc1817aClaude25import { Layout } from "../views/layout";
cb5a796Claude26import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude27import {
28 RepoHeader,
29 RepoNav,
30 Breadcrumb,
31 FileTable,
06d5ffeClaude32 RepoCard,
33 BranchSwitcher,
34 HighlightedCode,
35 PlainCode,
f6730d0ccantynz-alt36 Button,
37 sharedComponentStyles,
8c790e0Claude38 type RecentPush,
fc1817aClaude39} from "../views/components";
ea9ed4cClaude40import { DiffView } from "../views/diff-view";
fc1817aClaude41import {
42 getTree,
43 getBlob,
44 listCommits,
45 getCommit,
46 getCommitFullMessage,
47 getDiff,
48 getReadme,
49 getDefaultBranch,
50 listBranches,
398a10cClaude51 listTags,
fc1817aClaude52 repoExists,
06d5ffeClaude53 initBareRepo,
79136bbClaude54 getBlame,
55 getRawBlob,
56 searchCode,
398a10cClaude57 getRepoPath,
fc1817aClaude58} from "../git/repository";
79136bbClaude59import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude60import { highlightCode } from "../lib/highlight";
91a0204Claude61import { computeHealthScore } from "../lib/health-score";
62import type { HealthScore } from "../lib/health-score";
06d5ffeClaude63import { softAuth, requireAuth } from "../middleware/auth";
64import type { AuthEnv } from "../middleware/auth";
5bb52faccanty labs65import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
53c9249Claude66import { claudeSemanticSearch } from "../lib/claude-semantic-search";
67import { isAiAvailable } from "../lib/ai-client";
8f50ed0Claude68import { trackByName } from "../lib/traffic";
8ed88f2ccantynz-alt69import { LandingPage } from "../views/landing";
29924bcClaude70import { Landing2030Page } from "../views/landing-2030";
8ed88f2ccantynz-alt71import { LandingProPage, type LandingProLiveFeed } from "../views/landing-pro";
52ad8b1Claude72import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude73import {
74 listQueuedAiBuildIssues,
75 listRecentAutoMerges,
76 listRecentAiReviews,
77 countAiReviewsSince,
78 listDemoActivityFeed,
79} from "../lib/demo-activity";
11c3ab6ccanty labs80import { AgentWorkspace } from "../views/agent-workspace";
81import { DailyBrief } from "../views/daily-brief";
82import { TrustReport } from "../views/trust-report";
83import { ProductionLayers } from "../views/production-layers";
84import { DistributionView } from "../views/distribution";
85import { OrgMemory } from "../views/org-memory";
fc1817aClaude86
06d5ffeClaude87const web = new Hono<AuthEnv>();
88
89// Soft auth on all web routes — c.get("user") available but may be null
90web.use("*", softAuth);
fc1817aClaude91
5bb52faccanty labs92// ---------------------------------------------------------------------------
93// SECURITY: repo-content access gate.
94//
95// Every route that serves repository CONTENT (file tree, blobs, raw files,
96// blame, commits, diffs, branches, tags, code search, repo overview) MUST call
97// this before touching git-on-disk. Public repos resolve to "read" for
98// anonymous viewers (so public browsing stays fully anonymous); private repos
99// require a logged-in viewer with at least read access.
100//
101// Returns a 404 Response when the repo is missing OR the viewer lacks read
102// access — deliberately 404 (not 403) so we never leak the existence of a
103// private repo. Returns `null` when access is granted; callers proceed as
104// normal. The owner-username → user → repositories lookup mirrors the pattern
105// used by `requireRepoAccess` in ../middleware/repo-access.
106// ---------------------------------------------------------------------------
107async function assertRepoReadable(
108 c: any,
109 owner: string,
110 repo: string
111): Promise<Response | null> {
112 const user = c.get("user") ?? null;
113
114 const notFound = () =>
115 c.html(
116 <Layout title="Not Found" user={user}>
117 <div class="empty-state">
118 <h2>Repository not found</h2>
119 <p>
120 {owner}/{repo} does not exist.
121 </p>
122 </div>
123 </Layout>,
124 404
125 );
126
6b75ac1ccanty labs127 // Explicit dev/test bypass: when no database is configured at all there are
128 // no persisted repositories to protect (private repos only exist as DB rows),
129 // and the browse routes render purely from git-on-disk. This is a positively
130 // established condition (DATABASE_URL unset), NOT an error inference — so the
131 // access gate below can safely fail CLOSED on any runtime error. In
132 // production DATABASE_URL is always set, so this branch never runs there.
133 if (!config.databaseUrl) return null;
134
5bb52faccanty labs135 try {
136 const [ownerRow] = await db
137 .select({ id: users.id })
138 .from(users)
139 .where(eq(users.username, owner))
140 .limit(1);
141 if (!ownerRow) return notFound();
142
143 const [repoRow] = await db
144 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
145 .from(repositories)
146 .where(
147 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
148 )
149 .limit(1);
150 if (!repoRow) return notFound();
151
152 const access = await resolveRepoAccess({
153 repoId: repoRow.id,
154 userId: user?.id ?? null,
155 isPublic: !repoRow.isPrivate,
156 });
157 // Positive denial: the repo exists and the viewer lacks read access.
158 // Return 404 (not 403) so we never confirm a private repo's existence.
159 if (!satisfiesAccess(access, "read")) return notFound();
160
161 return null;
162 } catch (err) {
6b75ac1ccanty labs163 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
164 // lookup errored, so we cannot positively establish that this repo is
165 // public. Denying is the only safe choice for an access-control gate —
166 // failing open here would re-expose private repos during a DB hiccup,
167 // which is the exact vulnerability this function exists to close.
5bb52faccanty labs168 console.warn(
6b75ac1ccanty labs169 `[web] assertRepoReadable: access check failed for ${owner}/${repo} — denying:`,
5bb52faccanty labs170 err instanceof Error ? err.message : err
171 );
6b75ac1ccanty labs172 return notFound();
5bb52faccanty labs173 }
174}
175
ebaae0fClaude176// ---------------------------------------------------------------------------
177// Repo AI stats — computed once per repo home page load, best-effort.
178// Fast because every WHERE clause is bounded to a 7-day window.
179// ---------------------------------------------------------------------------
180interface RepoAiStats {
181 mergedCount: number;
182 reviewCount: number;
183 securityAlertCount: number;
184 hoursSaved: string;
185}
186
187async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
188 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
189
190 // 1. AI-merged PRs this week: activity_feed entries with action =
191 // 'auto_merge.merged' in the last 7 days for this repo.
192 // This is cheaper than a JOIN on pull_requests and covers both the
193 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
194 const [mergedRow] = await db
195 .select({ n: count() })
196 .from(activityFeed)
197 .where(
198 and(
199 eq(activityFeed.repositoryId, repoId),
200 eq(activityFeed.action, "auto_merge.merged"),
201 gte(activityFeed.createdAt, since)
202 )
203 );
204 const mergedCount = Number(mergedRow?.n ?? 0);
205
206 // 2. AI reviews this week: pr_comments where is_ai_review = true in
207 // the last 7 days, joined through pull_requests to scope to this repo.
208 const [reviewRow] = await db
209 .select({ n: count() })
210 .from(prComments)
211 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
212 .where(
213 and(
214 eq(pullRequests.repositoryId, repoId),
215 eq(prComments.isAiReview, true),
216 gte(prComments.createdAt, since)
217 )
218 );
219 const reviewCount = Number(reviewRow?.n ?? 0);
220
221 // 3. Open security alerts: open issues that carry a label whose name
222 // contains 'security' (case-insensitive) for this repo.
223 const [secRow] = await db
224 .select({ n: count() })
225 .from(issues)
226 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
227 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
228 .where(
229 and(
230 eq(issues.repositoryId, repoId),
231 eq(issues.state, "open"),
232 sql`lower(${labels.name}) like '%security%'`
233 )
234 );
235 const securityAlertCount = Number(secRow?.n ?? 0);
236
237 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
238 const hours = mergedCount * 1.5 + reviewCount * 0.5;
239 const hoursSaved = hours.toFixed(1);
240
241 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
242}
243
8c790e0Claude244/**
245 * Query the most recent push to a repo from the activity_feed table.
246 * Returns a RecentPush if there's been a push in the last 24 hours,
247 * or null otherwise. Used by the RepoHeader live/watch indicator.
248 *
249 * Queries activity_feed where action='push' and targetId holds the commit SHA.
250 */
251async function getRecentPush(repoId: string): Promise<RecentPush | null> {
252 try {
253 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
254 const [row] = await db
255 .select({
256 targetId: activityFeed.targetId,
257 createdAt: activityFeed.createdAt,
258 })
259 .from(activityFeed)
260 .where(
261 and(
262 eq(activityFeed.repositoryId, repoId),
263 eq(activityFeed.action, "push"),
264 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
265 )
266 )
267 .orderBy(desc(activityFeed.createdAt))
268 .limit(1);
269 if (!row || !row.targetId) return null;
270 return {
271 sha: row.targetId,
272 ageMs: Date.now() - new Date(row.createdAt).getTime(),
273 };
274 } catch {
275 // DB unavailable or schema mismatch — degrade gracefully.
276 return null;
277 }
278}
279
efb11c5Claude280/**
281 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
282 *
283 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
284 * scope is route-local and `layout.tsx` is locked. Each polished handler
285 * injects this via a `<style>` tag; the rules are namespaced by surface
286 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
287 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
288 * `.repo-home-*` styling Agent A already shipped.
289 */
290const codeBrowseCss = `
291 /* ───────── shared primitives ───────── */
292 .cb-hairline::before,
293 .new-repo-hero::before,
294 .profile-hero::before,
295 .commits-hero::before,
296 .commit-detail-card::before,
297 .search-hero::before {
298 content: '';
299 position: absolute;
300 top: 0; left: 0; right: 0;
301 height: 2px;
6fd5915Claude302 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude303 opacity: 0.7;
304 pointer-events: none;
305 }
306 @keyframes cbHeroOrb {
307 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
308 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
309 }
310 @media (prefers-reduced-motion: reduce) {
311 .new-repo-hero-orb,
312 .profile-hero-orb,
313 .commits-hero-orb { animation: none; }
314 }
315
316 /* ───────── new-repo ───────── */
317 .new-repo-hero {
318 position: relative;
319 margin-bottom: var(--space-5);
320 padding: var(--space-5) var(--space-6);
321 background: var(--bg-elevated);
322 border: 1px solid var(--border);
323 border-radius: 16px;
324 overflow: hidden;
325 }
326 .new-repo-hero-orb-wrap {
327 position: absolute;
328 inset: -25% -10% auto auto;
329 width: 360px;
330 height: 360px;
331 pointer-events: none;
332 z-index: 0;
333 }
334 .new-repo-hero-orb {
335 position: absolute;
336 inset: 0;
6fd5915Claude337 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude338 filter: blur(80px);
339 opacity: 0.7;
340 animation: cbHeroOrb 14s ease-in-out infinite;
341 }
342 .new-repo-hero-inner { position: relative; z-index: 1; }
343 .new-repo-eyebrow {
344 font-size: 12px;
345 font-family: var(--font-mono);
346 color: var(--text-muted);
347 letter-spacing: 0.1em;
348 text-transform: uppercase;
349 margin-bottom: var(--space-2);
350 }
351 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
352 .new-repo-title {
353 font-family: var(--font-display);
354 font-weight: 800;
355 letter-spacing: -0.028em;
356 font-size: clamp(28px, 4vw, 40px);
357 line-height: 1.05;
358 margin: 0 0 var(--space-2);
359 color: var(--text-strong);
360 }
361 .new-repo-sub {
362 font-size: 15px;
363 color: var(--text-muted);
364 margin: 0;
365 line-height: 1.55;
366 max-width: 620px;
367 }
368 .new-repo-form {
369 max-width: 680px;
370 }
371 .new-repo-error {
372 background: rgba(218, 54, 51, 0.12);
373 border: 1px solid rgba(218, 54, 51, 0.35);
374 color: #ffb3b3;
375 padding: 10px 14px;
376 border-radius: 10px;
377 margin-bottom: var(--space-4);
378 font-size: 14px;
379 }
380 .new-repo-form-grid {
381 display: flex;
382 flex-direction: column;
383 gap: var(--space-4);
384 }
385 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
386 .new-repo-label {
387 font-size: 13px;
388 color: var(--text-strong);
389 font-weight: 600;
390 }
391 .new-repo-label-optional {
392 color: var(--text-muted);
393 font-weight: 400;
394 font-size: 12px;
395 }
396 .new-repo-input {
397 appearance: none;
398 width: 100%;
399 background: var(--bg);
400 border: 1px solid var(--border);
401 color: var(--text-strong);
402 border-radius: 10px;
403 padding: 10px 12px;
404 font-size: 14px;
405 font-family: inherit;
406 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
407 }
408 .new-repo-input:focus {
409 outline: none;
410 border-color: var(--accent);
6fd5915Claude411 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude412 }
413 .new-repo-input-disabled {
414 color: var(--text-muted);
415 background: var(--bg-secondary);
416 cursor: not-allowed;
417 }
418 .new-repo-hint {
419 font-size: 12px;
420 color: var(--text-muted);
421 margin: 4px 0 0;
422 }
423 .new-repo-hint code {
424 font-family: var(--font-mono);
425 font-size: 11.5px;
426 background: var(--bg-secondary);
427 border: 1px solid var(--border);
428 border-radius: 5px;
429 padding: 1px 5px;
430 color: var(--text-strong);
431 }
432 .new-repo-visibility {
433 display: grid;
434 grid-template-columns: 1fr 1fr;
435 gap: var(--space-2);
436 }
437 @media (max-width: 600px) {
438 .new-repo-visibility { grid-template-columns: 1fr; }
439 }
440 .new-repo-vis-card {
441 display: flex;
442 gap: 10px;
443 padding: 14px;
444 background: var(--bg);
445 border: 1px solid var(--border);
446 border-radius: 12px;
447 cursor: pointer;
448 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
449 }
450 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
451 .new-repo-vis-card:has(input:checked) {
6fd5915Claude452 border-color: rgba(91,110,232,0.55);
453 background: rgba(91,110,232,0.06);
454 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude455 }
456 .new-repo-vis-radio {
457 margin-top: 3px;
458 accent-color: var(--accent);
459 }
460 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
461 .new-repo-vis-label {
462 font-size: 14px;
463 font-weight: 600;
464 color: var(--text-strong);
465 }
466 .new-repo-vis-desc {
467 font-size: 12.5px;
468 color: var(--text-muted);
469 line-height: 1.45;
470 }
471 .new-repo-callout {
472 margin-top: var(--space-2);
473 padding: var(--space-3) var(--space-4);
6fd5915Claude474 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
475 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude476 border-radius: 12px;
477 }
478 .new-repo-callout-eyebrow {
479 font-size: 11px;
480 font-family: var(--font-mono);
481 text-transform: uppercase;
482 letter-spacing: 0.12em;
483 color: var(--accent);
484 font-weight: 700;
485 margin-bottom: 4px;
486 }
487 .new-repo-callout-body {
488 font-size: 13px;
489 color: var(--text);
490 line-height: 1.5;
491 margin: 0;
492 }
493 .new-repo-callout-body code {
494 font-family: var(--font-mono);
495 font-size: 12px;
496 color: var(--accent);
6fd5915Claude497 background: rgba(91,110,232,0.1);
efb11c5Claude498 border-radius: 4px;
499 padding: 1px 5px;
500 }
398a10cClaude501 .new-repo-templates {
502 display: flex;
503 flex-wrap: wrap;
504 gap: 8px;
505 margin-top: 4px;
506 }
507 .new-repo-template-chip {
508 position: relative;
509 display: inline-flex;
510 align-items: center;
511 gap: 6px;
512 padding: 8px 14px;
513 background: var(--bg);
514 border: 1px solid var(--border);
515 border-radius: 999px;
516 font-size: 13px;
517 color: var(--text);
518 cursor: pointer;
519 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
520 }
521 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
522 .new-repo-template-chip:hover {
523 border-color: var(--border-strong, var(--border));
524 color: var(--text-strong);
525 }
526 .new-repo-template-chip:has(input:checked) {
6fd5915Claude527 border-color: rgba(91,110,232,0.55);
528 background: rgba(91,110,232,0.10);
398a10cClaude529 color: var(--text-strong);
6fd5915Claude530 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude531 }
532 .new-repo-template-chip-dot {
533 width: 8px; height: 8px;
534 border-radius: 999px;
535 background: var(--text-faint, var(--text-muted));
536 transition: background 140ms ease, box-shadow 140ms ease;
537 }
538 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude539 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
540 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude541 }
efb11c5Claude542 .new-repo-actions {
543 display: flex;
544 gap: var(--space-2);
545 margin-top: var(--space-2);
398a10cClaude546 align-items: center;
547 }
548 .new-repo-submit {
549 min-width: 180px;
6fd5915Claude550 border: 1px solid rgba(91,110,232,0.45);
551 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude552 color: #fff;
553 font-weight: 700;
6fd5915Claude554 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude555 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
556 }
557 .new-repo-submit:hover {
558 transform: translateY(-1px);
6fd5915Claude559 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude560 filter: brightness(1.06);
561 }
562 .new-repo-submit:focus-visible {
6fd5915Claude563 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude564 outline-offset: 2px;
efb11c5Claude565 }
566
567 /* ───────── profile ───────── */
568 .profile-hero {
569 position: relative;
570 margin-bottom: var(--space-5);
571 padding: var(--space-5) var(--space-6);
572 background: var(--bg-elevated);
573 border: 1px solid var(--border);
574 border-radius: 16px;
575 overflow: hidden;
576 }
577 .profile-hero-orb-wrap {
578 position: absolute;
579 inset: -25% -10% auto auto;
580 width: 360px;
581 height: 360px;
582 pointer-events: none;
583 z-index: 0;
584 }
585 .profile-hero-orb {
586 position: absolute;
587 inset: 0;
6fd5915Claude588 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude589 filter: blur(80px);
590 opacity: 0.7;
591 animation: cbHeroOrb 14s ease-in-out infinite;
592 }
593 .profile-hero-inner {
594 position: relative;
595 z-index: 1;
596 display: flex;
597 align-items: flex-start;
598 gap: var(--space-5);
599 }
600 .profile-hero-avatar {
601 flex: 0 0 auto;
602 width: 88px;
603 height: 88px;
604 border-radius: 50%;
6fd5915Claude605 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude606 color: #fff;
607 display: flex;
608 align-items: center;
609 justify-content: center;
610 font-size: 38px;
611 font-weight: 700;
612 font-family: var(--font-display);
6fd5915Claude613 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude614 }
615 .profile-hero-text { flex: 1; min-width: 0; }
616 .profile-eyebrow {
617 font-size: 12px;
618 font-family: var(--font-mono);
619 color: var(--text-muted);
620 letter-spacing: 0.1em;
621 text-transform: uppercase;
622 margin-bottom: var(--space-2);
623 }
624 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
625 .profile-name {
626 font-family: var(--font-display);
627 font-weight: 800;
628 letter-spacing: -0.028em;
629 font-size: clamp(28px, 3.6vw, 36px);
630 line-height: 1.05;
631 margin: 0 0 4px;
632 color: var(--text-strong);
633 }
634 .profile-handle {
635 font-family: var(--font-mono);
636 font-size: 13px;
637 color: var(--text-muted);
638 margin-bottom: var(--space-2);
639 }
640 .profile-bio {
641 font-size: 14.5px;
642 color: var(--text);
643 line-height: 1.55;
644 margin: 0 0 var(--space-3);
645 max-width: 640px;
646 }
647 .profile-meta {
648 display: flex;
649 align-items: center;
650 gap: var(--space-4);
651 flex-wrap: wrap;
652 font-size: 13px;
653 }
654 .profile-meta-link {
655 color: var(--text-muted);
656 transition: color var(--t-fast, 0.15s) ease;
657 }
658 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
659 .profile-meta-link strong {
660 color: var(--text-strong);
661 font-weight: 600;
662 font-variant-numeric: tabular-nums;
663 }
664 .profile-follow-form { margin: 0; }
665 @media (max-width: 600px) {
666 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
667 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
668 }
669 .profile-readme {
670 margin-bottom: var(--space-6);
671 background: var(--bg-elevated);
672 border: 1px solid var(--border);
673 border-radius: 12px;
674 overflow: hidden;
675 }
676 .profile-readme-head {
677 display: flex;
678 align-items: center;
679 gap: 8px;
680 padding: 10px 16px;
681 background: var(--bg-secondary);
682 border-bottom: 1px solid var(--border);
683 font-size: 13px;
684 color: var(--text-muted);
685 }
686 .profile-readme-icon { color: var(--accent); font-size: 14px; }
687 .profile-readme-body { padding: var(--space-5) var(--space-6); }
688 .profile-section-head {
689 display: flex;
690 align-items: baseline;
691 gap: var(--space-2);
692 margin-bottom: var(--space-3);
693 }
694 .profile-section-title {
695 font-family: var(--font-display);
696 font-weight: 700;
697 font-size: 20px;
698 letter-spacing: -0.015em;
699 margin: 0;
700 color: var(--text-strong);
701 }
702 .profile-section-count {
703 font-family: var(--font-mono);
704 font-size: 12px;
705 color: var(--text-muted);
706 background: var(--bg-secondary);
707 border: 1px solid var(--border);
708 border-radius: 999px;
709 padding: 2px 10px;
710 font-variant-numeric: tabular-nums;
711 }
712 .profile-empty {
713 display: flex;
714 align-items: center;
715 justify-content: space-between;
716 gap: var(--space-3);
717 padding: var(--space-4) var(--space-5);
718 background: var(--bg-elevated);
719 border: 1px dashed var(--border);
720 border-radius: 12px;
721 }
722 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
723
724 /* ───────── tree (file browser) ───────── */
725 .tree-header {
726 margin-bottom: var(--space-3);
727 display: flex;
728 flex-direction: column;
729 gap: var(--space-2);
730 }
731 .tree-header-row {
732 display: flex;
733 align-items: center;
734 justify-content: space-between;
735 gap: var(--space-3);
736 flex-wrap: wrap;
737 }
738 .tree-header-stats {
739 display: flex;
740 align-items: center;
741 gap: var(--space-3);
742 font-size: 12px;
743 color: var(--text-muted);
744 }
745 .tree-stat strong {
746 color: var(--text-strong);
747 font-weight: 600;
748 font-variant-numeric: tabular-nums;
749 }
750 .tree-stat-link {
751 color: var(--text-muted);
752 padding: 4px 10px;
753 border-radius: 999px;
754 border: 1px solid var(--border);
755 background: var(--bg-elevated);
756 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
757 }
758 .tree-stat-link:hover {
759 color: var(--accent);
6fd5915Claude760 border-color: rgba(91,110,232,0.45);
efb11c5Claude761 text-decoration: none;
762 }
763 .tree-breadcrumb-row {
764 font-size: 13px;
765 }
766
767 /* ───────── blob (file viewer) ───────── */
768 .blob-toolbar {
769 margin-bottom: var(--space-3);
770 display: flex;
771 flex-direction: column;
772 gap: var(--space-2);
773 }
774 .blob-breadcrumb { font-size: 13px; }
775 .blob-card {
776 background: var(--bg-elevated);
777 border: 1px solid var(--border);
778 border-radius: 12px;
779 overflow: hidden;
780 }
781 .blob-header-polished {
782 display: flex;
783 align-items: center;
784 justify-content: space-between;
785 gap: var(--space-3);
786 padding: 10px 14px;
787 background: var(--bg-secondary);
788 border-bottom: 1px solid var(--border);
789 }
790 .blob-header-meta {
791 display: flex;
792 align-items: center;
793 gap: var(--space-2);
794 min-width: 0;
795 flex: 1;
796 }
797 .blob-header-icon { font-size: 14px; opacity: 0.85; }
798 .blob-header-name {
799 font-family: var(--font-mono);
800 font-size: 13px;
801 color: var(--text-strong);
802 font-weight: 600;
803 overflow: hidden;
804 text-overflow: ellipsis;
805 white-space: nowrap;
806 }
807 .blob-header-size {
808 font-family: var(--font-mono);
809 font-size: 11.5px;
810 color: var(--text-muted);
811 font-variant-numeric: tabular-nums;
812 border-left: 1px solid var(--border);
813 padding-left: var(--space-2);
814 margin-left: 2px;
815 }
816 .blob-header-actions {
817 display: flex;
818 gap: 6px;
819 flex-shrink: 0;
820 }
821 .blob-pill {
822 display: inline-flex;
823 align-items: center;
824 padding: 4px 12px;
825 font-size: 12px;
826 font-weight: 500;
827 color: var(--text);
828 background: var(--bg-elevated);
829 border: 1px solid var(--border);
830 border-radius: 999px;
831 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
832 }
833 .blob-pill:hover {
834 color: var(--accent);
6fd5915Claude835 border-color: rgba(91,110,232,0.45);
efb11c5Claude836 text-decoration: none;
6fd5915Claude837 background: rgba(91,110,232,0.06);
efb11c5Claude838 }
839 .blob-pill-accent {
840 color: var(--accent);
6fd5915Claude841 border-color: rgba(91,110,232,0.35);
842 background: rgba(91,110,232,0.08);
efb11c5Claude843 }
844 .blob-pill-accent:hover {
6fd5915Claude845 background: rgba(91,110,232,0.14);
efb11c5Claude846 }
847 .blob-binary {
848 padding: var(--space-5);
849 color: var(--text-muted);
850 text-align: center;
851 font-size: 13px;
852 background: var(--bg);
853 }
854
855 /* ───────── commits list ───────── */
856 .commits-hero {
857 position: relative;
858 margin-bottom: var(--space-4);
859 padding: var(--space-5) var(--space-6);
860 background: var(--bg-elevated);
861 border: 1px solid var(--border);
862 border-radius: 16px;
863 overflow: hidden;
864 }
865 .commits-hero-orb-wrap {
866 position: absolute;
867 inset: -25% -10% auto auto;
868 width: 320px;
869 height: 320px;
870 pointer-events: none;
871 z-index: 0;
872 }
873 .commits-hero-orb {
874 position: absolute;
875 inset: 0;
6fd5915Claude876 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude877 filter: blur(80px);
878 opacity: 0.7;
879 animation: cbHeroOrb 14s ease-in-out infinite;
880 }
881 .commits-hero-inner { position: relative; z-index: 1; }
882 .commits-eyebrow {
883 font-size: 12px;
884 font-family: var(--font-mono);
885 color: var(--text-muted);
886 letter-spacing: 0.1em;
887 text-transform: uppercase;
888 margin-bottom: var(--space-2);
889 }
890 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
891 .commits-title {
892 font-family: var(--font-display);
893 font-weight: 800;
894 letter-spacing: -0.025em;
895 font-size: clamp(22px, 3vw, 30px);
896 line-height: 1.15;
897 margin: 0 0 var(--space-2);
898 color: var(--text-strong);
899 }
900 .commits-branch {
901 font-family: var(--font-mono);
902 font-size: 0.7em;
903 color: var(--text);
904 background: var(--bg-secondary);
905 border: 1px solid var(--border);
906 border-radius: 6px;
907 padding: 2px 8px;
908 font-weight: 500;
909 vertical-align: middle;
910 }
911 .commits-sub {
912 font-size: 14px;
913 color: var(--text-muted);
914 margin: 0;
915 line-height: 1.55;
916 max-width: 640px;
917 }
398a10cClaude918 .commits-toolbar {
919 display: flex;
920 justify-content: space-between;
921 align-items: center;
922 flex-wrap: wrap;
923 gap: var(--space-2);
924 margin-bottom: var(--space-3);
925 }
926 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
927 .commits-toolbar-link {
928 display: inline-flex;
929 align-items: center;
930 gap: 6px;
931 padding: 6px 12px;
932 font-size: 12.5px;
933 font-weight: 500;
934 color: var(--text-muted);
935 background: rgba(255,255,255,0.025);
936 border: 1px solid var(--border);
937 border-radius: 8px;
938 text-decoration: none;
939 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
940 }
941 .commits-toolbar-link:hover {
942 border-color: var(--border-strong);
943 color: var(--text-strong);
944 background: rgba(255,255,255,0.04);
945 text-decoration: none;
946 }
efb11c5Claude947 .commits-list-wrap {
948 background: var(--bg-elevated);
949 border: 1px solid var(--border);
398a10cClaude950 border-radius: 14px;
efb11c5Claude951 overflow: hidden;
398a10cClaude952 position: relative;
953 }
954 .commits-list-wrap::before {
955 content: '';
956 position: absolute;
957 top: 0; left: 0; right: 0;
958 height: 2px;
6fd5915Claude959 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude960 opacity: 0.55;
961 pointer-events: none;
962 }
963 .commits-day-head {
964 display: flex;
965 align-items: center;
966 gap: 10px;
967 padding: 10px 18px;
968 font-size: 11.5px;
969 font-family: var(--font-mono);
970 text-transform: uppercase;
971 letter-spacing: 0.08em;
972 color: var(--text-muted);
973 background: var(--bg-secondary);
974 border-bottom: 1px solid var(--border);
975 }
976 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
977 .commits-day-head-dot {
978 width: 6px; height: 6px;
979 border-radius: 9999px;
6fd5915Claude980 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude981 }
982 .commits-row {
983 display: flex;
984 align-items: flex-start;
985 gap: 14px;
986 padding: 14px 18px;
987 border-bottom: 1px solid var(--border-subtle);
988 transition: background 120ms ease;
989 }
990 .commits-row:last-child { border-bottom: none; }
991 .commits-row:hover { background: rgba(255,255,255,0.022); }
992 .commits-avatar {
993 width: 34px; height: 34px;
994 border-radius: 9999px;
6fd5915Claude995 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude996 color: #fff;
997 display: inline-flex;
998 align-items: center;
999 justify-content: center;
1000 font-family: var(--font-display);
1001 font-weight: 700;
1002 font-size: 13.5px;
1003 flex-shrink: 0;
1004 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
1005 }
1006 .commits-row-body { flex: 1; min-width: 0; }
1007 .commits-row-msg {
1008 display: flex;
1009 align-items: center;
1010 gap: 8px;
1011 flex-wrap: wrap;
1012 }
1013 .commits-row-msg a {
1014 font-family: var(--font-display);
1015 font-weight: 600;
1016 font-size: 14px;
1017 color: var(--text-strong);
1018 text-decoration: none;
1019 letter-spacing: -0.005em;
1020 }
1021 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1022 .commits-row-verified {
1023 font-size: 9.5px;
1024 padding: 1px 7px;
1025 border-radius: 9999px;
1026 background: rgba(52,211,153,0.16);
e589f77ccantynz-alt1027 color: var(--green);
398a10cClaude1028 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1029 text-transform: uppercase;
1030 letter-spacing: 0.06em;
1031 font-weight: 700;
1032 }
1033 .commits-row-meta {
1034 margin-top: 4px;
1035 display: flex;
1036 align-items: center;
1037 gap: 8px;
1038 flex-wrap: wrap;
1039 font-size: 12.5px;
1040 color: var(--text-muted);
1041 }
1042 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1043 .commits-row-meta .sep { opacity: 0.4; }
1044 .commits-row-time {
1045 font-variant-numeric: tabular-nums;
1046 }
1047 .commits-row-side {
1048 display: flex;
1049 align-items: center;
1050 gap: 8px;
1051 flex-shrink: 0;
1052 }
1053 .commits-row-sha {
1054 display: inline-flex;
1055 align-items: center;
1056 padding: 4px 10px;
1057 border-radius: 9999px;
1058 font-family: var(--font-mono);
1059 font-size: 11.5px;
1060 font-weight: 600;
1061 color: var(--text-strong);
1062 background: rgba(255,255,255,0.04);
1063 border: 1px solid var(--border);
1064 text-decoration: none;
1065 letter-spacing: 0.04em;
1066 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1067 }
1068 .commits-row-sha:hover {
6fd5915Claude1069 border-color: rgba(91,110,232,0.55);
398a10cClaude1070 color: var(--accent);
6fd5915Claude1071 background: rgba(91,110,232,0.08);
398a10cClaude1072 text-decoration: none;
1073 }
1074 .commits-row-copy {
1075 display: inline-flex;
1076 align-items: center;
1077 justify-content: center;
1078 width: 28px; height: 28px;
1079 padding: 0;
1080 border-radius: 8px;
1081 background: transparent;
1082 border: 1px solid var(--border);
1083 color: var(--text-muted);
1084 cursor: pointer;
1085 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1086 }
398a10cClaude1087 .commits-row-copy:hover {
1088 border-color: var(--border-strong);
1089 color: var(--text);
1090 background: rgba(255,255,255,0.04);
1091 }
e589f77ccantynz-alt1092 .commits-row-copy.is-copied { color: var(--green); border-color: rgba(52,211,153,0.35); }
8c790e0Claude1093 /* Push Watch link — eye icon next to the SHA chip */
1094 .commits-row-watch {
1095 display: inline-flex;
1096 align-items: center;
1097 justify-content: center;
1098 width: 28px;
1099 height: 28px;
1100 border-radius: 6px;
1101 border: 1px solid var(--border);
1102 color: var(--text-muted);
1103 background: transparent;
1104 cursor: pointer;
1105 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1106 text-decoration: none !important;
1107 }
1108 .commits-row-watch:hover {
6fd5915Claude1109 border-color: rgba(91,110,232,0.45);
8c790e0Claude1110 color: var(--accent);
6fd5915Claude1111 background: rgba(91,110,232,0.08);
8c790e0Claude1112 text-decoration: none !important;
1113 }
efb11c5Claude1114 .commits-empty {
398a10cClaude1115 position: relative;
1116 overflow: hidden;
1117 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1118 text-align: center;
398a10cClaude1119 background: var(--bg-elevated);
1120 border: 1px dashed var(--border-strong);
1121 border-radius: 16px;
1122 }
1123 .commits-empty-orb {
1124 position: absolute;
1125 inset: -40% 30% auto 30%;
1126 height: 280px;
6fd5915Claude1127 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1128 filter: blur(70px);
1129 opacity: 0.7;
1130 pointer-events: none;
1131 z-index: 0;
1132 }
1133 .commits-empty-inner { position: relative; z-index: 1; }
1134 .commits-empty-icon {
1135 width: 56px; height: 56px;
1136 border-radius: 9999px;
6fd5915Claude1137 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1138 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1139 display: inline-flex;
1140 align-items: center;
1141 justify-content: center;
1142 color: #c4b5fd;
1143 margin: 0 auto 14px;
1144 }
1145 .commits-empty-title {
1146 font-family: var(--font-display);
1147 font-size: 18px;
1148 font-weight: 700;
1149 margin: 0 0 6px;
1150 color: var(--text-strong);
1151 }
1152 .commits-empty-sub {
1153 margin: 0 auto 0;
1154 font-size: 13.5px;
efb11c5Claude1155 color: var(--text-muted);
398a10cClaude1156 max-width: 420px;
1157 line-height: 1.5;
1158 }
1159
1160 /* ───────── branches list ───────── */
1161 .branches-list {
efb11c5Claude1162 background: var(--bg-elevated);
398a10cClaude1163 border: 1px solid var(--border);
1164 border-radius: 14px;
1165 overflow: hidden;
1166 position: relative;
1167 }
1168 .branches-list::before {
1169 content: '';
1170 position: absolute;
1171 top: 0; left: 0; right: 0;
1172 height: 2px;
6fd5915Claude1173 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1174 opacity: 0.55;
1175 pointer-events: none;
1176 }
1177 .branches-row {
1178 display: flex;
1179 align-items: center;
1180 gap: var(--space-3);
1181 padding: 14px 18px;
1182 border-bottom: 1px solid var(--border-subtle);
1183 transition: background 120ms ease;
1184 flex-wrap: wrap;
1185 }
1186 .branches-row:last-child { border-bottom: none; }
1187 .branches-row:hover { background: rgba(255,255,255,0.022); }
1188 .branches-row-icon {
1189 width: 32px; height: 32px;
1190 border-radius: 8px;
6fd5915Claude1191 background: rgba(91,110,232,0.10);
398a10cClaude1192 color: #c4b5fd;
1193 display: inline-flex;
1194 align-items: center;
1195 justify-content: center;
1196 flex-shrink: 0;
6fd5915Claude1197 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1198 }
1199 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1200 .branches-row-name {
1201 display: inline-flex;
1202 align-items: center;
1203 gap: 8px;
1204 flex-wrap: wrap;
1205 }
1206 .branches-row-name a {
1207 font-family: var(--font-mono);
1208 font-size: 13.5px;
1209 color: var(--text-strong);
1210 font-weight: 600;
1211 text-decoration: none;
1212 }
1213 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1214 .branches-row-default {
1215 font-size: 10px;
1216 padding: 2px 8px;
1217 border-radius: 9999px;
6fd5915Claude1218 background: rgba(91,110,232,0.14);
398a10cClaude1219 color: #c4b5fd;
6fd5915Claude1220 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1221 text-transform: uppercase;
1222 letter-spacing: 0.08em;
1223 font-weight: 700;
1224 font-family: var(--font-mono);
1225 }
1226 .branches-row-meta {
1227 display: flex;
1228 align-items: center;
1229 gap: 8px;
1230 flex-wrap: wrap;
1231 font-size: 12.5px;
1232 color: var(--text-muted);
1233 font-variant-numeric: tabular-nums;
1234 }
1235 .branches-row-meta .sep { opacity: 0.4; }
1236 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1237 .branches-row-side {
1238 display: flex;
1239 align-items: center;
1240 gap: 10px;
1241 flex-shrink: 0;
1242 flex-wrap: wrap;
1243 }
1244 .branches-row-divergence {
1245 display: inline-flex;
1246 align-items: center;
1247 gap: 8px;
1248 font-family: var(--font-mono);
1249 font-size: 11.5px;
1250 color: var(--text-muted);
1251 padding: 4px 10px;
1252 border-radius: 9999px;
1253 background: rgba(255,255,255,0.035);
1254 border: 1px solid var(--border);
1255 font-variant-numeric: tabular-nums;
1256 }
e589f77ccantynz-alt1257 .branches-row-divergence .ahead { color: var(--green); }
1258 .branches-row-divergence .behind { color: var(--red); }
398a10cClaude1259 .branches-row-actions {
1260 display: flex;
1261 align-items: center;
1262 gap: 6px;
1263 }
1264 .branches-btn {
1265 display: inline-flex;
1266 align-items: center;
1267 gap: 5px;
1268 padding: 6px 12px;
1269 border-radius: 9999px;
1270 font-size: 12px;
1271 font-weight: 600;
1272 text-decoration: none;
1273 border: 1px solid var(--border);
1274 background: transparent;
1275 color: var(--text-muted);
1276 cursor: pointer;
1277 font: inherit;
1278 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1279 }
1280 .branches-btn:hover {
1281 border-color: var(--border-strong);
1282 color: var(--text);
1283 background: rgba(255,255,255,0.04);
1284 text-decoration: none;
1285 }
1286 .branches-btn-danger {
e589f77ccantynz-alt1287 color: var(--red);
398a10cClaude1288 border-color: rgba(248,113,113,0.30);
1289 }
1290 .branches-btn-danger:hover {
1291 border-style: dashed;
1292 border-color: rgba(248,113,113,0.65);
1293 background: rgba(248,113,113,0.06);
1294 color: #fecaca;
1295 }
1296
1297 /* ───────── tags list ───────── */
1298 .tags-list {
1299 background: var(--bg-elevated);
1300 border: 1px solid var(--border);
1301 border-radius: 14px;
1302 overflow: hidden;
1303 position: relative;
1304 }
1305 .tags-list::before {
1306 content: '';
1307 position: absolute;
1308 top: 0; left: 0; right: 0;
1309 height: 2px;
6fd5915Claude1310 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1311 opacity: 0.55;
1312 pointer-events: none;
1313 }
1314 .tags-row {
1315 display: flex;
1316 align-items: center;
1317 gap: var(--space-3);
1318 padding: 14px 18px;
1319 border-bottom: 1px solid var(--border-subtle);
1320 transition: background 120ms ease;
1321 flex-wrap: wrap;
1322 }
1323 .tags-row:last-child { border-bottom: none; }
1324 .tags-row:hover { background: rgba(255,255,255,0.022); }
1325 .tags-row-icon {
1326 width: 32px; height: 32px;
1327 border-radius: 8px;
6fd5915Claude1328 background: rgba(95,143,160,0.10);
398a10cClaude1329 color: #67e8f9;
1330 display: inline-flex;
1331 align-items: center;
1332 justify-content: center;
1333 flex-shrink: 0;
6fd5915Claude1334 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1335 }
1336 .tags-row-main { flex: 1; min-width: 240px; }
1337 .tags-row-name {
1338 display: inline-flex;
1339 align-items: center;
1340 gap: 8px;
1341 flex-wrap: wrap;
1342 }
1343 .tags-row-version {
1344 display: inline-flex;
1345 align-items: center;
1346 padding: 3px 11px;
1347 border-radius: 9999px;
1348 font-family: var(--font-mono);
1349 font-size: 13px;
1350 font-weight: 700;
1351 color: #67e8f9;
6fd5915Claude1352 background: rgba(95,143,160,0.12);
1353 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1354 letter-spacing: 0.01em;
1355 }
1356 .tags-row-meta {
1357 margin-top: 4px;
1358 display: flex;
1359 align-items: center;
1360 gap: 8px;
1361 flex-wrap: wrap;
1362 font-size: 12.5px;
1363 color: var(--text-muted);
1364 font-variant-numeric: tabular-nums;
1365 }
1366 .tags-row-meta .sep { opacity: 0.4; }
1367 .tags-row-sha {
1368 font-family: var(--font-mono);
1369 font-size: 11.5px;
1370 color: var(--text-strong);
1371 padding: 2px 8px;
1372 border-radius: 6px;
1373 background: rgba(255,255,255,0.04);
1374 border: 1px solid var(--border);
1375 text-decoration: none;
1376 letter-spacing: 0.04em;
1377 }
1378 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1379 .tags-row-side {
1380 display: flex;
1381 align-items: center;
1382 gap: 6px;
1383 flex-shrink: 0;
1384 flex-wrap: wrap;
1385 }
1386 .tags-row-link {
1387 display: inline-flex;
1388 align-items: center;
1389 gap: 5px;
1390 padding: 6px 12px;
1391 border-radius: 9999px;
1392 font-size: 12px;
1393 font-weight: 600;
1394 text-decoration: none;
1395 color: var(--text-muted);
1396 background: rgba(255,255,255,0.025);
1397 border: 1px solid var(--border);
1398 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1399 }
1400 .tags-row-link:hover {
6fd5915Claude1401 border-color: rgba(91,110,232,0.45);
398a10cClaude1402 color: var(--text-strong);
6fd5915Claude1403 background: rgba(91,110,232,0.06);
398a10cClaude1404 text-decoration: none;
efb11c5Claude1405 }
1406
1407 /* ───────── commit detail ───────── */
1408 .commit-detail-card {
1409 position: relative;
1410 margin-bottom: var(--space-5);
1411 padding: var(--space-5) var(--space-5);
1412 background: var(--bg-elevated);
1413 border: 1px solid var(--border);
1414 border-radius: 14px;
1415 overflow: hidden;
1416 }
1417 .commit-detail-eyebrow {
1418 display: flex;
1419 align-items: center;
1420 gap: var(--space-2);
1421 font-size: 12px;
1422 font-family: var(--font-mono);
1423 text-transform: uppercase;
1424 letter-spacing: 0.1em;
1425 color: var(--text-muted);
1426 margin-bottom: var(--space-2);
1427 }
1428 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1429 .commit-detail-sha-pill {
1430 font-family: var(--font-mono);
1431 font-size: 11.5px;
1432 color: var(--text-strong);
1433 background: var(--bg-secondary);
1434 border: 1px solid var(--border);
1435 border-radius: 999px;
1436 padding: 2px 10px;
1437 letter-spacing: 0.04em;
1438 }
1439 .commit-detail-verify {
1440 font-size: 10px;
1441 padding: 2px 8px;
1442 border-radius: 999px;
1443 text-transform: uppercase;
1444 letter-spacing: 0.06em;
1445 font-weight: 700;
1446 color: #fff;
1447 }
1448 .commit-detail-verify-ok {
1449 background: linear-gradient(135deg, #2ea043, #34d399);
1450 }
1451 .commit-detail-verify-warn {
1452 background: linear-gradient(135deg, #d29922, #f59e0b);
1453 }
1454 .commit-detail-title {
1455 font-family: var(--font-display);
1456 font-weight: 700;
1457 letter-spacing: -0.018em;
1458 font-size: clamp(20px, 2.5vw, 26px);
1459 line-height: 1.25;
1460 margin: 0 0 var(--space-2);
1461 color: var(--text-strong);
1462 }
1463 .commit-detail-body {
1464 white-space: pre-wrap;
1465 color: var(--text-muted);
1466 font-size: 14px;
1467 line-height: 1.55;
1468 margin: 0 0 var(--space-3);
1469 font-family: var(--font-mono);
1470 background: var(--bg);
1471 border: 1px solid var(--border);
1472 border-radius: 8px;
1473 padding: var(--space-3) var(--space-4);
1474 max-height: 280px;
1475 overflow: auto;
1476 }
1477 .commit-detail-meta {
1478 display: flex;
1479 flex-wrap: wrap;
1480 gap: var(--space-3);
1481 font-size: 13px;
1482 color: var(--text-muted);
1483 margin-bottom: var(--space-3);
1484 }
1485 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1486 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1487 .commit-detail-sha-link {
1488 font-family: var(--font-mono);
1489 font-size: 12.5px;
1490 color: var(--accent);
6fd5915Claude1491 background: rgba(91,110,232,0.08);
efb11c5Claude1492 border-radius: 6px;
1493 padding: 1px 6px;
1494 margin-left: 2px;
1495 }
6fd5915Claude1496 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1497 .commit-detail-stats {
1498 display: flex;
1499 flex-wrap: wrap;
1500 align-items: center;
1501 gap: var(--space-3);
1502 padding-top: var(--space-3);
1503 border-top: 1px solid var(--border);
1504 font-size: 13px;
1505 color: var(--text-muted);
1506 }
1507 .commit-detail-stat {
1508 display: inline-flex;
1509 align-items: center;
1510 gap: 4px;
1511 font-variant-numeric: tabular-nums;
1512 }
1513 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1514 .commit-detail-stat-add strong { color: var(--green); }
1515 .commit-detail-stat-del strong { color: var(--red); }
efb11c5Claude1516 .commit-detail-stat-mark {
1517 font-family: var(--font-mono);
1518 font-weight: 700;
1519 font-size: 14px;
1520 }
e589f77ccantynz-alt1521 .commit-detail-stat-add .commit-detail-stat-mark { color: var(--green); }
1522 .commit-detail-stat-del .commit-detail-stat-mark { color: var(--red); }
efb11c5Claude1523 .commit-detail-sha-full {
1524 margin-left: auto;
1525 font-family: var(--font-mono);
1526 font-size: 11.5px;
1527 color: var(--text-faint);
1528 letter-spacing: 0.02em;
1529 overflow: hidden;
1530 text-overflow: ellipsis;
1531 white-space: nowrap;
1532 max-width: 100%;
1533 }
1534 .commit-detail-checks {
1535 margin-top: var(--space-3);
1536 padding-top: var(--space-3);
1537 border-top: 1px solid var(--border);
1538 }
1539 .commit-detail-checks-head {
1540 display: flex;
1541 align-items: center;
1542 gap: var(--space-2);
1543 font-size: 13px;
1544 color: var(--text-muted);
1545 margin-bottom: 8px;
1546 }
1547 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1548 .commit-detail-check-state-success { color: var(--green); font-weight: 600; }
1549 .commit-detail-check-state-failure { color: var(--red); font-weight: 600; }
efb11c5Claude1550 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1551 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1552 .commit-detail-check {
1553 font-size: 11px;
1554 padding: 2px 8px;
1555 border-radius: 999px;
1556 color: #fff;
1557 font-weight: 500;
1558 }
398a10cClaude1559 .commit-detail-check a { color: inherit; text-decoration: none; }
1560 .commit-detail-check-success { background: #2ea043; }
1561 .commit-detail-check-pending { background: #d29922; }
1562 .commit-detail-check-failure { background: #da3633; }
1563
1564 /* ───────── blame ───────── */
1565 .blame-head { margin-bottom: var(--space-5); }
1566 .blame-eyebrow {
1567 display: inline-flex;
1568 align-items: center;
1569 gap: 8px;
1570 text-transform: uppercase;
1571 font-family: var(--font-mono);
1572 font-size: 11px;
1573 letter-spacing: 0.16em;
1574 color: var(--text-muted);
1575 font-weight: 600;
1576 margin-bottom: 10px;
1577 }
1578 .blame-eyebrow-dot {
1579 width: 8px; height: 8px;
1580 border-radius: 9999px;
6fd5915Claude1581 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1582 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1583 }
1584 .blame-title {
1585 font-family: var(--font-display);
1586 font-size: clamp(22px, 3vw, 30px);
1587 font-weight: 800;
1588 letter-spacing: -0.025em;
1589 line-height: 1.15;
1590 margin: 0 0 6px;
1591 color: var(--text-strong);
1592 }
1593 .blame-title code {
1594 font-family: var(--font-mono);
1595 font-size: 0.78em;
1596 color: var(--text-strong);
1597 font-weight: 700;
1598 }
1599 .blame-sub {
1600 margin: 0;
1601 font-size: 14px;
1602 color: var(--text-muted);
1603 line-height: 1.5;
1604 max-width: 700px;
1605 }
efb11c5Claude1606 .blame-toolbar {
398a10cClaude1607 display: flex;
1608 justify-content: space-between;
1609 align-items: center;
1610 gap: var(--space-3);
efb11c5Claude1611 margin-bottom: var(--space-3);
398a10cClaude1612 flex-wrap: wrap;
efb11c5Claude1613 font-size: 13px;
1614 }
398a10cClaude1615 .blame-toolbar-actions { display: flex; gap: 6px; }
1616 .blame-card {
1617 background: var(--bg-elevated);
1618 border: 1px solid var(--border);
1619 border-radius: 14px;
1620 overflow: hidden;
1621 position: relative;
1622 }
1623 .blame-card::before {
1624 content: '';
1625 position: absolute;
1626 top: 0; left: 0; right: 0;
1627 height: 2px;
6fd5915Claude1628 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1629 opacity: 0.55;
1630 pointer-events: none;
1631 }
efb11c5Claude1632 .blame-header {
1633 display: flex;
1634 align-items: center;
1635 justify-content: space-between;
1636 gap: var(--space-3);
1637 padding: 10px 14px;
1638 background: var(--bg-secondary);
1639 border-bottom: 1px solid var(--border);
1640 }
1641 .blame-header-meta {
1642 display: flex;
1643 align-items: center;
1644 gap: var(--space-2);
1645 min-width: 0;
1646 flex-wrap: wrap;
1647 }
1648 .blame-header-icon { color: var(--accent); font-size: 14px; }
1649 .blame-header-name {
1650 font-family: var(--font-mono);
1651 font-size: 13px;
1652 color: var(--text-strong);
1653 font-weight: 600;
1654 }
1655 .blame-header-tag {
1656 font-size: 10.5px;
1657 text-transform: uppercase;
1658 letter-spacing: 0.08em;
1659 font-family: var(--font-mono);
6fd5915Claude1660 background: rgba(91,110,232,0.12);
efb11c5Claude1661 color: var(--accent);
1662 border-radius: 999px;
1663 padding: 2px 8px;
1664 font-weight: 600;
1665 }
1666 .blame-header-stats {
1667 font-family: var(--font-mono);
1668 font-size: 11.5px;
1669 color: var(--text-muted);
1670 font-variant-numeric: tabular-nums;
1671 border-left: 1px solid var(--border);
1672 padding-left: var(--space-2);
1673 }
1674 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1675 .blame-table {
1676 width: 100%;
1677 border-collapse: collapse;
1678 font-family: var(--font-mono);
1679 font-size: 12.5px;
1680 line-height: 1.6;
1681 }
1682 .blame-table tr { border-bottom: 1px solid transparent; }
1683 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1684 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1685 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1686 .blame-gutter {
1687 width: 220px;
1688 min-width: 220px;
1689 padding: 0 12px;
1690 vertical-align: top;
1691 background: rgba(255,255,255,0.012);
1692 border-right: 1px solid var(--border-subtle);
1693 font-variant-numeric: tabular-nums;
1694 color: var(--text-muted);
1695 font-size: 11px;
1696 white-space: nowrap;
1697 overflow: hidden;
1698 text-overflow: ellipsis;
1699 padding-top: 2px;
1700 padding-bottom: 2px;
1701 }
1702 .blame-gutter-inner {
1703 display: inline-flex;
1704 align-items: center;
1705 gap: 7px;
1706 max-width: 100%;
1707 overflow: hidden;
1708 }
1709 .blame-gutter-sha {
1710 font-family: var(--font-mono);
1711 font-size: 10.5px;
1712 font-weight: 600;
1713 color: #c4b5fd;
6fd5915Claude1714 background: rgba(91,110,232,0.10);
398a10cClaude1715 padding: 1px 7px;
1716 border-radius: 9999px;
6fd5915Claude1717 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1718 text-decoration: none;
1719 letter-spacing: 0.04em;
1720 flex-shrink: 0;
1721 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1722 }
1723 .blame-gutter-sha:hover {
6fd5915Claude1724 background: rgba(91,110,232,0.22);
398a10cClaude1725 color: #ddd6fe;
6fd5915Claude1726 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1727 text-decoration: none;
1728 }
1729 .blame-gutter-author {
1730 color: var(--text);
1731 overflow: hidden;
1732 text-overflow: ellipsis;
1733 white-space: nowrap;
1734 font-size: 11px;
1735 font-family: var(--font-sans, inherit);
1736 }
1737 .blame-line-num {
1738 width: 1%;
1739 min-width: 50px;
1740 padding: 0 12px;
1741 text-align: right;
1742 color: var(--text-faint);
1743 user-select: none;
1744 border-right: 1px solid var(--border-subtle);
1745 font-variant-numeric: tabular-nums;
1746 }
1747 .blame-line-content {
1748 padding: 0 14px;
1749 white-space: pre;
1750 color: var(--text);
1751 transition: background 120ms ease;
1752 }
efb11c5Claude1753
1754 /* ───────── search ───────── */
1755 .search-hero {
1756 position: relative;
1757 margin-bottom: var(--space-4);
1758 padding: var(--space-5) var(--space-6);
1759 background: var(--bg-elevated);
1760 border: 1px solid var(--border);
1761 border-radius: 16px;
1762 overflow: hidden;
1763 }
1764 .search-eyebrow {
1765 font-size: 12px;
1766 font-family: var(--font-mono);
1767 color: var(--text-muted);
1768 letter-spacing: 0.1em;
1769 text-transform: uppercase;
1770 margin-bottom: var(--space-2);
1771 }
1772 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1773 .search-title {
1774 font-family: var(--font-display);
1775 font-weight: 800;
1776 letter-spacing: -0.025em;
1777 font-size: clamp(22px, 3vw, 30px);
1778 line-height: 1.15;
1779 margin: 0 0 var(--space-3);
1780 color: var(--text-strong);
1781 }
1782 .search-form {
1783 display: flex;
1784 gap: var(--space-2);
1785 align-items: stretch;
1786 }
1787 .search-input-wrap {
1788 position: relative;
1789 flex: 1;
1790 display: flex;
1791 align-items: center;
1792 }
1793 .search-input-icon {
1794 position: absolute;
1795 left: 12px;
1796 color: var(--text-muted);
1797 font-size: 15px;
1798 pointer-events: none;
1799 }
1800 .search-input {
1801 appearance: none;
1802 width: 100%;
1803 padding: 10px 12px 10px 34px;
1804 background: var(--bg);
1805 border: 1px solid var(--border);
1806 border-radius: 10px;
1807 color: var(--text-strong);
1808 font-size: 14px;
1809 font-family: inherit;
1810 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1811 }
1812 .search-input:focus {
1813 outline: none;
1814 border-color: var(--accent);
6fd5915Claude1815 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1816 }
1817 .search-submit { min-width: 96px; }
1818 .search-results-head {
1819 display: flex;
1820 align-items: baseline;
1821 gap: var(--space-2);
1822 margin-bottom: var(--space-3);
1823 font-size: 13px;
1824 color: var(--text-muted);
1825 }
1826 .search-results-count strong {
1827 color: var(--text-strong);
1828 font-weight: 600;
1829 font-variant-numeric: tabular-nums;
1830 }
1831 .search-results-q { color: var(--text-strong); font-weight: 600; }
1832 .search-results-head code {
1833 font-family: var(--font-mono);
1834 font-size: 12px;
1835 background: var(--bg-secondary);
1836 border: 1px solid var(--border);
1837 border-radius: 5px;
1838 padding: 1px 6px;
1839 color: var(--text);
1840 }
1841 .search-empty {
1842 padding: var(--space-5) var(--space-6);
1843 background: var(--bg-elevated);
1844 border: 1px dashed var(--border);
1845 border-radius: 12px;
1846 color: var(--text-muted);
1847 font-size: 14px;
1848 }
1849 .search-empty strong { color: var(--text-strong); }
1850 .search-results {
1851 display: flex;
1852 flex-direction: column;
1853 gap: var(--space-3);
1854 }
1855 .search-file-head {
1856 display: flex;
1857 align-items: center;
1858 justify-content: space-between;
1859 gap: var(--space-2);
1860 }
1861 .search-file-link {
1862 font-family: var(--font-mono);
1863 font-size: 13px;
1864 color: var(--text-strong);
1865 font-weight: 600;
1866 }
1867 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1868 .search-file-count {
1869 font-family: var(--font-mono);
1870 font-size: 11.5px;
1871 color: var(--text-muted);
1872 font-variant-numeric: tabular-nums;
1873 }
1874`;
1875
fc1817aClaude1876// Home page
06d5ffeClaude1877web.get("/", async (c) => {
1878 const user = c.get("user");
1879
1880 if (user) {
0316dbbClaude1881 return c.redirect("/dashboard");
06d5ffeClaude1882 }
1883
8e9f1d9Claude1884 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1885 let publicStats: PublicStats | null = null;
8e9f1d9Claude1886 try {
1887 const [repoRow] = await db
1888 .select({ n: sql<number>`count(*)::int` })
1889 .from(repositories)
1890 .where(eq(repositories.isPrivate, false));
1891 const [userRow] = await db
1892 .select({ n: sql<number>`count(*)::int` })
1893 .from(users);
1894 stats = {
1895 publicRepos: Number(repoRow?.n ?? 0),
1896 users: Number(userRow?.n ?? 0),
1897 };
1898 } catch {
1899 stats = undefined;
1900 }
1901
52ad8b1Claude1902 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1903 try {
1904 publicStats = await computePublicStats();
1905 } catch {
1906 publicStats = null;
1907 }
1908
534f04aClaude1909 // Block M1 — initial SSR snapshot for the live-now demo feed.
1910 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1911 // in try/catch so a freak module-level explosion can't take down /.
8ed88f2ccantynz-alt1912 let liveFeed: LandingProLiveFeed | null = null;
534f04aClaude1913 try {
1914 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1915 listQueuedAiBuildIssues(3),
1916 listRecentAutoMerges(3, 24),
1917 listRecentAiReviews(3, 24),
1918 countAiReviewsSince(24),
1919 listDemoActivityFeed(10),
1920 ]);
1921 liveFeed = {
1922 queued: queued.map((i) => ({
1923 repo: i.repo,
1924 number: i.number,
1925 title: i.title,
8ed88f2ccantynz-alt1926 createdAt: new Date(i.createdAt),
534f04aClaude1927 })),
1928 merges: merges.map((m) => ({
1929 repo: m.repo,
1930 number: m.number,
1931 title: m.title,
1932 mergedAt: m.mergedAt,
1933 })),
1934 reviews: reviewList.map((r) => ({
1935 repo: r.repo,
1936 prNumber: r.prNumber,
1937 commentSnippet: r.commentSnippet,
1938 createdAt: r.createdAt,
1939 })),
1940 reviewCount,
1941 feed: feed.map((e) => ({
1942 kind: e.kind,
1943 repo: e.repo,
1944 ref: e.ref,
1945 at: e.at,
1946 })),
1947 };
1948 } catch {
1949 liveFeed = null;
1950 }
1951
29924bcClaude1952 void LandingPage;
f8e5feaccanty labs1953 void Landing2030Page;
1954 return c.html(
1955 "<!DOCTYPE html>" +
1956 String(
1957 <LandingProPage
1958 stats={stats}
1959 publicStats={publicStats}
1960 liveFeed={liveFeed}
1961 />
1962 )
1963 );
fc1817aClaude1964});
1965
06d5ffeClaude1966// New repository form
1967web.get("/new", requireAuth, (c) => {
1968 const user = c.get("user")!;
1969 const error = c.req.query("error");
1970
1971 return c.html(
1972 <Layout title="New repository" user={user}>
efb11c5Claude1973 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1974 <div class="new-repo-hero">
1975 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1976 <div class="new-repo-hero-orb" />
1977 </div>
1978 <div class="new-repo-hero-inner">
1979 <div class="new-repo-eyebrow">
1980 <strong>Create</strong> · {user.username}
1981 </div>
1982 <h1 class="new-repo-title">
1983 Spin up a <span class="gradient-text">repository</span>.
1984 </h1>
1985 <p class="new-repo-sub">
1986 Push your first commit, and Gluecron wires up gate checks, AI review,
1987 and auto-merge from the moment your branch lands.
1988 </p>
1989 </div>
1990 </div>
06d5ffeClaude1991 <div class="new-repo-form">
efb11c5Claude1992 {error && (
1993 <div class="new-repo-error" role="alert">
1994 {decodeURIComponent(error)}
1995 </div>
1996 )}
1997 <form method="post" action="/new" class="new-repo-form-grid">
1998 <div class="new-repo-row">
1999 <label class="new-repo-label">Owner</label>
2000 <input
2001 type="text"
2002 value={user.username}
2003 disabled
2004 aria-label="Owner"
2005 class="new-repo-input new-repo-input-disabled"
2006 />
06d5ffeClaude2007 </div>
efb11c5Claude2008 <div class="new-repo-row">
2009 <label class="new-repo-label" for="name">
2010 Repository name
2011 </label>
06d5ffeClaude2012 <input
2013 type="text"
2014 id="name"
2015 name="name"
2016 required
2017 pattern="^[a-zA-Z0-9._-]+$"
2018 placeholder="my-project"
2019 autocomplete="off"
efb11c5Claude2020 class="new-repo-input"
06d5ffeClaude2021 />
efb11c5Claude2022 <p class="new-repo-hint">
2023 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2024 <code>{user.username}/&lt;name&gt;</code>.
2025 </p>
06d5ffeClaude2026 </div>
efb11c5Claude2027 <div class="new-repo-row">
2028 <label class="new-repo-label" for="description">
2029 Description{" "}
2030 <span class="new-repo-label-optional">(optional)</span>
2031 </label>
06d5ffeClaude2032 <input
2033 type="text"
2034 id="description"
2035 name="description"
2036 placeholder="A short description of your repository"
efb11c5Claude2037 class="new-repo-input"
06d5ffeClaude2038 />
2039 </div>
efb11c5Claude2040 <div class="new-repo-row">
2041 <span class="new-repo-label">Visibility</span>
2042 <div class="new-repo-visibility">
2043 <label class="new-repo-vis-card">
2044 <input
2045 type="radio"
2046 name="visibility"
2047 value="public"
2048 checked
2049 class="new-repo-vis-radio"
2050 />
2051 <span class="new-repo-vis-body">
2052 <span class="new-repo-vis-label">Public</span>
2053 <span class="new-repo-vis-desc">
2054 Anyone can see this repository. You choose who can commit.
2055 </span>
2056 </span>
2057 </label>
2058 <label class="new-repo-vis-card">
2059 <input
2060 type="radio"
2061 name="visibility"
2062 value="private"
2063 class="new-repo-vis-radio"
2064 />
2065 <span class="new-repo-vis-body">
2066 <span class="new-repo-vis-label">Private</span>
2067 <span class="new-repo-vis-desc">
2068 Only you (and collaborators you invite) can see this
2069 repository.
2070 </span>
2071 </span>
2072 </label>
2073 </div>
2074 </div>
398a10cClaude2075 <div class="new-repo-row">
2076 <span class="new-repo-label">
2077 Starter content{" "}
2078 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2079 </span>
2080 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2081 <label class="new-repo-template-chip">
2082 <input type="radio" name="starter" value="empty" checked />
2083 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2084 Empty
2085 </label>
2086 <label class="new-repo-template-chip">
2087 <input type="radio" name="starter" value="readme" />
2088 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2089 README
2090 </label>
2091 <label class="new-repo-template-chip">
2092 <input type="radio" name="starter" value="readme-mit" />
2093 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2094 README + MIT
2095 </label>
2096 <label class="new-repo-template-chip">
2097 <input type="radio" name="starter" value="node" />
2098 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2099 Node + .gitignore
2100 </label>
2101 </div>
2102 <p class="new-repo-hint">
2103 Just a UI hint — push your own commits to fill the repo.
2104 </p>
2105 </div>
44f1a02Claude2106 <div class="new-repo-row">
2107 <label class="new-repo-label" for="data_region">
2108 Data region
2109 </label>
2110 <select
2111 id="data_region"
2112 name="data_region"
2113 class="new-repo-input"
2114 style="cursor: pointer;"
2115 >
2116 <option value="us" selected>US (default)</option>
2117 <option value="eu">EU (Frankfurt)</option>
2118 </select>
2119 <p class="new-repo-hint">
2120 EU data residency requires a{" "}
2121 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2122 Pro plan or higher
2123 </a>
2124 . Repositories cannot be moved between regions after creation.
2125 </p>
2126 </div>
efb11c5Claude2127 <div class="new-repo-callout">
2128 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2129 <p class="new-repo-callout-body">
2130 Every push is gate-checked and reviewed by Claude automatically.
2131 Label an issue <code>ai-build</code> and Gluecron will open the PR
2132 for you.
2133 </p>
2134 </div>
2135 <div class="new-repo-actions">
398a10cClaude2136 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2137 Create repository
2138 </button>
2139 <a href="/dashboard" class="btn new-repo-cancel">
2140 Cancel
2141 </a>
06d5ffeClaude2142 </div>
2143 </form>
2144 </div>
2145 </Layout>
2146 );
2147});
2148
2149web.post("/new", requireAuth, async (c) => {
2150 const user = c.get("user")!;
2151 const body = await c.req.parseBody();
2152 const name = String(body.name || "").trim();
2153 const description = String(body.description || "").trim();
2154 const isPrivate = body.visibility === "private";
44f1a02Claude2155 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2156
2157 if (!name) {
2158 return c.redirect("/new?error=Repository+name+is+required");
2159 }
2160
c63b860Claude2161 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2162 // outage never blocks repo creation.
2163 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2164 const gate = await checkRepoCreateAllowed(user.id);
2165 if (!gate.ok) {
2166 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2167 }
2168
06d5ffeClaude2169 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2170 return c.redirect("/new?error=Invalid+repository+name");
2171 }
2172
2173 if (await repoExists(user.username, name)) {
2174 return c.redirect("/new?error=Repository+already+exists");
2175 }
2176
2177 const diskPath = await initBareRepo(user.username, name);
2178
3ef4c9dClaude2179 const [newRepo] = await db
2180 .insert(repositories)
2181 .values({
2182 name,
2183 ownerId: user.id,
2184 description: description || null,
2185 isPrivate,
2186 diskPath,
44f1a02Claude2187 dataRegion,
3ef4c9dClaude2188 })
2189 .returning();
2190
2191 if (newRepo) {
2192 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2193 await bootstrapRepository({
2194 repositoryId: newRepo.id,
2195 ownerUserId: user.id,
2196 defaultBranch: "main",
2197 });
2198 }
06d5ffeClaude2199
2200 return c.redirect(`/${user.username}/${name}`);
2201});
2202
11c3ab6ccanty labs2203// Daily brief — GET /brief
2204web.get("/brief", (c) => {
2205 const user = c.get("user");
8ed88f2ccantynz-alt2206 return c.html(
2207 <DailyBrief
2208 user={user ? { name: user.displayName || user.username } : undefined}
2209 />
2210 );
11c3ab6ccanty labs2211});
2212
2213// Trust report — GET /trust (public)
2214web.get("/trust", (c) => {
2215 return c.html(<TrustReport />);
2216});
2217
2218// Production layers — GET /layers
2219web.get("/layers", (c) => {
2220 const user = c.get("user");
2221 return c.html(<ProductionLayers user={user} />);
2222});
2223
2224// Distribution — GET /distribute
2225web.get("/distribute", (c) => {
2226 const user = c.get("user");
2227 return c.html(<DistributionView user={user} />);
2228});
2229
06d5ffeClaude2230// User profile
fc1817aClaude2231web.get("/:owner", async (c) => {
06d5ffeClaude2232 const { owner: ownerName } = c.req.param();
2233 const user = c.get("user");
2234
2235 // Avoid clashing with fixed routes
2236 if (
2237 ["login", "register", "logout", "new", "settings", "api"].includes(
2238 ownerName
2239 )
2240 ) {
2241 return c.notFound();
2242 }
2243
2244 let ownerUser;
2245 try {
2246 const [found] = await db
2247 .select()
2248 .from(users)
2249 .where(eq(users.username, ownerName))
2250 .limit(1);
2251 ownerUser = found;
2252 } catch {
2253 // DB not available — check if repos exist on disk
2254 ownerUser = null;
2255 }
2256
2257 // Even without DB, show repos if they exist on disk
2258 let repos: any[] = [];
2259 if (ownerUser) {
2260 const allRepos = await db
2261 .select()
2262 .from(repositories)
2263 .where(eq(repositories.ownerId, ownerUser.id))
2264 .orderBy(desc(repositories.updatedAt));
2265
2266 // Show public repos to everyone, private only to owner
2267 repos =
2268 user?.id === ownerUser.id
2269 ? allRepos
2270 : allRepos.filter((r) => !r.isPrivate);
2271 }
2272
7aa8b99Claude2273 // Block J4 — follow counts + viewer's follow state
2274 let followState = {
2275 followers: 0,
2276 following: 0,
2277 viewerFollows: false,
2278 };
2279 if (ownerUser) {
2280 try {
2281 const { followCounts, isFollowing } = await import("../lib/follows");
2282 const counts = await followCounts(ownerUser.id);
2283 followState.followers = counts.followers;
2284 followState.following = counts.following;
2285 if (user && user.id !== ownerUser.id) {
2286 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2287 }
2288 } catch {
2289 // DB hiccup — fall back to zeros.
2290 }
2291 }
2292 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2293
d412586Claude2294 // Block J5 — profile README. Render owner/owner repo's README on the
2295 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2296 // back to "<user>/.github" for org-style profile repos.
2297 let profileReadmeHtml: string | null = null;
2298 try {
2299 const candidates = [ownerName, ".github"];
2300 for (const rname of candidates) {
2301 if (await repoExists(ownerName, rname)) {
2302 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2303 const md = await getReadme(ownerName, rname, ref);
2304 if (md) {
2305 profileReadmeHtml = renderMarkdown(md);
2306 break;
2307 }
2308 }
2309 }
2310 } catch {
2311 profileReadmeHtml = null;
2312 }
2313
efb11c5Claude2314 const displayName = ownerUser?.displayName || ownerName;
2315 const memberSince = ownerUser?.createdAt
2316 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2317 : null;
2318
fc1817aClaude2319 return c.html(
06d5ffeClaude2320 <Layout title={ownerName} user={user}>
efb11c5Claude2321 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2322 <div class="profile-hero">
2323 <div class="profile-hero-orb-wrap" aria-hidden="true">
2324 <div class="profile-hero-orb" />
06d5ffeClaude2325 </div>
efb11c5Claude2326 <div class="profile-hero-inner">
2327 <div class="profile-hero-avatar" aria-hidden="true">
2328 {displayName[0].toUpperCase()}
2329 </div>
2330 <div class="profile-hero-text">
2331 <div class="profile-eyebrow">
2332 <strong>Developer</strong>
2333 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2334 <>
2335 {" "}· Joined{" "}
2336 {memberSince.toLocaleDateString("en-US", {
2337 month: "short",
2338 year: "numeric",
2339 })}
2340 </>
2341 )}
2342 </div>
2343 <h1 class="profile-name">
2344 <span class="gradient-text">{displayName}</span>
2345 </h1>
2346 <div class="profile-handle">@{ownerName}</div>
2347 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2348 <div class="profile-meta">
2349 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2350 <strong>{followState.followers}</strong> follower
2351 {followState.followers === 1 ? "" : "s"}
2352 </a>
2353 <a href={`/${ownerName}/following`} class="profile-meta-link">
2354 <strong>{followState.following}</strong> following
2355 </a>
2356 <a href={`/${ownerName}`} class="profile-meta-link">
2357 <strong>{repos.length}</strong> repo
2358 {repos.length === 1 ? "" : "s"}
2359 </a>
2360 {canFollow && (
2361 <form
2362 method="post"
2363 action={`/${ownerName}/${
2364 followState.viewerFollows ? "unfollow" : "follow"
2365 }`}
2366 class="profile-follow-form"
7aa8b99Claude2367 >
efb11c5Claude2368 <button
2369 type="submit"
2370 class={`btn ${
2371 followState.viewerFollows ? "" : "btn-primary"
2372 } btn-sm`}
2373 >
2374 {followState.viewerFollows ? "Unfollow" : "Follow"}
2375 </button>
2376 </form>
2377 )}
2378 </div>
7aa8b99Claude2379 </div>
06d5ffeClaude2380 </div>
2381 </div>
d412586Claude2382 {profileReadmeHtml && (
efb11c5Claude2383 <div class="profile-readme">
2384 <div class="profile-readme-head">
2385 <span class="profile-readme-icon">{"☰"}</span>
2386 <span>{ownerName}/{ownerName} README.md</span>
2387 </div>
2388 <div
2389 class="markdown-body profile-readme-body"
2390 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2391 />
2392 </div>
d412586Claude2393 )}
efb11c5Claude2394 <div class="profile-section-head">
2395 <h2 class="profile-section-title">Repositories</h2>
2396 <span class="profile-section-count">{repos.length}</span>
2397 </div>
06d5ffeClaude2398 {repos.length === 0 ? (
efb11c5Claude2399 <div class="profile-empty">
2400 <p class="profile-empty-text">
2401 No repositories yet
2402 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2403 </p>
2404 {user?.id === ownerUser?.id && (
2405 <a href="/new" class="btn btn-primary btn-sm">
2406 + Create your first
2407 </a>
2408 )}
2409 </div>
06d5ffeClaude2410 ) : (
2411 <div class="card-grid">
2412 {repos.map((repo) => (
2413 <RepoCard repo={repo} ownerName={ownerName} />
2414 ))}
2415 </div>
2416 )}
fc1817aClaude2417 </Layout>
2418 );
2419});
2420
06d5ffeClaude2421// Star/unstar a repo
2422web.post("/:owner/:repo/star", requireAuth, async (c) => {
2423 const { owner: ownerName, repo: repoName } = c.req.param();
2424 const user = c.get("user")!;
2425
2426 try {
2427 const [ownerUser] = await db
2428 .select()
2429 .from(users)
2430 .where(eq(users.username, ownerName))
2431 .limit(1);
2432 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2433
2434 const [repo] = await db
2435 .select()
2436 .from(repositories)
2437 .where(
2438 and(
2439 eq(repositories.ownerId, ownerUser.id),
2440 eq(repositories.name, repoName)
2441 )
2442 )
2443 .limit(1);
2444 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2445
2446 // Toggle star
2447 const [existing] = await db
2448 .select()
2449 .from(stars)
2450 .where(
2451 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2452 )
2453 .limit(1);
2454
2455 if (existing) {
2456 await db.delete(stars).where(eq(stars.id, existing.id));
2457 await db
2458 .update(repositories)
2459 .set({ starCount: Math.max(0, repo.starCount - 1) })
2460 .where(eq(repositories.id, repo.id));
2461 } else {
2462 await db.insert(stars).values({
2463 userId: user.id,
2464 repositoryId: repo.id,
2465 });
2466 await db
2467 .update(repositories)
2468 .set({ starCount: repo.starCount + 1 })
2469 .where(eq(repositories.id, repo.id));
a74f4edccanty labs2470 void fireWebhooks(repo.id, "star", { action: "created" });
06d5ffeClaude2471 }
2472 } catch {
2473 // DB error — ignore
2474 }
2475
2476 return c.redirect(`/${ownerName}/${repoName}`);
2477});
2478
641aa42Claude2479// ---------------------------------------------------------------------------
2480// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2481// ---------------------------------------------------------------------------
2482const obCss = `
2483 .ob-card {
2484 position: relative;
2485 margin: 14px 0 18px;
6fd5915Claude2486 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2487 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2488 border-radius: 14px;
2489 overflow: hidden;
2490 }
2491 .ob-card::before {
2492 content: '';
2493 position: absolute;
2494 top: 0; left: 0; right: 0;
2495 height: 2px;
6fd5915Claude2496 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2497 pointer-events: none;
2498 }
2499 .ob-header {
2500 padding: 16px 20px 10px;
2501 border-bottom: 1px solid var(--border);
2502 }
2503 .ob-header h3 {
2504 font-size: 16px;
2505 font-weight: 700;
2506 margin: 0 0 4px;
2507 color: var(--text-strong);
2508 }
2509 .ob-header p {
2510 font-size: 13px;
2511 color: var(--text-muted);
2512 margin: 0;
2513 }
2514 .ob-sections {
2515 display: grid;
2516 grid-template-columns: repeat(3, 1fr);
2517 gap: 0;
2518 }
2519 @media (max-width: 760px) {
2520 .ob-sections { grid-template-columns: 1fr; }
2521 }
2522 .ob-section {
2523 padding: 14px 20px;
2524 border-right: 1px solid var(--border);
2525 }
2526 .ob-section:last-child { border-right: none; }
2527 .ob-section h4 {
2528 font-size: 12px;
2529 font-weight: 700;
2530 text-transform: uppercase;
2531 letter-spacing: 0.08em;
2532 color: var(--accent);
2533 margin: 0 0 8px;
2534 }
2535 .ob-preview {
2536 font-family: var(--font-mono);
2537 font-size: 11.5px;
2538 background: var(--bg);
2539 border: 1px solid var(--border);
2540 border-radius: 6px;
2541 padding: 8px 10px;
2542 color: var(--text-muted);
2543 line-height: 1.55;
2544 margin-bottom: 8px;
2545 white-space: pre-wrap;
2546 overflow: hidden;
2547 max-height: 80px;
2548 position: relative;
2549 }
2550 .ob-preview::after {
2551 content: '';
2552 position: absolute;
2553 bottom: 0; left: 0; right: 0;
2554 height: 24px;
2555 background: linear-gradient(transparent, var(--bg));
2556 pointer-events: none;
2557 }
2558 .ob-labels {
2559 display: flex;
2560 flex-wrap: wrap;
2561 gap: 5px;
2562 margin-bottom: 8px;
2563 }
2564 .ob-label-chip {
2565 display: inline-flex;
2566 align-items: center;
2567 padding: 2px 8px;
2568 border-radius: 9999px;
2569 font-size: 11.5px;
2570 font-weight: 600;
2571 line-height: 1.5;
2572 border: 1px solid transparent;
2573 }
2574 .ob-section ul {
2575 margin: 0;
2576 padding: 0 0 0 16px;
2577 font-size: 13px;
2578 color: var(--text-muted);
2579 line-height: 1.7;
2580 }
2581 .ob-section ul li { margin-bottom: 2px; }
2582 .ob-footer {
2583 display: flex;
2584 align-items: center;
2585 justify-content: flex-end;
2586 padding: 10px 20px;
2587 border-top: 1px solid var(--border);
2588 gap: 10px;
2589 }
2590 .ob-dismiss {
2591 appearance: none;
2592 background: transparent;
2593 border: 1px solid var(--border);
2594 color: var(--text-muted);
2595 border-radius: 6px;
2596 padding: 5px 12px;
2597 font-size: 12.5px;
2598 font-family: inherit;
2599 cursor: pointer;
2600 transition: background var(--t-fast), color var(--t-fast);
2601 }
2602 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2603 .btn-sm {
2604 appearance: none;
2605 background: var(--bg-elevated);
2606 border: 1px solid var(--border);
2607 color: var(--text-strong);
2608 border-radius: 6px;
2609 padding: 5px 12px;
2610 font-size: 12.5px;
2611 font-weight: 600;
2612 font-family: inherit;
2613 cursor: pointer;
2614 text-decoration: none;
2615 display: inline-flex;
2616 align-items: center;
2617 transition: background var(--t-fast);
2618 }
2619 .btn-sm:hover { background: var(--bg-hover); }
2620 .btn-sm.btn-primary {
2621 background: var(--accent);
2622 color: #fff;
2623 border-color: var(--accent);
2624 }
2625 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2626`;
2627
2628// Onboarding card component — shown to repo owner until dismissed
2629function RepoOnboardingCard({
2630 owner,
2631 repo,
2632 data,
2633}: {
2634 owner: string;
2635 repo: string;
2636 data: typeof repoOnboardingData.$inferSelect;
2637}) {
2638 const labels = (data.suggestedLabels ?? []) as Array<{
2639 name: string;
2640 color: string;
2641 description: string;
2642 }>;
2643 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2644 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2645
2646 return (
2647 <>
2648 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2649 <div class="ob-card" id="repo-onboarding">
2650 <div class="ob-header">
2651 <h3>Get started with {owner}/{repo}</h3>
2652 <p>
2653 Detected: {data.detectedLanguage ?? "Unknown"}
2654 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2655 Here&rsquo;s what we suggest to hit the ground running.
2656 </p>
2657 </div>
2658 <div class="ob-sections">
2659 <div class="ob-section">
2660 <h4>Suggested README</h4>
2661 <div class="ob-preview">{readmePreview}</div>
2662 <button
2663 class="btn-sm"
2664 type="button"
2665 onclick={`(function(){var t=${JSON.stringify(data.suggestedReadme ?? "")};try{navigator.clipboard.writeText(t).then(function(){var b=document.querySelector('.ob-copy-btn');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy to clipboard';},2000);}});}catch(_){};})()`}
2666 aria-label="Copy suggested README to clipboard"
2667 >
2668 Copy to clipboard
2669 </button>
2670 </div>
2671 <div class="ob-section">
2672 <h4>Suggested labels ({labels.length})</h4>
2673 <div class="ob-labels">
2674 {labels.slice(0, 6).map((l) => (
2675 <span
2676 class="ob-label-chip"
2677 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2678 title={l.description}
2679 >
2680 {l.name}
2681 </span>
2682 ))}
2683 </div>
2684 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2685 <button class="btn-sm btn-primary" type="submit">
2686 Create all labels
2687 </button>
2688 </form>
2689 </div>
2690 <div class="ob-section">
2691 <h4>First steps</h4>
2692 <ul>
2693 {suggestions.map((s) => (
2694 <li>{s}</li>
2695 ))}
2696 </ul>
2697 </div>
2698 </div>
2699 <div class="ob-footer">
2700 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2701 <button class="ob-dismiss" type="submit">
2702 Dismiss
2703 </button>
2704 </form>
2705 </div>
2706 <script
2707 dangerouslySetInnerHTML={{
2708 __html: `
2709 (function(){
2710 var card = document.getElementById('repo-onboarding');
2711 if (!card) return;
2712 document.addEventListener('keydown', function(e){
2713 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2714 card.style.display = 'none';
2715 }
2716 });
2717 })();
2718 `,
2719 }}
2720 />
2721 </div>
2722 </>
2723 );
2724}
2725
2726// ---------------------------------------------------------------------------
2727// Setup routes — create suggested labels + dismiss onboarding
2728// ---------------------------------------------------------------------------
2729
2730// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2731// requireAuth + write access. Idempotent (label UPSERT by name).
2732web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2733 const { owner, repo } = c.req.param();
2734 const user = c.get("user")!;
2735
2736 // Resolve repo + verify write access
2737 let repoRow: { id: string; ownerId: string } | null = null;
2738 try {
2739 const [ownerUser] = await db
2740 .select({ id: users.id })
2741 .from(users)
2742 .where(eq(users.username, owner))
2743 .limit(1);
2744 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2745 const [r] = await db
2746 .select({ id: repositories.id, ownerId: repositories.ownerId })
2747 .from(repositories)
2748 .where(
2749 and(
2750 eq(repositories.ownerId, ownerUser.id),
2751 eq(repositories.name, repo)
2752 )
2753 )
2754 .limit(1);
2755 repoRow = r ?? null;
2756 } catch {
2757 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2758 }
2759 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2760 if (repoRow.ownerId !== user.id) {
2761 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2762 }
2763
2764 // Fetch the onboarding data
2765 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2766 try {
2767 const [r] = await db
2768 .select()
2769 .from(repoOnboardingData)
2770 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2771 .limit(1);
2772 obRow = r ?? null;
2773 } catch {
2774 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2775 }
2776 if (!obRow) {
2777 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2778 }
2779
2780 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2781 name: string;
2782 color: string;
2783 description: string;
2784 }>;
2785
2786 // Create labels — import the labels table which was already imported
2787 let created = 0;
2788 for (const l of suggestedLabels) {
2789 try {
2790 await db
2791 .insert(labels)
2792 .values({
2793 repositoryId: repoRow.id,
2794 name: l.name,
2795 color: l.color,
2796 description: l.description ?? "",
2797 })
2798 .onConflictDoNothing();
2799 created++;
2800 } catch {
2801 /* skip duplicates */
2802 }
2803 }
2804
2805 // Mark onboarding dismissed
2806 try {
2807 await db
2808 .update(repositories)
2809 .set({ onboardingShown: true })
2810 .where(eq(repositories.id, repoRow.id));
2811 } catch {
2812 /* ignore */
2813 }
2814
2815 return c.redirect(
2816 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2817 );
2818});
2819
2820// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2821web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2822 const { owner, repo } = c.req.param();
2823 const user = c.get("user")!;
2824
2825 try {
2826 const [ownerUser] = await db
2827 .select({ id: users.id })
2828 .from(users)
2829 .where(eq(users.username, owner))
2830 .limit(1);
2831 if (ownerUser) {
2832 const [r] = await db
2833 .select({ id: repositories.id, ownerId: repositories.ownerId })
2834 .from(repositories)
2835 .where(
2836 and(
2837 eq(repositories.ownerId, ownerUser.id),
2838 eq(repositories.name, repo)
2839 )
2840 )
2841 .limit(1);
2842 if (r && r.ownerId === user.id) {
2843 await db
2844 .update(repositories)
2845 .set({ onboardingShown: true })
2846 .where(eq(repositories.id, r.id));
2847 }
2848 }
2849 } catch {
2850 /* swallow — dismiss should never fail visibly */
2851 }
2852
2853 return c.redirect(`/${owner}/${repo}`);
2854});
2855
11c3ab6ccanty labs2856// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2857web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2858 const { owner, repo } = c.req.param();
2859 const user = c.get("user");
5bb52faccanty labs2860 const gate = await assertRepoReadable(c, owner, repo);
2861 if (gate) return gate;
11c3ab6ccanty labs2862 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2863});
2864
2865// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2866web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2867 const { owner, repo } = c.req.param();
2868 const user = c.get("user");
5bb52faccanty labs2869 const gate = await assertRepoReadable(c, owner, repo);
2870 if (gate) return gate;
11c3ab6ccanty labs2871 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2872});
2873
fc1817aClaude2874// Repository overview — file tree at HEAD
2875web.get("/:owner/:repo", async (c) => {
2876 const { owner, repo } = c.req.param();
06d5ffeClaude2877 const user = c.get("user");
fc1817aClaude2878
5bb52faccanty labs2879 // SECURITY: gate private-repo content before any render (incl. skeleton).
2880 const gate = await assertRepoReadable(c, owner, repo);
2881 if (gate) return gate;
2882
f1dc7c7Claude2883 // ── Loading skeleton (flag-gated) ──
2884 // Renders an SSR'd shell with file-tree + README placeholders when
2885 // `?skeleton=1` is set. Lets the user see the page structure before
2886 // git ops finish. Behind a flag for now so we never flash before the
2887 // real content lands.
2888 if (c.req.query("skeleton") === "1") {
2889 return c.html(
2890 <Layout title={`${owner}/${repo}`} user={user}>
2891 <style
2892 dangerouslySetInnerHTML={{
2893 __html: `
2894 .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; }
2895 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2896 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2897 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2898 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2899 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2900 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2901 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2902 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2903 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2904 .repo-skel-readme { height: 320px; border-radius: 12px; }
2905 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2906 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2907 `,
2908 }}
2909 />
2910 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2911 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2912 <div class="repo-skel-grid" aria-hidden="true">
2913 <div>
2914 <div class="repo-skel repo-skel-branch" />
2915 <div class="repo-skel-tree">
2916 {Array.from({ length: 8 }).map(() => (
2917 <div class="repo-skel repo-skel-tree-row" />
2918 ))}
2919 </div>
2920 <div class="repo-skel repo-skel-readme" />
2921 </div>
2922 <aside class="repo-skel-side">
2923 <div class="repo-skel repo-skel-side-card" />
2924 <div class="repo-skel repo-skel-side-card" />
2925 </aside>
2926 </div>
2927 <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">
2928 Loading {owner}/{repo}…
2929 </span>
2930 </Layout>
2931 );
2932 }
2933
8f50ed0Claude2934 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2935 trackByName(owner, repo, "view", {
2936 userId: user?.id || null,
2937 path: `/${owner}/${repo}`,
2938 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2939 userAgent: c.req.header("user-agent") || null,
2940 referer: c.req.header("referer") || null,
a28cedeClaude2941 }).catch((err) => {
2942 console.warn(
2943 `[web] view tracking failed for ${owner}/${repo}:`,
2944 err instanceof Error ? err.message : err
2945 );
2946 });
8f50ed0Claude2947
fc1817aClaude2948 if (!(await repoExists(owner, repo))) {
2949 return c.html(
06d5ffeClaude2950 <Layout title="Not Found" user={user}>
fc1817aClaude2951 <div class="empty-state">
2952 <h2>Repository not found</h2>
2953 <p>
2954 {owner}/{repo} does not exist.
2955 </p>
2956 </div>
2957 </Layout>,
2958 404
2959 );
2960 }
2961
05b973eClaude2962 // Parallelize all independent operations
2963 const [defaultBranch, branches] = await Promise.all([
2964 getDefaultBranch(owner, repo).then((b) => b || "main"),
2965 listBranches(owner, repo),
2966 ]);
2967 const [tree, starInfo] = await Promise.all([
2968 getTree(owner, repo, defaultBranch),
2969 // Star info fetched in parallel with tree
2970 (async () => {
2971 try {
2972 const [ownerUser] = await db
2973 .select()
2974 .from(users)
2975 .where(eq(users.username, owner))
2976 .limit(1);
71cd5ecClaude2977 if (!ownerUser)
2978 return {
2979 starCount: 0,
2980 starred: false,
2981 archived: false,
2982 isTemplate: false,
544d842Claude2983 forkCount: 0,
2984 description: null as string | null,
2985 pushedAt: null as Date | null,
2986 createdAt: null as Date | null,
cb5a796Claude2987 repoId: null as string | null,
2988 repoOwnerId: null as string | null,
71cd5ecClaude2989 };
05b973eClaude2990 const [repoRow] = await db
2991 .select()
2992 .from(repositories)
2993 .where(
2994 and(
2995 eq(repositories.ownerId, ownerUser.id),
2996 eq(repositories.name, repo)
2997 )
06d5ffeClaude2998 )
05b973eClaude2999 .limit(1);
71cd5ecClaude3000 if (!repoRow)
3001 return {
3002 starCount: 0,
3003 starred: false,
3004 archived: false,
3005 isTemplate: false,
544d842Claude3006 forkCount: 0,
3007 description: null as string | null,
3008 pushedAt: null as Date | null,
3009 createdAt: null as Date | null,
cb5a796Claude3010 repoId: null as string | null,
3011 repoOwnerId: null as string | null,
71cd5ecClaude3012 };
05b973eClaude3013 let starred = false;
06d5ffeClaude3014 if (user) {
3015 const [star] = await db
3016 .select()
3017 .from(stars)
3018 .where(
3019 and(
3020 eq(stars.userId, user.id),
3021 eq(stars.repositoryId, repoRow.id)
3022 )
3023 )
3024 .limit(1);
3025 starred = !!star;
3026 }
71cd5ecClaude3027 return {
3028 starCount: repoRow.starCount,
3029 starred,
3030 archived: repoRow.isArchived,
3031 isTemplate: repoRow.isTemplate,
544d842Claude3032 forkCount: repoRow.forkCount,
3033 description: repoRow.description as string | null,
3034 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3035 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3036 repoId: repoRow.id as string,
3037 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3038 };
05b973eClaude3039 } catch {
71cd5ecClaude3040 return {
3041 starCount: 0,
3042 starred: false,
3043 archived: false,
3044 isTemplate: false,
544d842Claude3045 forkCount: 0,
3046 description: null as string | null,
3047 pushedAt: null as Date | null,
3048 createdAt: null as Date | null,
cb5a796Claude3049 repoId: null as string | null,
3050 repoOwnerId: null as string | null,
71cd5ecClaude3051 };
06d5ffeClaude3052 }
05b973eClaude3053 })(),
3054 ]);
544d842Claude3055 const {
3056 starCount,
3057 starred,
3058 archived,
3059 isTemplate,
3060 forkCount,
3061 description,
3062 pushedAt,
3063 createdAt,
cb5a796Claude3064 repoId,
3065 repoOwnerId,
544d842Claude3066 } = starInfo;
3067
91a0204Claude3068 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3069 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3070 // and the badge simply doesn't render.
3071 let healthScore: HealthScore | null = null;
3072 if (repoId) {
3073 try {
3074 healthScore = await computeHealthScore(repoId);
3075 } catch {
3076 // swallow — badge is optional
3077 }
3078 }
3079
cb5a796Claude3080 // Pending-comments banner data (lazy + best-effort). Only the repo
3081 // owner sees the banner, so non-owner views skip the DB hit entirely.
3082 let repoHomePendingCount = 0;
3083 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3084 try {
3085 const { countPendingForRepo } = await import(
3086 "../lib/comment-moderation"
3087 );
3088 repoHomePendingCount = await countPendingForRepo(repoId);
3089 } catch {
3090 /* swallow */
3091 }
3092 }
3093
8c790e0Claude3094 // Push Watch discoverability — fetch the most recent push within 24 h.
3095 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3096 const recentPush: RecentPush | null = repoId
3097 ? await getRecentPush(repoId)
3098 : null;
3099
ebaae0fClaude3100 // AI stats strip — best-effort, fail-open to zero values.
3101 let aiStats: RepoAiStats = {
3102 mergedCount: 0,
3103 reviewCount: 0,
3104 securityAlertCount: 0,
3105 hoursSaved: "0.0",
3106 };
3107 if (repoId) {
3108 try {
3109 aiStats = await getRepoAiStats(repoId);
3110 } catch {
3111 /* swallow — show zero values */
3112 }
3113 }
3114
641aa42Claude3115 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3116 // if the DB is down the card simply won't appear. Only the owner sees it.
3117 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3118 let showOnboarding = false;
3119 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3120 try {
3121 // Check if onboarding_shown is still false (not yet dismissed)
3122 const [repoRow2] = await db
3123 .select({ onboardingShown: repositories.onboardingShown })
3124 .from(repositories)
3125 .where(eq(repositories.id, repoId))
3126 .limit(1);
3127 if (repoRow2 && !repoRow2.onboardingShown) {
3128 const [obRow] = await db
3129 .select()
3130 .from(repoOnboardingData)
3131 .where(eq(repoOnboardingData.repositoryId, repoId))
3132 .limit(1);
3133 onboardingRow = obRow ?? null;
3134 showOnboarding = !!onboardingRow;
3135 }
3136 } catch {
3137 /* swallow — onboarding is optional */
3138 }
3139 }
3140
544d842Claude3141 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3142 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3143 const repoHomeCss = `
3144 .repo-home-hero {
3145 position: relative;
3146 margin-bottom: var(--space-5);
3147 padding: var(--space-5) var(--space-6);
3148 background: var(--bg-elevated);
3149 border: 1px solid var(--border);
3150 border-radius: 16px;
3151 overflow: hidden;
3152 }
3153 .repo-home-hero::before {
3154 content: '';
3155 position: absolute;
3156 top: 0; left: 0; right: 0;
3157 height: 2px;
6fd5915Claude3158 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3159 opacity: 0.7;
3160 pointer-events: none;
3161 }
3162 .repo-home-hero-orb-wrap {
3163 position: absolute;
3164 inset: -25% -10% auto auto;
3165 width: 360px;
3166 height: 360px;
3167 pointer-events: none;
3168 z-index: 0;
3169 }
3170 .repo-home-hero-orb {
3171 position: absolute;
3172 inset: 0;
6fd5915Claude3173 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3174 filter: blur(80px);
3175 opacity: 0.7;
3176 animation: repoHomeOrb 14s ease-in-out infinite;
3177 }
3178 @keyframes repoHomeOrb {
3179 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3180 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3181 }
3182 @media (prefers-reduced-motion: reduce) {
3183 .repo-home-hero-orb { animation: none; }
3184 }
3185 .repo-home-hero-inner {
3186 position: relative;
3187 z-index: 1;
3188 }
3189 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3190 .repo-home-eyebrow {
3191 font-size: 12px;
3192 font-family: var(--font-mono);
3193 color: var(--text-muted);
3194 letter-spacing: 0.1em;
3195 text-transform: uppercase;
3196 margin-bottom: var(--space-2);
3197 }
3198 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3199 .repo-home-description {
3200 font-size: 15px;
3201 line-height: 1.55;
3202 color: var(--text);
3203 margin: 0;
3204 max-width: 720px;
3205 }
3206 .repo-home-description-empty {
3207 font-size: 14px;
3208 color: var(--text-muted);
3209 font-style: italic;
3210 margin: 0;
3211 }
3212 .repo-home-stat-row {
3213 display: flex;
3214 flex-wrap: wrap;
3215 gap: var(--space-4);
3216 margin-top: var(--space-3);
3217 font-size: 13px;
3218 color: var(--text-muted);
3219 }
3220 .repo-home-stat {
3221 display: inline-flex;
3222 align-items: center;
3223 gap: 6px;
3224 }
3225 .repo-home-stat strong {
3226 color: var(--text-strong);
3227 font-weight: 600;
3228 font-variant-numeric: tabular-nums;
3229 }
3230 .repo-home-stat .repo-home-stat-icon {
3231 color: var(--text-faint);
3232 font-size: 14px;
3233 line-height: 1;
3234 }
3235 .repo-home-stat a {
3236 color: var(--text-muted);
3237 transition: color var(--t-fast) var(--ease);
3238 }
3239 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3240
3241 /* Two-column layout: file tree + sidebar */
3242 .repo-home-grid {
3243 display: grid;
3244 grid-template-columns: minmax(0, 1fr) 280px;
3245 gap: var(--space-5);
3246 align-items: start;
3247 }
3248 @media (max-width: 960px) {
3249 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3250 }
3251 .repo-home-main { min-width: 0; }
3252
3253 /* Sidebar card */
3254 .repo-home-side {
3255 display: flex;
3256 flex-direction: column;
3257 gap: var(--space-4);
3258 }
3259 .repo-home-side-card {
3260 background: var(--bg-elevated);
3261 border: 1px solid var(--border);
3262 border-radius: 12px;
3263 padding: var(--space-4);
3264 }
3265 .repo-home-side-title {
3266 font-size: 11px;
3267 font-family: var(--font-mono);
3268 letter-spacing: 0.12em;
3269 text-transform: uppercase;
3270 color: var(--text-muted);
3271 margin: 0 0 var(--space-3);
3272 font-weight: 600;
3273 }
3274 .repo-home-side-row {
3275 display: flex;
3276 justify-content: space-between;
3277 align-items: center;
3278 gap: var(--space-2);
3279 font-size: 13px;
3280 padding: 6px 0;
3281 border-top: 1px solid var(--border);
3282 }
3283 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3284 .repo-home-side-key {
3285 color: var(--text-muted);
3286 display: inline-flex;
3287 align-items: center;
3288 gap: 6px;
3289 }
3290 .repo-home-side-val {
3291 color: var(--text-strong);
3292 font-weight: 500;
3293 font-variant-numeric: tabular-nums;
3294 max-width: 60%;
3295 text-align: right;
3296 overflow: hidden;
3297 text-overflow: ellipsis;
3298 white-space: nowrap;
3299 }
3300 .repo-home-side-val a { color: var(--text-strong); }
3301 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3302
3303 /* Clone / Code tabs */
3304 .repo-home-clone {
3305 background: var(--bg-elevated);
3306 border: 1px solid var(--border);
3307 border-radius: 12px;
3308 overflow: hidden;
3309 }
3310 .repo-home-clone-tabs {
3311 display: flex;
3312 gap: 0;
3313 background: var(--bg-secondary);
3314 border-bottom: 1px solid var(--border);
3315 padding: 0 var(--space-2);
3316 }
3317 .repo-home-clone-tab {
3318 appearance: none;
3319 background: transparent;
3320 border: 0;
3321 border-bottom: 2px solid transparent;
3322 padding: 9px 12px;
3323 font-size: 12px;
3324 font-weight: 500;
3325 color: var(--text-muted);
3326 cursor: pointer;
3327 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3328 font-family: inherit;
3329 margin-bottom: -1px;
3330 }
3331 .repo-home-clone-tab:hover { color: var(--text-strong); }
3332 .repo-home-clone-tab[aria-selected="true"] {
3333 color: var(--text-strong);
3334 border-bottom-color: var(--accent);
3335 }
3336 .repo-home-clone-body {
3337 padding: var(--space-3);
3338 display: flex;
3339 align-items: center;
3340 gap: var(--space-2);
3341 }
3342 .repo-home-clone-input {
3343 flex: 1;
3344 min-width: 0;
3345 font-family: var(--font-mono);
3346 font-size: 12px;
3347 background: var(--bg);
3348 border: 1px solid var(--border);
3349 border-radius: 8px;
3350 padding: 8px 10px;
3351 color: var(--text-strong);
3352 overflow: hidden;
3353 text-overflow: ellipsis;
3354 white-space: nowrap;
3355 }
3356 .repo-home-clone-copy {
3357 appearance: none;
3358 background: var(--bg-secondary);
3359 border: 1px solid var(--border);
3360 color: var(--text-strong);
3361 border-radius: 8px;
3362 padding: 8px 12px;
3363 font-size: 12px;
3364 font-weight: 600;
3365 cursor: pointer;
3366 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3367 font-family: inherit;
3368 }
3369 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3370 .repo-home-clone-pane { display: none; }
3371 .repo-home-clone-pane[data-active="true"] { display: flex; }
3372
3373 /* README card */
3374 .repo-home-readme {
3375 margin-top: var(--space-5);
3376 background: var(--bg-elevated);
3377 border: 1px solid var(--border);
3378 border-radius: 12px;
3379 overflow: hidden;
3380 }
3381 .repo-home-readme-head {
3382 display: flex;
3383 align-items: center;
3384 gap: 8px;
3385 padding: 10px 16px;
3386 background: var(--bg-secondary);
3387 border-bottom: 1px solid var(--border);
3388 font-size: 13px;
3389 color: var(--text-muted);
3390 }
3391 .repo-home-readme-head .repo-home-readme-icon {
3392 color: var(--accent);
3393 font-size: 14px;
3394 }
3395 .repo-home-readme-body {
3396 padding: var(--space-5) var(--space-6);
3397 }
3398
3399 /* Empty-state CTA */
3400 .repo-home-empty {
3401 position: relative;
3402 margin-top: var(--space-4);
3403 background: var(--bg-elevated);
3404 border: 1px solid var(--border);
3405 border-radius: 16px;
3406 padding: var(--space-6);
3407 overflow: hidden;
3408 }
3409 .repo-home-empty::before {
3410 content: '';
3411 position: absolute;
3412 top: 0; left: 0; right: 0;
3413 height: 2px;
6fd5915Claude3414 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3415 opacity: 0.7;
3416 pointer-events: none;
3417 }
3418 .repo-home-empty-eyebrow {
3419 font-size: 12px;
3420 font-family: var(--font-mono);
3421 color: var(--accent);
3422 letter-spacing: 0.12em;
3423 text-transform: uppercase;
3424 margin-bottom: var(--space-2);
3425 font-weight: 600;
3426 }
3427 .repo-home-empty-title {
3428 font-family: var(--font-display);
3429 font-weight: 800;
3430 letter-spacing: -0.025em;
3431 font-size: clamp(22px, 3vw, 30px);
3432 line-height: 1.1;
3433 margin: 0 0 var(--space-2);
3434 color: var(--text-strong);
3435 }
3436 .repo-home-empty-sub {
3437 color: var(--text-muted);
3438 font-size: 14px;
3439 line-height: 1.55;
3440 max-width: 640px;
3441 margin: 0 0 var(--space-4);
3442 }
3443 .repo-home-empty-snippet {
3444 background: var(--bg);
3445 border: 1px solid var(--border);
3446 border-radius: 10px;
3447 padding: var(--space-3) var(--space-4);
3448 font-family: var(--font-mono);
3449 font-size: 12.5px;
3450 line-height: 1.7;
3451 color: var(--text-strong);
3452 overflow-x: auto;
3453 white-space: pre;
3454 margin: 0;
3455 }
3456 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3457 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3458
91a0204Claude3459 /* Health score badge */
3460 .repo-health-badge {
3461 display: inline-flex;
3462 align-items: center;
3463 gap: 5px;
3464 padding: 3px 10px;
3465 border-radius: 999px;
3466 font-size: 11.5px;
3467 font-weight: 700;
3468 font-family: var(--font-mono);
3469 text-decoration: none;
3470 border: 1px solid transparent;
3471 transition: filter 120ms ease, opacity 120ms ease;
3472 vertical-align: middle;
3473 }
3474 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3475 .repo-health-badge-elite {
3476 background: rgba(52,211,153,0.15);
e589f77ccantynz-alt3477 color: var(--green);
91a0204Claude3478 border-color: rgba(52,211,153,0.30);
3479 }
3480 .repo-health-badge-strong {
3481 background: rgba(96,165,250,0.15);
3482 color: #93c5fd;
3483 border-color: rgba(96,165,250,0.30);
3484 }
3485 .repo-health-badge-improving {
3486 background: rgba(251,191,36,0.15);
3487 color: #fde68a;
3488 border-color: rgba(251,191,36,0.30);
3489 }
3490 .repo-health-badge-needs-attention {
3491 background: rgba(248,113,113,0.15);
e589f77ccantynz-alt3492 color: var(--red);
91a0204Claude3493 border-color: rgba(248,113,113,0.30);
3494 }
3495
3496 /* Three-option empty-state panel */
3497 .repo-empty-options {
3498 display: grid;
3499 grid-template-columns: repeat(3, 1fr);
3500 gap: var(--space-3);
3501 margin-top: var(--space-5);
3502 }
3503 @media (max-width: 760px) {
3504 .repo-empty-options { grid-template-columns: 1fr; }
3505 }
3506 .repo-empty-option {
3507 position: relative;
3508 background: var(--bg-elevated);
3509 border: 1px solid var(--border);
3510 border-radius: 14px;
3511 padding: var(--space-4) var(--space-4);
3512 display: flex;
3513 flex-direction: column;
3514 gap: var(--space-2);
3515 overflow: hidden;
3516 transition: border-color 140ms ease, background 140ms ease;
3517 }
3518 .repo-empty-option:hover {
6fd5915Claude3519 border-color: rgba(91,110,232,0.45);
3520 background: rgba(91,110,232,0.04);
91a0204Claude3521 }
3522 .repo-empty-option::before {
3523 content: '';
3524 position: absolute;
3525 top: 0; left: 0; right: 0;
3526 height: 2px;
6fd5915Claude3527 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3528 opacity: 0.55;
3529 pointer-events: none;
3530 }
3531 .repo-empty-option-label {
3532 font-size: 10px;
3533 font-family: var(--font-mono);
3534 font-weight: 700;
3535 letter-spacing: 0.12em;
3536 text-transform: uppercase;
3537 color: var(--accent);
3538 margin-bottom: 2px;
3539 }
3540 .repo-empty-option-title {
3541 font-family: var(--font-display);
3542 font-weight: 700;
3543 font-size: 16px;
3544 letter-spacing: -0.01em;
3545 color: var(--text-strong);
3546 margin: 0;
3547 }
3548 .repo-empty-option-sub {
3549 font-size: 13px;
3550 color: var(--text-muted);
3551 line-height: 1.5;
3552 margin: 0;
3553 flex: 1;
3554 }
3555 .repo-empty-option-snippet {
3556 background: var(--bg);
3557 border: 1px solid var(--border);
3558 border-radius: 8px;
3559 padding: var(--space-2) var(--space-3);
3560 font-family: var(--font-mono);
3561 font-size: 11.5px;
3562 line-height: 1.75;
3563 color: var(--text-strong);
3564 overflow-x: auto;
3565 white-space: pre;
3566 margin: var(--space-1) 0 0;
3567 }
3568 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3569 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3570 .repo-empty-option-cta {
3571 display: inline-flex;
3572 align-items: center;
3573 gap: 6px;
3574 margin-top: auto;
3575 padding-top: var(--space-2);
3576 font-size: 13px;
3577 font-weight: 600;
3578 color: var(--accent);
3579 text-decoration: none;
3580 transition: color 120ms ease;
3581 }
3582 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3583
544d842Claude3584 @media (max-width: 720px) {
3585 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3586 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3587 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3588 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3589 .repo-home-side-val { max-width: 55%; }
3590 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3591 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3592 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3593 }
ebaae0fClaude3594
3595 /* AI stats strip */
3596 .repo-ai-stats-strip {
3597 display: flex;
3598 align-items: center;
3599 gap: 6px;
3600 margin-top: 12px;
3601 margin-bottom: 4px;
3602 font-size: 12px;
3603 color: var(--text-muted);
3604 flex-wrap: wrap;
3605 }
3606 .repo-ai-stats-strip a {
3607 color: var(--text-muted);
3608 text-decoration: underline;
3609 text-decoration-color: transparent;
3610 transition: color 120ms ease, text-decoration-color 120ms ease;
3611 }
3612 .repo-ai-stats-strip a:hover {
3613 color: var(--accent);
3614 text-decoration-color: currentColor;
3615 }
3616 .repo-ai-stats-sep {
3617 opacity: 0.4;
3618 user-select: none;
3619 }
544d842Claude3620 `;
3621 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3622 // SSH URL — port-aware:
3623 // Standard port 22 → git@host:owner/repo.git
3624 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3625 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3626 try {
3627 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3628 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3629 ? host
3630 : "localhost";
3631 const sshPort = config.sshPort;
3632 if (sshPort === 22) {
3633 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3634 } else {
3635 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3636 }
3637 } catch {
3638 // Fall through to default.
3639 }
3640 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3641 const formatRelative = (date: Date | null): string => {
3642 if (!date) return "never";
3643 const ms = Date.now() - date.getTime();
3644 const s = Math.max(0, Math.round(ms / 1000));
3645 if (s < 60) return "just now";
3646 const m = Math.round(s / 60);
3647 if (m < 60) return `${m} min ago`;
3648 const h = Math.round(m / 60);
3649 if (h < 24) return `${h}h ago`;
3650 const d = Math.round(h / 24);
3651 if (d < 30) return `${d}d ago`;
3652 const mo = Math.round(d / 30);
3653 if (mo < 12) return `${mo}mo ago`;
3654 const y = Math.round(d / 365);
3655 return `${y}y ago`;
3656 };
06d5ffeClaude3657
fc1817aClaude3658 if (tree.length === 0) {
5acce80Claude3659 const repoOgDesc = description
3660 ? `${owner}/${repo} on Gluecron — ${description}`
3661 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3662 return c.html(
5acce80Claude3663 <Layout
3664 title={`${owner}/${repo}`}
3665 user={user}
3666 description={repoOgDesc}
3667 ogTitle={`${owner}/${repo} — Gluecron`}
3668 ogDescription={repoOgDesc}
3669 twitterCard="summary"
3670 >
544d842Claude3671 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3672 <style dangerouslySetInnerHTML={{ __html: `
3673 .empty-options-grid {
3674 display: grid;
3675 grid-template-columns: repeat(3, 1fr);
3676 gap: var(--space-4);
3677 margin-top: var(--space-4);
3678 }
3679 @media (max-width: 800px) {
3680 .empty-options-grid { grid-template-columns: 1fr; }
3681 }
3682 .empty-option-card {
3683 position: relative;
3684 background: var(--bg-elevated);
3685 border: 1px solid var(--border);
3686 border-radius: 14px;
3687 padding: var(--space-5);
3688 display: flex;
3689 flex-direction: column;
3690 gap: var(--space-3);
3691 overflow: hidden;
3692 transition: border-color 140ms ease, box-shadow 140ms ease;
3693 }
3694 .empty-option-card:hover {
6fd5915Claude3695 border-color: rgba(91,110,232,0.45);
3696 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3697 }
3698 .empty-option-card::before {
3699 content: '';
3700 position: absolute;
3701 top: 0; left: 0; right: 0;
3702 height: 2px;
6fd5915Claude3703 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3704 opacity: 0.55;
3705 pointer-events: none;
3706 }
3707 .empty-option-label {
3708 font-size: 10.5px;
3709 font-family: var(--font-mono);
3710 text-transform: uppercase;
3711 letter-spacing: 0.14em;
3712 color: var(--accent);
3713 font-weight: 700;
3714 }
3715 .empty-option-title {
3716 font-family: var(--font-display);
3717 font-weight: 700;
3718 font-size: 17px;
3719 letter-spacing: -0.01em;
3720 color: var(--text-strong);
3721 margin: 0;
3722 line-height: 1.25;
3723 }
3724 .empty-option-body {
3725 font-size: 13px;
3726 color: var(--text-muted);
3727 line-height: 1.55;
3728 margin: 0;
3729 flex: 1;
3730 }
3731 .empty-option-snippet {
3732 background: var(--bg);
3733 border: 1px solid var(--border);
3734 border-radius: 8px;
3735 padding: var(--space-3) var(--space-4);
3736 font-family: var(--font-mono);
3737 font-size: 11.5px;
3738 line-height: 1.75;
3739 color: var(--text-strong);
3740 overflow-x: auto;
3741 white-space: pre;
3742 margin: 0;
3743 }
3744 .empty-option-snippet .ec { color: var(--text-faint); }
3745 .empty-option-snippet .em { color: var(--accent); }
3746 ` }} />
f6730d0ccantynz-alt3747 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
544d842Claude3748 <div class="repo-home-hero">
3749 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3750 <div class="repo-home-hero-orb" />
3751 </div>
3752 <div class="repo-home-hero-inner">
3753 <div class="repo-home-eyebrow">
3754 <strong>Repository</strong> · {owner}
3755 </div>
91a0204Claude3756 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3757 <RepoHeader
3758 owner={owner}
3759 repo={repo}
3760 starCount={starCount}
3761 starred={starred}
3762 forkCount={forkCount}
3763 currentUser={user?.username}
3764 archived={archived}
3765 isTemplate={isTemplate}
8c790e0Claude3766 recentPush={recentPush}
91a0204Claude3767 />
3768 {healthScore && (() => {
3769 const gradeLabel: Record<string, string> = {
3770 elite: "Elite", strong: "Strong",
3771 improving: "Improving", "needs-attention": "Needs Attention",
3772 };
3773 return (
3774 <a
3775 href={`/${owner}/${repo}/insights/health`}
3776 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3777 title={`Health score: ${healthScore!.total}/100`}
3778 >
3779 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3780 </a>
3781 );
3782 })()}
3783 </div>
544d842Claude3784 {description ? (
3785 <p class="repo-home-description">{description}</p>
3786 ) : (
3787 <p class="repo-home-description-empty">
3788 No description yet — push a README to tell the world what this
3789 ships.
3790 </p>
3791 )}
3792 </div>
3793 </div>
fc1817aClaude3794 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3795 <div style="margin-top:var(--space-4)">
3796 <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)">
3797 Getting started
3798 </div>
544d842Claude3799 <h2 class="repo-home-empty-title">
91a0204Claude3800 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3801 </h2>
3802 <p class="repo-home-empty-sub">
91a0204Claude3803 Choose how you want to kick things off. Every push wires up gate
3804 checks and AI review automatically.
544d842Claude3805 </p>
91a0204Claude3806 <div class="empty-options-grid">
3807 <div class="empty-option-card">
3808 <div class="empty-option-label">Option A</div>
3809 <h3 class="empty-option-title">Push your first commit</h3>
3810 <p class="empty-option-body">
3811 Wire up an existing project in seconds. Run these commands from
3812 your project directory.
3813 </p>
3814 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3815<span class="em">git remote add</span> origin {cloneHttpsUrl}
3816<span class="em">git branch</span> -M main
3817<span class="em">git push</span> -u origin main</pre>
3818 </div>
3819 <div class="empty-option-card">
3820 <div class="empty-option-label">Option B</div>
3821 <h3 class="empty-option-title">Import from GitHub</h3>
3822 <p class="empty-option-body">
3823 Mirror an existing GitHub repository here in one click. Gluecron
3824 syncs the full history and branches.
3825 </p>
f6730d0ccantynz-alt3826 <Button href="/import" variant="secondary" style="align-self:flex-start">
91a0204Claude3827 Import repository
f6730d0ccantynz-alt3828 </Button>
91a0204Claude3829 </div>
3830 <div class="empty-option-card">
3831 <div class="empty-option-label">Option C</div>
3832 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3833 <p class="empty-option-body">
3834 Let AI write your first feature. Describe what you want to build
3835 and Gluecron opens a pull request with the code.
3836 </p>
f6730d0ccantynz-alt3837 <Button href={`/${owner}/${repo}/specs`} variant="primary" style="align-self:flex-start">
91a0204Claude3838 Let AI write your first feature
f6730d0ccantynz-alt3839 </Button>
91a0204Claude3840 </div>
3841 </div>
fc1817aClaude3842 </div>
3843 </Layout>
3844 );
3845 }
3846
3847 const readme = await getReadme(owner, repo, defaultBranch);
3848
544d842Claude3849 // Sidebar facts — derived from data we already have.
3850 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3851 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3852
5acce80Claude3853 const repoOgDesc = description
3854 ? `${owner}/${repo} on Gluecron — ${description}`
3855 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3856
fc1817aClaude3857 return c.html(
5acce80Claude3858 <Layout
3859 title={`${owner}/${repo}`}
3860 user={user}
3861 description={repoOgDesc}
3862 ogTitle={`${owner}/${repo} — Gluecron`}
3863 ogDescription={repoOgDesc}
3864 twitterCard="summary"
3865 >
544d842Claude3866 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3867 {/* Repo-context commands for the command palette (Feature 2) */}
3868 <script
3869 id="cmdk-repo-context"
3870 dangerouslySetInnerHTML={{
3871 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3872 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3873 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3874 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3875 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3876 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3877 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3878 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3879 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3880 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3881 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3882 ])};`,
3883 }}
3884 />
544d842Claude3885 <div class="repo-home-hero">
3886 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3887 <div class="repo-home-hero-orb" />
3888 </div>
3889 <div class="repo-home-hero-inner">
3890 <div class="repo-home-eyebrow">
3891 <strong>Repository</strong> · {owner}
3892 </div>
91a0204Claude3893 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3894 <RepoHeader
3895 owner={owner}
3896 repo={repo}
3897 starCount={starCount}
3898 starred={starred}
3899 forkCount={forkCount}
3900 currentUser={user?.username}
3901 archived={archived}
3902 isTemplate={isTemplate}
8c790e0Claude3903 recentPush={recentPush}
91a0204Claude3904 />
3905 {healthScore && (() => {
3906 const gradeLabel: Record<string, string> = {
3907 elite: "Elite", strong: "Strong",
3908 improving: "Improving", "needs-attention": "Needs Attention",
3909 };
3910 return (
3911 <a
3912 href={`/${owner}/${repo}/insights/health`}
3913 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3914 title={`Health score: ${healthScore!.total}/100`}
3915 >
3916 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3917 </a>
3918 );
3919 })()}
3920 </div>
544d842Claude3921 {description ? (
3922 <p class="repo-home-description">{description}</p>
3923 ) : (
3924 <p class="repo-home-description-empty">
3925 No description yet.
3926 </p>
3927 )}
3928 <div class="repo-home-stat-row" aria-label="Repository stats">
3929 <span class="repo-home-stat" title="Default branch">
3930 <span class="repo-home-stat-icon">{"⎇"}</span>
3931 <strong>{defaultBranch}</strong>
3932 </span>
3933 <a
3934 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3935 class="repo-home-stat"
3936 title="Browse all branches"
3937 >
3938 <span class="repo-home-stat-icon">{"⊢"}</span>
3939 <strong>{branches.length}</strong>{" "}
3940 branch{branches.length === 1 ? "" : "es"}
3941 </a>
3942 <span class="repo-home-stat" title="Top-level entries">
3943 <span class="repo-home-stat-icon">{"■"}</span>
3944 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3945 {dirCount > 0 && (
3946 <>
3947 {" · "}
3948 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3949 </>
3950 )}
3951 </span>
3952 {pushedAt && (
3953 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3954 <span class="repo-home-stat-icon">{"↻"}</span>
3955 Updated <strong>{formatRelative(pushedAt)}</strong>
3956 </span>
3957 )}
3958 </div>
3959 </div>
3960 </div>
71cd5ecClaude3961 {isTemplate && user && user.username !== owner && (
3962 <div
3963 class="panel"
dc26881CC LABS App3964 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3965 >
3966 <div style="font-size:13px">
3967 <strong>Template repository.</strong> Create a new repository from
3968 this template's files.
3969 </div>
3970 <form
001af43Claude3971 method="post"
71cd5ecClaude3972 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3973 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3974 >
3975 <input
3976 type="text"
3977 name="name"
3978 placeholder="new-repo-name"
3979 required
2c3ba6ecopilot-swe-agent[bot]3980 aria-label="New repository name"
71cd5ecClaude3981 style="width:200px"
3982 />
3983 <button type="submit" class="btn btn-primary">
3984 Use this template
3985 </button>
3986 </form>
3987 </div>
3988 )}
fc1817aClaude3989 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude3990 <RepoHomePendingBanner
3991 owner={owner}
3992 repo={repo}
3993 count={repoHomePendingCount}
3994 />
641aa42Claude3995 {showOnboarding && onboardingRow && (
3996 <RepoOnboardingCard
3997 owner={owner}
3998 repo={repo}
3999 data={onboardingRow}
4000 />
4001 )}
c6018a5Claude4002 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4003 row sits just below the nav as a slim CTA strip. Scoped under
4004 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4005 <style
4006 dangerouslySetInnerHTML={{
4007 __html: `
4008 .repo-ai-cta-row {
4009 display: flex;
4010 flex-wrap: wrap;
4011 gap: 8px;
4012 margin: 12px 0 18px;
4013 padding: 10px 14px;
6fd5915Claude4014 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4015 border: 1px solid var(--border);
4016 border-radius: 10px;
4017 position: relative;
4018 overflow: hidden;
4019 }
4020 .repo-ai-cta-row::before {
4021 content: '';
4022 position: absolute;
4023 top: 0; left: 0; right: 0;
4024 height: 1px;
6fd5915Claude4025 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4026 opacity: 0.7;
4027 pointer-events: none;
4028 }
4029 .repo-ai-cta-label {
4030 font-size: 11px;
4031 font-weight: 600;
4032 letter-spacing: 0.06em;
4033 text-transform: uppercase;
4034 color: var(--accent);
4035 align-self: center;
4036 padding-right: 8px;
4037 border-right: 1px solid var(--border);
4038 margin-right: 4px;
4039 }
4040 .repo-ai-cta {
4041 display: inline-flex;
4042 align-items: center;
4043 gap: 6px;
4044 padding: 5px 10px;
4045 font-size: 12.5px;
4046 font-weight: 500;
4047 color: var(--text);
4048 background: var(--bg);
4049 border: 1px solid var(--border);
4050 border-radius: 7px;
4051 text-decoration: none;
4052 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4053 }
4054 .repo-ai-cta:hover {
6fd5915Claude4055 border-color: rgba(91,110,232,0.45);
c6018a5Claude4056 color: var(--text-strong);
6fd5915Claude4057 background: rgba(91,110,232,0.06);
c6018a5Claude4058 text-decoration: none;
4059 }
4060 .repo-ai-cta-icon {
4061 opacity: 0.75;
4062 font-size: 12px;
4063 }
4064 @media (max-width: 640px) {
4065 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4066 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4067 }
4068 `,
4069 }}
4070 />
4071 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4072 <span class="repo-ai-cta-label">AI surfaces</span>
4073 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4074 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4075 Chat
4076 </a>
4077 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4078 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4079 Previews
4080 </a>
4081 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4082 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4083 Migrations
4084 </a>
53c9249Claude4085 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4086 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4087 AI Search
c6018a5Claude4088 </a>
4089 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4090 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4091 AI release notes
4092 </a>
3646bfeClaude4093 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4094 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4095 Dev environment
4096 </a>
c6018a5Claude4097 </nav>
544d842Claude4098 <div class="repo-home-grid">
4099 <div class="repo-home-main">
4100 <BranchSwitcher
4101 owner={owner}
4102 repo={repo}
4103 currentRef={defaultBranch}
4104 branches={branches}
4105 pathType="tree"
4106 />
4107 <FileTable
4108 entries={tree}
4109 owner={owner}
4110 repo={repo}
4111 ref={defaultBranch}
4112 path=""
4113 />
ebaae0fClaude4114 {/* AI stats strip — one-line summary of AI value for this repo */}
4115 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4116 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4117 <span>{"⚡"}</span>
4118 {aiStats.mergedCount > 0 ? (
4119 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4120 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4121 </a>
4122 ) : (
4123 <span>0 AI merges this week</span>
4124 )}
4125 <span class="repo-ai-stats-sep">{"·"}</span>
4126 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4127 <span class="repo-ai-stats-sep">{"·"}</span>
4128 {aiStats.securityAlertCount > 0 ? (
4129 <a href={`/${owner}/${repo}/issues?label=security`}>
4130 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4131 </a>
4132 ) : (
4133 <span>0 open security alerts</span>
4134 )}
4135 </div>
4136 ) : (
4137 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4138 <span>{"⚡"}</span>
4139 <span>AI is watching — no activity this week</span>
4140 </div>
4141 )}
544d842Claude4142 {readme && (() => {
4143 const readmeHtml = renderMarkdown(readme);
4144 return (
4145 <div class="repo-home-readme">
4146 <div class="repo-home-readme-head">
4147 <span class="repo-home-readme-icon">{"☰"}</span>
4148 <span>README.md</span>
4149 </div>
4150 <style>{markdownCss}</style>
4151 <div class="markdown-body repo-home-readme-body">
4152 {html([readmeHtml] as unknown as TemplateStringsArray)}
4153 </div>
4154 </div>
4155 );
4156 })()}
4157 </div>
4158 <aside class="repo-home-side" aria-label="Repository details">
4159 <div class="repo-home-clone">
4160 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4161 <button
4162 type="button"
4163 class="repo-home-clone-tab"
4164 role="tab"
4165 aria-selected="true"
4166 data-pane="https"
4167 data-repo-home-clone-tab
4168 >
4169 HTTPS
4170 </button>
4171 <button
4172 type="button"
4173 class="repo-home-clone-tab"
4174 role="tab"
4175 aria-selected="false"
4176 data-pane="ssh"
4177 data-repo-home-clone-tab
4178 >
4179 SSH
4180 </button>
4181 <button
4182 type="button"
4183 class="repo-home-clone-tab"
4184 role="tab"
4185 aria-selected="false"
4186 data-pane="cli"
4187 data-repo-home-clone-tab
4188 >
4189 CLI
4190 </button>
4191 </div>
4192 <div
4193 class="repo-home-clone-pane"
4194 data-pane="https"
4195 data-active="true"
4196 role="tabpanel"
4197 >
4198 <div class="repo-home-clone-body">
4199 <input
4200 class="repo-home-clone-input"
4201 type="text"
4202 value={cloneHttpsUrl}
4203 readonly
4204 aria-label="HTTPS clone URL"
4205 data-repo-home-clone-input
4206 />
4207 <button
4208 type="button"
4209 class="repo-home-clone-copy"
4210 data-repo-home-copy={cloneHttpsUrl}
4211 >
4212 Copy
4213 </button>
4214 </div>
4215 </div>
4216 <div
4217 class="repo-home-clone-pane"
4218 data-pane="ssh"
4219 data-active="false"
4220 role="tabpanel"
4221 >
4222 <div class="repo-home-clone-body">
4223 <input
4224 class="repo-home-clone-input"
4225 type="text"
4226 value={cloneSshUrl}
4227 readonly
4228 aria-label="SSH clone URL"
4229 data-repo-home-clone-input
4230 />
4231 <button
4232 type="button"
4233 class="repo-home-clone-copy"
4234 data-repo-home-copy={cloneSshUrl}
4235 >
4236 Copy
4237 </button>
4238 </div>
4239 </div>
4240 <div
4241 class="repo-home-clone-pane"
4242 data-pane="cli"
4243 data-active="false"
4244 role="tabpanel"
4245 >
4246 <div class="repo-home-clone-body">
4247 <input
4248 class="repo-home-clone-input"
4249 type="text"
4250 value={cloneCliCmd}
4251 readonly
4252 aria-label="Gluecron CLI clone command"
4253 data-repo-home-clone-input
4254 />
4255 <button
4256 type="button"
4257 class="repo-home-clone-copy"
4258 data-repo-home-copy={cloneCliCmd}
4259 >
4260 Copy
4261 </button>
4262 </div>
79136bbClaude4263 </div>
fc1817aClaude4264 </div>
544d842Claude4265 <div class="repo-home-side-card">
4266 <h3 class="repo-home-side-title">About</h3>
4267 <div class="repo-home-side-row">
4268 <span class="repo-home-side-key">Default branch</span>
4269 <span class="repo-home-side-val">{defaultBranch}</span>
4270 </div>
4271 <div class="repo-home-side-row">
4272 <span class="repo-home-side-key">Branches</span>
4273 <span class="repo-home-side-val">
4274 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4275 {branches.length}
4276 </a>
4277 </span>
4278 </div>
4279 <div class="repo-home-side-row">
4280 <span class="repo-home-side-key">Stars</span>
4281 <span class="repo-home-side-val">{starCount}</span>
4282 </div>
4283 <div class="repo-home-side-row">
4284 <span class="repo-home-side-key">Forks</span>
4285 <span class="repo-home-side-val">{forkCount}</span>
4286 </div>
4287 {pushedAt && (
4288 <div class="repo-home-side-row">
4289 <span class="repo-home-side-key">Last push</span>
4290 <span class="repo-home-side-val">
4291 {formatRelative(pushedAt)}
4292 </span>
4293 </div>
4294 )}
4295 {createdAt && (
4296 <div class="repo-home-side-row">
4297 <span class="repo-home-side-key">Created</span>
4298 <span class="repo-home-side-val">
4299 {formatRelative(createdAt)}
4300 </span>
4301 </div>
4302 )}
4303 {(archived || isTemplate) && (
4304 <div class="repo-home-side-row">
4305 <span class="repo-home-side-key">State</span>
4306 <span class="repo-home-side-val">
4307 {archived ? "Archived" : "Template"}
4308 </span>
4309 </div>
4310 )}
4311 </div>
f1dc38bClaude4312 {/* Claude AI — quick-access card for authenticated users */}
4313 {user && (
4314 <div class="repo-home-side-card" style="margin-top:12px">
4315 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4316 ✨ Claude AI
4317 </h3>
4318 <a
4319 href={`/${owner}/${repo}/claude`}
e589f77ccantynz-alt4320 style="display:block;background:var(--accent);color:#fff;text-align:center;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-bottom:8px"
f1dc38bClaude4321 >
4322 Claude Code sessions
4323 </a>
4324 <a
4325 href={`/${owner}/${repo}/ask`}
4326 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4327 >
4328 Ask AI about this repo
4329 </a>
4330 </div>
4331 )}
544d842Claude4332 </aside>
4333 </div>
4334 <script
4335 dangerouslySetInnerHTML={{
4336 __html: `
4337 (function(){
4338 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4339 tabs.forEach(function(tab){
4340 tab.addEventListener('click', function(){
4341 var target = tab.getAttribute('data-pane');
4342 tabs.forEach(function(t){
4343 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4344 });
4345 var panes = document.querySelectorAll('.repo-home-clone-pane');
4346 panes.forEach(function(p){
4347 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4348 });
4349 });
4350 });
4351 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4352 copyBtns.forEach(function(btn){
4353 btn.addEventListener('click', function(){
4354 var text = btn.getAttribute('data-repo-home-copy') || '';
4355 var done = function(){
4356 var prev = btn.textContent;
4357 btn.textContent = 'Copied';
4358 setTimeout(function(){ btn.textContent = prev; }, 1200);
4359 };
4360 if (navigator.clipboard && navigator.clipboard.writeText) {
4361 navigator.clipboard.writeText(text).then(done, done);
4362 } else {
4363 var ta = document.createElement('textarea');
4364 ta.value = text;
4365 document.body.appendChild(ta);
4366 ta.select();
4367 try { document.execCommand('copy'); } catch (e) {}
4368 document.body.removeChild(ta);
4369 done();
4370 }
4371 });
4372 });
4373 })();
4374 `,
4375 }}
4376 />
fc1817aClaude4377 </Layout>
4378 );
4379});
4380
4381// Browse tree at ref/path
4382web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4383 const { owner, repo } = c.req.param();
06d5ffeClaude4384 const user = c.get("user");
5bb52faccanty labs4385 const gate = await assertRepoReadable(c, owner, repo);
4386 if (gate) return gate;
fc1817aClaude4387 const refAndPath = c.req.param("ref");
4388
4389 const branches = await listBranches(owner, repo);
4390 let ref = "";
4391 let treePath = "";
4392
4393 for (const branch of branches) {
4394 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4395 ref = branch;
4396 treePath = refAndPath.slice(branch.length + 1);
4397 break;
4398 }
4399 }
4400
4401 if (!ref) {
4402 const slashIdx = refAndPath.indexOf("/");
4403 if (slashIdx === -1) {
4404 ref = refAndPath;
4405 } else {
4406 ref = refAndPath.slice(0, slashIdx);
4407 treePath = refAndPath.slice(slashIdx + 1);
4408 }
4409 }
4410
4411 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4412 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4413 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4414
4415 return c.html(
06d5ffeClaude4416 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4417 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4418 <RepoHeader owner={owner} repo={repo} />
4419 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4420 <div class="tree-header">
4421 <div class="tree-header-row">
4422 <BranchSwitcher
4423 owner={owner}
4424 repo={repo}
4425 currentRef={ref}
4426 branches={branches}
4427 pathType="tree"
4428 subPath={treePath}
4429 />
4430 <div class="tree-header-stats">
4431 <span class="tree-stat" title="Entries in this directory">
4432 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4433 {dirCount > 0 && (
4434 <>
4435 {" · "}
4436 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4437 </>
4438 )}
4439 </span>
4440 <a
4441 href={`/${owner}/${repo}/search`}
4442 class="tree-stat tree-stat-link"
4443 title="Search code in this repository"
4444 >
4445 {"⌕"} Search
4446 </a>
4447 </div>
4448 </div>
4449 <div class="tree-breadcrumb-row">
4450 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4451 </div>
4452 </div>
fc1817aClaude4453 <FileTable
4454 entries={tree}
4455 owner={owner}
4456 repo={repo}
4457 ref={ref}
4458 path={treePath}
4459 />
4460 </Layout>
4461 );
4462});
4463
06d5ffeClaude4464// View file blob with syntax highlighting
fc1817aClaude4465web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4466 const { owner, repo } = c.req.param();
06d5ffeClaude4467 const user = c.get("user");
5bb52faccanty labs4468 const gate = await assertRepoReadable(c, owner, repo);
4469 if (gate) return gate;
fc1817aClaude4470 const refAndPath = c.req.param("ref");
4471
4472 const branches = await listBranches(owner, repo);
4473 let ref = "";
4474 let filePath = "";
4475
4476 for (const branch of branches) {
4477 if (refAndPath.startsWith(branch + "/")) {
4478 ref = branch;
4479 filePath = refAndPath.slice(branch.length + 1);
4480 break;
4481 }
4482 }
4483
4484 if (!ref) {
4485 const slashIdx = refAndPath.indexOf("/");
4486 if (slashIdx === -1) return c.text("Not found", 404);
4487 ref = refAndPath.slice(0, slashIdx);
4488 filePath = refAndPath.slice(slashIdx + 1);
4489 }
4490
4491 const blob = await getBlob(owner, repo, ref, filePath);
4492 if (!blob) {
4493 return c.html(
06d5ffeClaude4494 <Layout title="Not Found" user={user}>
fc1817aClaude4495 <div class="empty-state">
4496 <h2>File not found</h2>
4497 </div>
4498 </Layout>,
4499 404
4500 );
4501 }
4502
06d5ffeClaude4503 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4504 const lineCount = blob.isBinary
4505 ? 0
4506 : (blob.content.endsWith("\n")
4507 ? blob.content.split("\n").length - 1
4508 : blob.content.split("\n").length);
4509 const formatBytes = (n: number): string => {
4510 if (n < 1024) return `${n} B`;
4511 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4512 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4513 };
fc1817aClaude4514
4515 return c.html(
06d5ffeClaude4516 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4517 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4518 <RepoHeader owner={owner} repo={repo} />
4519 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4520 <div class="blob-toolbar">
4521 <BranchSwitcher
4522 owner={owner}
4523 repo={repo}
4524 currentRef={ref}
4525 branches={branches}
4526 pathType="blob"
4527 subPath={filePath}
4528 />
4529 <div class="blob-breadcrumb">
4530 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4531 </div>
4532 </div>
4533 <div class="blob-view blob-card">
4534 <div class="blob-header blob-header-polished">
4535 <div class="blob-header-meta">
4536 <span class="blob-header-icon" aria-hidden="true">
4537 {"📄"}
4538 </span>
4539 <span class="blob-header-name">{fileName}</span>
4540 <span class="blob-header-size">
4541 {formatBytes(blob.size)}
4542 {!blob.isBinary && (
4543 <>
4544 {" · "}
4545 {lineCount} line{lineCount === 1 ? "" : "s"}
4546 </>
4547 )}
4548 </span>
4549 </div>
4550 <div class="blob-header-actions">
4551 <a
4552 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4553 class="blob-pill"
4554 >
79136bbClaude4555 Raw
4556 </a>
efb11c5Claude4557 <a
4558 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4559 class="blob-pill"
4560 >
79136bbClaude4561 Blame
4562 </a>
efb11c5Claude4563 <a
4564 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4565 class="blob-pill"
4566 >
16b325cClaude4567 History
4568 </a>
0074234Claude4569 {user && (
efb11c5Claude4570 <a
4571 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4572 class="blob-pill blob-pill-accent"
4573 >
0074234Claude4574 Edit
4575 </a>
4576 )}
efb11c5Claude4577 </div>
fc1817aClaude4578 </div>
4579 {blob.isBinary ? (
efb11c5Claude4580 <div class="blob-binary">
fc1817aClaude4581 Binary file not shown.
4582 </div>
06d5ffeClaude4583 ) : (() => {
4584 const { html: highlighted, language } = highlightCode(
4585 blob.content,
4586 fileName
4587 );
4588 if (language) {
4589 return (
4590 <HighlightedCode
4591 highlightedHtml={highlighted}
efb11c5Claude4592 lineCount={lineCount}
06d5ffeClaude4593 />
4594 );
4595 }
4596 const lines = blob.content.split("\n");
4597 if (lines[lines.length - 1] === "") lines.pop();
4598 return <PlainCode lines={lines} />;
4599 })()}
fc1817aClaude4600 </div>
4601 </Layout>
4602 );
4603});
4604
398a10cClaude4605// ─── Branches list ────────────────────────────────────────────────────────
4606// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4607// author + relative time + ahead/behind vs the default branch. No DB. All
4608// data comes from git plumbing; failures degrade gracefully (counts omitted).
4609web.get("/:owner/:repo/branches", async (c) => {
4610 const { owner, repo } = c.req.param();
4611 const user = c.get("user");
5bb52faccanty labs4612 const gate = await assertRepoReadable(c, owner, repo);
4613 if (gate) return gate;
398a10cClaude4614
4615 if (!(await repoExists(owner, repo))) return c.notFound();
4616
4617 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4618 const branches = await listBranches(owner, repo);
4619 const repoDir = getRepoPath(owner, repo);
4620
4621 type BranchRow = {
4622 name: string;
4623 isDefault: boolean;
4624 sha: string;
4625 subject: string;
4626 author: string;
4627 date: string;
4628 ahead: number;
4629 behind: number;
4630 };
4631
4632 const runGit = async (args: string[]): Promise<string> => {
4633 try {
4634 const proc = Bun.spawn(["git", ...args], {
4635 cwd: repoDir,
4636 stdout: "pipe",
4637 stderr: "pipe",
4638 });
4639 const out = await new Response(proc.stdout).text();
4640 await proc.exited;
4641 return out;
4642 } catch {
4643 return "";
4644 }
4645 };
4646
4647 const meta = await runGit([
4648 "for-each-ref",
4649 "--sort=-committerdate",
4650 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4651 "refs/heads/",
4652 ]);
4653 const metaByName: Record<
4654 string,
4655 { sha: string; subject: string; author: string; date: string }
4656 > = {};
4657 for (const line of meta.split("\n").filter(Boolean)) {
4658 const [name, sha, subject, author, date] = line.split("\0");
4659 metaByName[name] = { sha, subject, author, date };
4660 }
4661
4662 const branchOrder = [...branches].sort((a, b) => {
4663 if (a === defaultBranch) return -1;
4664 if (b === defaultBranch) return 1;
4665 const aDate = metaByName[a]?.date || "";
4666 const bDate = metaByName[b]?.date || "";
4667 return bDate.localeCompare(aDate);
4668 });
4669
4670 const rows: BranchRow[] = [];
4671 for (const name of branchOrder) {
4672 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4673 let ahead = 0;
4674 let behind = 0;
4675 if (name !== defaultBranch && metaByName[defaultBranch]) {
4676 const out = await runGit([
4677 "rev-list",
4678 "--left-right",
4679 "--count",
4680 `${defaultBranch}...${name}`,
4681 ]);
4682 const parts = out.trim().split(/\s+/);
4683 if (parts.length === 2) {
4684 behind = parseInt(parts[0], 10) || 0;
4685 ahead = parseInt(parts[1], 10) || 0;
4686 }
4687 }
4688 rows.push({
4689 name,
4690 isDefault: name === defaultBranch,
4691 sha: m.sha,
4692 subject: m.subject,
4693 author: m.author,
4694 date: m.date,
4695 ahead,
4696 behind,
4697 });
4698 }
4699
4700 const relative = (iso: string): string => {
4701 if (!iso) return "—";
4702 const d = new Date(iso);
4703 if (Number.isNaN(d.getTime())) return "—";
4704 const diff = Date.now() - d.getTime();
4705 const m = Math.floor(diff / 60000);
4706 if (m < 1) return "just now";
4707 if (m < 60) return `${m} min ago`;
4708 const h = Math.floor(m / 60);
4709 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4710 const dd = Math.floor(h / 24);
4711 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4712 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4713 };
4714
4715 const success = c.req.query("success");
4716 const error = c.req.query("error");
4717
4718 return c.html(
4719 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4720 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4721 <RepoHeader owner={owner} repo={repo} />
4722 <RepoNav owner={owner} repo={repo} active="code" />
4723 <div
4724 class="branches-wrap"
eed4684Claude4725 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4726 >
4727 <header class="branches-head" style="margin-bottom:var(--space-5)">
4728 <div
4729 class="branches-eyebrow"
4730 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"
4731 >
4732 <span
4733 class="branches-eyebrow-dot"
4734 aria-hidden="true"
6fd5915Claude4735 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);box-shadow:0 0 0 3px rgba(91,110,232,0.18)"
398a10cClaude4736 />
4737 Repository · Branches
4738 </div>
4739 <h1
4740 class="branches-title"
4741 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)"
4742 >
4743 <span class="gradient-text">{rows.length}</span>{" "}
4744 branch{rows.length === 1 ? "" : "es"}
4745 </h1>
4746 <p
4747 class="branches-sub"
4748 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4749 >
4750 All work-in-progress lines for{" "}
4751 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4752 counts are relative to{" "}
4753 <code style="font-size:12.5px">{defaultBranch}</code>.
4754 </p>
4755 </header>
4756
4757 {success && (
4758 <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">
4759 {decodeURIComponent(success)}
4760 </div>
4761 )}
4762 {error && (
4763 <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">
4764 {decodeURIComponent(error)}
4765 </div>
4766 )}
4767
4768 {rows.length === 0 ? (
4769 <div class="commits-empty">
4770 <div class="commits-empty-orb" aria-hidden="true" />
4771 <div class="commits-empty-inner">
4772 <div class="commits-empty-icon" aria-hidden="true">
4773 <svg
4774 width="22"
4775 height="22"
4776 viewBox="0 0 24 24"
4777 fill="none"
4778 stroke="currentColor"
4779 stroke-width="2"
4780 stroke-linecap="round"
4781 stroke-linejoin="round"
4782 >
4783 <line x1="6" y1="3" x2="6" y2="15" />
4784 <circle cx="18" cy="6" r="3" />
4785 <circle cx="6" cy="18" r="3" />
4786 <path d="M18 9a9 9 0 0 1-9 9" />
4787 </svg>
4788 </div>
4789 <h3 class="commits-empty-title">No branches yet</h3>
4790 <p class="commits-empty-sub">
4791 Push your first commit to create the default branch.
4792 </p>
4793 </div>
4794 </div>
4795 ) : (
4796 <div class="branches-list">
4797 {rows.map((r) => (
4798 <div class="branches-row">
4799 <div class="branches-row-icon" aria-hidden="true">
4800 <svg
4801 width="14"
4802 height="14"
4803 viewBox="0 0 24 24"
4804 fill="none"
4805 stroke="currentColor"
4806 stroke-width="2"
4807 stroke-linecap="round"
4808 stroke-linejoin="round"
4809 >
4810 <line x1="6" y1="3" x2="6" y2="15" />
4811 <circle cx="18" cy="6" r="3" />
4812 <circle cx="6" cy="18" r="3" />
4813 <path d="M18 9a9 9 0 0 1-9 9" />
4814 </svg>
4815 </div>
4816 <div class="branches-row-main">
4817 <div class="branches-row-name">
4818 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4819 {r.isDefault && (
4820 <span
4821 class="branches-row-default"
4822 title="Default branch"
4823 >
4824 Default
4825 </span>
4826 )}
4827 </div>
4828 <div class="branches-row-meta">
4829 {r.subject ? (
4830 <>
4831 <strong>{r.author || "—"}</strong>
4832 <span class="sep">·</span>
4833 <span
4834 title={r.date ? new Date(r.date).toISOString() : ""}
4835 >
4836 updated {relative(r.date)}
4837 </span>
4838 {r.sha && (
4839 <>
4840 <span class="sep">·</span>
4841 <a
4842 href={`/${owner}/${repo}/commit/${r.sha}`}
4843 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4844 >
4845 {r.sha.slice(0, 7)}
4846 </a>
4847 </>
4848 )}
4849 </>
4850 ) : (
4851 <span>No commit metadata</span>
4852 )}
4853 </div>
4854 </div>
4855 <div class="branches-row-side">
4856 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4857 <span
4858 class="branches-row-divergence"
4859 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4860 >
4861 <span class="ahead">{r.ahead} ahead</span>
4862 <span style="opacity:0.4">|</span>
4863 <span class="behind">{r.behind} behind</span>
4864 </span>
4865 )}
4866 <div class="branches-row-actions">
4867 <a
4868 href={`/${owner}/${repo}/commits/${r.name}`}
4869 class="branches-btn"
4870 title="View commits on this branch"
4871 >
4872 Commits
4873 </a>
4874 {!r.isDefault &&
4875 user &&
4876 user.username === owner && (
4877 <form
4878 method="post"
4879 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4880 style="margin:0"
4881 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4882 >
4883 <button
4884 type="submit"
4885 class="branches-btn branches-btn-danger"
4886 >
4887 Delete
4888 </button>
4889 </form>
4890 )}
4891 </div>
4892 </div>
4893 </div>
4894 ))}
4895 </div>
4896 )}
4897 </div>
4898 </Layout>
4899 );
4900});
4901
4902// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4903// that are not merged into the default branch — matches the explicit
4904// confirmation on the row's delete button.
4905web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4906 const { owner, repo } = c.req.param();
4907 const branchName = decodeURIComponent(c.req.param("name"));
4908 const user = c.get("user")!;
4909
4910 // Owner-only check (mirrors collaborators.tsx pattern).
4911 const [ownerRow] = await db
4912 .select()
4913 .from(users)
4914 .where(eq(users.username, owner))
4915 .limit(1);
4916 if (!ownerRow || ownerRow.id !== user.id) {
4917 return c.redirect(
4918 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4919 );
4920 }
4921
4922 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4923 if (branchName === defaultBranch) {
4924 return c.redirect(
4925 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4926 );
4927 }
4928
4929 const branches = await listBranches(owner, repo);
4930 if (!branches.includes(branchName)) {
4931 return c.redirect(
4932 `/${owner}/${repo}/branches?error=Branch+not+found`
4933 );
4934 }
4935
4936 try {
4937 const repoDir = getRepoPath(owner, repo);
4938 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4939 cwd: repoDir,
4940 stdout: "pipe",
4941 stderr: "pipe",
4942 });
4943 await proc.exited;
4944 if (proc.exitCode !== 0) {
4945 return c.redirect(
4946 `/${owner}/${repo}/branches?error=Delete+failed`
4947 );
4948 }
4949 } catch {
4950 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4951 }
4952
4953 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4954});
4955
4956// ─── Tags list ────────────────────────────────────────────────────────────
4957// Pulls from `listTags` (sorted newest first). For each tag we look up an
4958// associated release row so the "View release" CTA can deep-link directly
4959// without making the user hunt for it.
4960web.get("/:owner/:repo/tags", async (c) => {
4961 const { owner, repo } = c.req.param();
4962 const user = c.get("user");
5bb52faccanty labs4963 const gate = await assertRepoReadable(c, owner, repo);
4964 if (gate) return gate;
398a10cClaude4965
4966 if (!(await repoExists(owner, repo))) return c.notFound();
4967
4968 const tags = await listTags(owner, repo);
4969
4970 // Map tags -> releases. Best-effort; releases table may not exist in
4971 // every test setup, so any error falls through with an empty set.
4972 const tagsWithReleases = new Set<string>();
4973 try {
4974 const [ownerRow] = await db
4975 .select()
4976 .from(users)
4977 .where(eq(users.username, owner))
4978 .limit(1);
4979 if (ownerRow) {
4980 const [repoRow] = await db
4981 .select()
4982 .from(repositories)
4983 .where(
4984 and(
4985 eq(repositories.ownerId, ownerRow.id),
4986 eq(repositories.name, repo)
4987 )
4988 )
4989 .limit(1);
4990 if (repoRow) {
4991 // Raw SQL so we don't need to import the releases schema here.
4992 const result = await db.execute(
4993 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
4994 );
4995 const rows: any[] = (result as any).rows || (result as any) || [];
4996 for (const row of rows) {
4997 const tag = row?.tag;
4998 if (typeof tag === "string") tagsWithReleases.add(tag);
4999 }
5000 }
5001 }
5002 } catch {
5003 // No releases table or DB error — leave set empty.
5004 }
5005
5006 const relative = (iso: string): string => {
5007 if (!iso) return "—";
5008 const d = new Date(iso);
5009 if (Number.isNaN(d.getTime())) return "—";
5010 const diff = Date.now() - d.getTime();
5011 const m = Math.floor(diff / 60000);
5012 if (m < 1) return "just now";
5013 if (m < 60) return `${m} min ago`;
5014 const h = Math.floor(m / 60);
5015 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5016 const dd = Math.floor(h / 24);
5017 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5018 return d.toLocaleDateString("en-US", {
5019 month: "short",
5020 day: "numeric",
5021 year: "numeric",
5022 });
5023 };
5024
5025 return c.html(
5026 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5027 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5028 <RepoHeader owner={owner} repo={repo} />
5029 <RepoNav owner={owner} repo={repo} active="code" />
5030 <div
5031 class="tags-wrap"
eed4684Claude5032 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5033 >
5034 <header class="tags-head" style="margin-bottom:var(--space-5)">
5035 <div
5036 class="tags-eyebrow"
5037 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"
5038 >
5039 <span
5040 class="tags-eyebrow-dot"
5041 aria-hidden="true"
6fd5915Claude5042 style="width:8px;height:8px;border-radius:9999px;background:linear-gradient(135deg,#5b6ee8,#5f8fa0);box-shadow:0 0 0 3px rgba(91,110,232,0.18)"
398a10cClaude5043 />
5044 Repository · Tags
5045 </div>
5046 <h1
5047 class="tags-title"
5048 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)"
5049 >
5050 <span class="gradient-text">{tags.length}</span>{" "}
5051 tag{tags.length === 1 ? "" : "s"}
5052 </h1>
5053 <p
5054 class="tags-sub"
5055 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5056 >
5057 Named points in the history — typically releases, milestones,
5058 or shipped versions. Click a tag to browse the tree at that
5059 revision.
5060 </p>
5061 </header>
5062
5063 {tags.length === 0 ? (
5064 <div class="commits-empty">
5065 <div class="commits-empty-orb" aria-hidden="true" />
5066 <div class="commits-empty-inner">
5067 <div class="commits-empty-icon" aria-hidden="true">
5068 <svg
5069 width="22"
5070 height="22"
5071 viewBox="0 0 24 24"
5072 fill="none"
5073 stroke="currentColor"
5074 stroke-width="2"
5075 stroke-linecap="round"
5076 stroke-linejoin="round"
5077 >
5078 <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" />
5079 <line x1="7" y1="7" x2="7.01" y2="7" />
5080 </svg>
5081 </div>
5082 <h3 class="commits-empty-title">No tags yet</h3>
5083 <p class="commits-empty-sub">
5084 Tag a commit to mark a release or milestone. From the CLI:{" "}
5085 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5086 </p>
5087 </div>
5088 </div>
5089 ) : (
5090 <div class="tags-list">
5091 {tags.map((t) => {
5092 const hasRelease = tagsWithReleases.has(t.name);
5093 return (
5094 <div class="tags-row">
5095 <div class="tags-row-icon" aria-hidden="true">
5096 <svg
5097 width="14"
5098 height="14"
5099 viewBox="0 0 24 24"
5100 fill="none"
5101 stroke="currentColor"
5102 stroke-width="2"
5103 stroke-linecap="round"
5104 stroke-linejoin="round"
5105 >
5106 <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" />
5107 <line x1="7" y1="7" x2="7.01" y2="7" />
5108 </svg>
5109 </div>
5110 <div class="tags-row-main">
5111 <div class="tags-row-name">
5112 <a
5113 href={`/${owner}/${repo}/tree/${t.name}`}
5114 style="text-decoration:none"
5115 >
5116 <span class="tags-row-version">{t.name}</span>
5117 </a>
5118 </div>
5119 <div class="tags-row-meta">
5120 <span
5121 title={t.date ? new Date(t.date).toISOString() : ""}
5122 >
5123 Tagged {relative(t.date)}
5124 </span>
5125 <span class="sep">·</span>
5126 <a
5127 href={`/${owner}/${repo}/commit/${t.sha}`}
5128 class="tags-row-sha"
5129 title={t.sha}
5130 >
5131 {t.sha.slice(0, 7)}
5132 </a>
5133 </div>
5134 </div>
5135 <div class="tags-row-side">
5136 <a
5137 href={`/${owner}/${repo}/tree/${t.name}`}
5138 class="tags-row-link"
5139 >
5140 Browse files
5141 </a>
5142 {hasRelease && (
5143 <a
5144 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5145 class="tags-row-link"
6fd5915Claude5146 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5147 >
5148 View release
5149 </a>
5150 )}
5151 </div>
5152 </div>
5153 );
5154 })}
5155 </div>
5156 )}
5157 </div>
5158 </Layout>
5159 );
5160});
5161
fc1817aClaude5162// Commit log
5163web.get("/:owner/:repo/commits/:ref?", async (c) => {
5164 const { owner, repo } = c.req.param();
06d5ffeClaude5165 const user = c.get("user");
5bb52faccanty labs5166 const gate = await assertRepoReadable(c, owner, repo);
5167 if (gate) return gate;
fc1817aClaude5168 const ref =
5169 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5170 const branches = await listBranches(owner, repo);
fc1817aClaude5171
5172 const commits = await listCommits(owner, repo, ref, 50);
5173
3951454Claude5174 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5175 // Also resolve repoId here so we can query the recent push for the
5176 // Push Watch live/watch indicator in the header.
3951454Claude5177 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5178 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5179 try {
5180 const [ownerRow] = await db
5181 .select()
5182 .from(users)
5183 .where(eq(users.username, owner))
5184 .limit(1);
5185 if (ownerRow) {
5186 const [repoRow] = await db
5187 .select()
5188 .from(repositories)
5189 .where(
5190 and(
5191 eq(repositories.ownerId, ownerRow.id),
5192 eq(repositories.name, repo)
5193 )
5194 )
5195 .limit(1);
8c790e0Claude5196 if (repoRow) {
5197 // Fetch verifications and recent push in parallel.
5198 const [verRows, rp] = await Promise.all([
5199 commits.length > 0
5200 ? db
5201 .select()
5202 .from(commitVerifications)
5203 .where(
5204 and(
5205 eq(commitVerifications.repositoryId, repoRow.id),
5206 inArray(
5207 commitVerifications.commitSha,
5208 commits.map((c) => c.sha)
5209 )
5210 )
5211 )
5212 : Promise.resolve([]),
5213 getRecentPush(repoRow.id),
5214 ]);
5215 for (const r of verRows) {
3951454Claude5216 verifications[r.commitSha] = {
5217 verified: r.verified,
5218 reason: r.reason,
5219 };
5220 }
8c790e0Claude5221 commitsPageRecentPush = rp;
3951454Claude5222 }
5223 }
5224 } catch {
5225 // DB unavailable — skip the badges gracefully.
5226 }
5227
fc1817aClaude5228 return c.html(
06d5ffeClaude5229 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5230 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5231 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5232 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5233 <div class="commits-hero">
5234 <div class="commits-hero-orb-wrap" aria-hidden="true">
5235 <div class="commits-hero-orb" />
fc1817aClaude5236 </div>
efb11c5Claude5237 <div class="commits-hero-inner">
5238 <div class="commits-eyebrow">
5239 <strong>History</strong> · {owner}/{repo}
5240 </div>
5241 <h1 class="commits-title">
5242 <span class="gradient-text">{commits.length}</span> recent commit
5243 {commits.length === 1 ? "" : "s"} on{" "}
5244 <span class="commits-branch">{ref}</span>
5245 </h1>
5246 <p class="commits-sub">
5247 Browse the project's history. Click any commit to see the full
5248 diff, AI review notes, and signature status.
5249 </p>
5250 </div>
5251 </div>
5252 <div class="commits-toolbar">
5253 <BranchSwitcher
3951454Claude5254 owner={owner}
5255 repo={repo}
efb11c5Claude5256 currentRef={ref}
5257 branches={branches}
5258 pathType="commits"
3951454Claude5259 />
398a10cClaude5260 <div class="commits-toolbar-actions">
5261 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5262 {"⊢"} Branches
5263 </a>
5264 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5265 {"#"} Tags
5266 </a>
5267 </div>
efb11c5Claude5268 </div>
5269 {commits.length === 0 ? (
5270 <div class="commits-empty">
398a10cClaude5271 <div class="commits-empty-orb" aria-hidden="true" />
5272 <div class="commits-empty-inner">
5273 <div class="commits-empty-icon" aria-hidden="true">
5274 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5275 <circle cx="12" cy="12" r="4" />
5276 <line x1="1.05" y1="12" x2="7" y2="12" />
5277 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5278 </svg>
5279 </div>
5280 <h3 class="commits-empty-title">No commits yet</h3>
5281 <p class="commits-empty-sub">
5282 This branch is empty. Push your first commit, or use the
5283 web editor to create a file.
5284 </p>
5285 </div>
efb11c5Claude5286 </div>
5287 ) : (
5288 <div class="commits-list-wrap">
398a10cClaude5289 {(() => {
5290 // Group commits by day for the section headers.
5291 const dayLabel = (iso: string): string => {
5292 const d = new Date(iso);
5293 if (Number.isNaN(d.getTime())) return "Unknown";
5294 const today = new Date();
5295 const yesterday = new Date(today);
5296 yesterday.setDate(today.getDate() - 1);
5297 const sameDay = (a: Date, b: Date) =>
5298 a.getFullYear() === b.getFullYear() &&
5299 a.getMonth() === b.getMonth() &&
5300 a.getDate() === b.getDate();
5301 if (sameDay(d, today)) return "Today";
5302 if (sameDay(d, yesterday)) return "Yesterday";
5303 return d.toLocaleDateString("en-US", {
5304 month: "long",
5305 day: "numeric",
5306 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5307 });
5308 };
5309 const relative = (iso: string): string => {
5310 const d = new Date(iso);
5311 if (Number.isNaN(d.getTime())) return "";
5312 const diff = Date.now() - d.getTime();
5313 const m = Math.floor(diff / 60000);
5314 if (m < 1) return "just now";
5315 if (m < 60) return `${m} min ago`;
5316 const h = Math.floor(m / 60);
5317 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5318 const dd = Math.floor(h / 24);
5319 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5320 return d.toLocaleDateString("en-US", {
5321 month: "short",
5322 day: "numeric",
5323 });
5324 };
5325 const initial = (name: string): string =>
5326 (name || "?").trim().charAt(0).toUpperCase() || "?";
5327 // Build groups preserving order.
5328 const groups: Array<{ label: string; items: typeof commits }> = [];
5329 let lastLabel = "";
5330 for (const cm of commits) {
5331 const label = dayLabel(cm.date);
5332 if (label !== lastLabel) {
5333 groups.push({ label, items: [] });
5334 lastLabel = label;
5335 }
5336 groups[groups.length - 1].items.push(cm);
5337 }
5338 return groups.map((g) => (
5339 <>
5340 <div class="commits-day-head">
5341 <span class="commits-day-head-dot" aria-hidden="true" />
5342 Commits on {g.label}
5343 </div>
5344 {g.items.map((cm) => {
5345 const v = verifications[cm.sha];
5346 return (
5347 <div class="commits-row">
5348 <div class="commits-avatar" aria-hidden="true">
5349 {initial(cm.author)}
5350 </div>
5351 <div class="commits-row-body">
5352 <div class="commits-row-msg">
5353 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5354 {cm.message}
5355 </a>
5356 {v?.verified && (
5357 <span
5358 class="commits-row-verified"
5359 title="Signed with a registered key"
5360 >
5361 Verified
5362 </span>
5363 )}
5364 </div>
5365 <div class="commits-row-meta">
5366 <strong>{cm.author}</strong>
5367 <span class="sep">·</span>
5368 <span
5369 class="commits-row-time"
5370 title={new Date(cm.date).toISOString()}
5371 >
5372 committed {relative(cm.date)}
5373 </span>
5374 {cm.parentShas.length > 1 && (
5375 <>
5376 <span class="sep">·</span>
5377 <span>merge of {cm.parentShas.length} parents</span>
5378 </>
5379 )}
5380 </div>
5381 </div>
5382 <div class="commits-row-side">
5383 <a
5384 href={`/${owner}/${repo}/commit/${cm.sha}`}
5385 class="commits-row-sha"
5386 title={cm.sha}
5387 >
5388 {cm.sha.slice(0, 7)}
5389 </a>
8c790e0Claude5390 <a
5391 href={`/${owner}/${repo}/push/${cm.sha}`}
5392 class="commits-row-watch"
5393 title="Watch gate + deploy results for this push"
5394 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5395 >
5396 <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">
5397 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5398 <circle cx="12" cy="12" r="3" />
5399 </svg>
5400 </a>
398a10cClaude5401 <button
5402 type="button"
5403 class="commits-row-copy"
5404 data-copy-sha={cm.sha}
5405 title="Copy full SHA"
5406 aria-label={`Copy SHA ${cm.sha}`}
5407 >
5408 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5409 <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" />
5410 <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" />
5411 </svg>
5412 </button>
5413 </div>
5414 </div>
5415 );
5416 })}
5417 </>
5418 ));
5419 })()}
efb11c5Claude5420 </div>
fc1817aClaude5421 )}
398a10cClaude5422 <script
5423 dangerouslySetInnerHTML={{
5424 __html: `
5425 (function(){
5426 document.addEventListener('click', function(e){
5427 var t = e.target; if (!t) return;
5428 var btn = t.closest && t.closest('[data-copy-sha]');
5429 if (!btn) return;
5430 e.preventDefault();
5431 var sha = btn.getAttribute('data-copy-sha') || '';
5432 if (!navigator.clipboard) return;
5433 navigator.clipboard.writeText(sha).then(function(){
5434 btn.classList.add('is-copied');
5435 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5436 }).catch(function(){});
5437 });
5438 })();
5439 `,
5440 }}
5441 />
fc1817aClaude5442 </Layout>
5443 );
5444});
5445
5446// Single commit with diff
5447web.get("/:owner/:repo/commit/:sha", async (c) => {
5448 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5449 const user = c.get("user");
5bb52faccanty labs5450 const gate = await assertRepoReadable(c, owner, repo);
5451 if (gate) return gate;
fc1817aClaude5452
05b973eClaude5453 // Fetch commit, full message, and diff in parallel
5454 const [commit, fullMessage, diffResult] = await Promise.all([
5455 getCommit(owner, repo, sha),
5456 getCommitFullMessage(owner, repo, sha),
5457 getDiff(owner, repo, sha),
5458 ]);
fc1817aClaude5459 if (!commit) {
5460 return c.html(
06d5ffeClaude5461 <Layout title="Not Found" user={user}>
fc1817aClaude5462 <div class="empty-state">
5463 <h2>Commit not found</h2>
5464 </div>
5465 </Layout>,
5466 404
5467 );
5468 }
5469
3951454Claude5470 // Block J3 — try to verify this commit's signature.
5471 let verification:
5472 | { verified: boolean; reason: string; signatureType: string | null }
5473 | null = null;
0cdfd89Claude5474 // Block J8 — external CI commit statuses rollup.
5475 let statusCombined:
5476 | {
5477 state: "pending" | "success" | "failure";
5478 total: number;
5479 contexts: Array<{
5480 context: string;
5481 state: string;
5482 description: string | null;
5483 targetUrl: string | null;
5484 }>;
5485 }
5486 | null = null;
3951454Claude5487 try {
5488 const [ownerRow] = await db
5489 .select()
5490 .from(users)
5491 .where(eq(users.username, owner))
5492 .limit(1);
5493 if (ownerRow) {
5494 const [repoRow] = await db
5495 .select()
5496 .from(repositories)
5497 .where(
5498 and(
5499 eq(repositories.ownerId, ownerRow.id),
5500 eq(repositories.name, repo)
5501 )
5502 )
5503 .limit(1);
5504 if (repoRow) {
5505 const { verifyCommit } = await import("../lib/signatures");
5506 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5507 verification = {
5508 verified: v.verified,
5509 reason: v.reason,
5510 signatureType: v.signatureType,
5511 };
0cdfd89Claude5512 try {
5513 const { combinedStatus } = await import("../lib/commit-statuses");
5514 const combined = await combinedStatus(repoRow.id, commit.sha);
5515 if (combined.total > 0) {
5516 statusCombined = {
5517 state: combined.state as any,
5518 total: combined.total,
5519 contexts: combined.contexts.map((c) => ({
5520 context: c.context,
5521 state: c.state,
5522 description: c.description,
5523 targetUrl: c.targetUrl,
5524 })),
5525 };
5526 }
5527 } catch {
5528 statusCombined = null;
5529 }
3951454Claude5530 }
5531 }
5532 } catch {
5533 verification = null;
5534 }
5535
05b973eClaude5536 const { files, raw } = diffResult;
fc1817aClaude5537
efb11c5Claude5538 // Diff stats: count additions / deletions across all files for the
5539 // header summary bar. Computed here from the parsed diff so we don't
5540 // touch the DiffView component.
5541 let additions = 0;
5542 let deletions = 0;
5543 for (const f of files) {
5544 const hunks = (f as any).hunks as Array<any> | undefined;
5545 if (Array.isArray(hunks)) {
5546 for (const h of hunks) {
5547 const lines = (h?.lines || []) as Array<any>;
5548 for (const ln of lines) {
5549 const t = ln?.type || ln?.kind;
5550 if (t === "add" || t === "added" || t === "+") additions += 1;
5551 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5552 deletions += 1;
5553 }
5554 }
5555 }
5556 }
5557 // Fall back: scan raw if file-level counting yielded zero (it's just a
5558 // header polish — never let a parsing miss break the page).
5559 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5560 for (const line of raw.split("\n")) {
5561 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5562 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5563 }
5564 }
5565 const fileCount = files.length;
5566
fc1817aClaude5567 return c.html(
06d5ffeClaude5568 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5569 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5570 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5571 <div class="commit-detail-card">
5572 <div class="commit-detail-eyebrow">
5573 <strong>Commit</strong>
5574 <span class="commit-detail-sha-pill" title={commit.sha}>
5575 {commit.sha.slice(0, 7)}
5576 </span>
3951454Claude5577 {verification && verification.reason !== "unsigned" && (
5578 <span
efb11c5Claude5579 class={`commit-detail-verify ${
3951454Claude5580 verification.verified
efb11c5Claude5581 ? "commit-detail-verify-ok"
5582 : "commit-detail-verify-warn"
3951454Claude5583 }`}
5584 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5585 >
5586 {verification.verified ? "Verified" : verification.reason}
5587 </span>
5588 )}
fc1817aClaude5589 </div>
efb11c5Claude5590 <h1 class="commit-detail-title">{commit.message}</h1>
5591 {fullMessage !== commit.message && (
5592 <pre class="commit-detail-body">{fullMessage}</pre>
5593 )}
5594 <div class="commit-detail-meta">
5595 <span class="commit-detail-author">
5596 <strong>{commit.author}</strong> committed on{" "}
5597 {new Date(commit.date).toLocaleDateString("en-US", {
5598 month: "long",
5599 day: "numeric",
5600 year: "numeric",
5601 })}
5602 </span>
fc1817aClaude5603 {commit.parentShas.length > 0 && (
efb11c5Claude5604 <span class="commit-detail-parents">
5605 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5606 {commit.parentShas.map((p, idx) => (
5607 <>
5608 {idx > 0 && " "}
5609 <a
5610 href={`/${owner}/${repo}/commit/${p}`}
5611 class="commit-detail-sha-link"
5612 >
5613 {p.slice(0, 7)}
5614 </a>
5615 </>
fc1817aClaude5616 ))}
5617 </span>
5618 )}
5619 </div>
efb11c5Claude5620 <div class="commit-detail-stats">
5621 <span class="commit-detail-stat">
5622 <strong>{fileCount}</strong>{" "}
5623 file{fileCount === 1 ? "" : "s"} changed
5624 </span>
5625 <span class="commit-detail-stat commit-detail-stat-add">
5626 <span class="commit-detail-stat-mark">+</span>
5627 <strong>{additions}</strong>
5628 </span>
5629 <span class="commit-detail-stat commit-detail-stat-del">
5630 <span class="commit-detail-stat-mark">−</span>
5631 <strong>{deletions}</strong>
5632 </span>
5633 <span class="commit-detail-sha-full" title="Full SHA">
5634 {commit.sha}
5635 </span>
5636 </div>
0cdfd89Claude5637 {statusCombined && (
efb11c5Claude5638 <div class="commit-detail-checks">
5639 <div class="commit-detail-checks-head">
5640 <strong>Checks</strong>
5641 <span class="commit-detail-checks-summary">
5642 {statusCombined.total} total ·{" "}
5643 <span
5644 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5645 >
5646 {statusCombined.state}
5647 </span>
0cdfd89Claude5648 </span>
efb11c5Claude5649 </div>
5650 <div class="commit-detail-check-row">
0cdfd89Claude5651 {statusCombined.contexts.map((cx) => (
5652 <span
efb11c5Claude5653 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5654 title={cx.description || cx.context}
5655 >
5656 {cx.targetUrl ? (
efb11c5Claude5657 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5658 {cx.context}: {cx.state}
5659 </a>
5660 ) : (
5661 <>
5662 {cx.context}: {cx.state}
5663 </>
5664 )}
5665 </span>
5666 ))}
5667 </div>
5668 </div>
5669 )}
fc1817aClaude5670 </div>
ea9ed4cClaude5671 <DiffView
5672 raw={raw}
5673 files={files}
5674 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5675 />
fc1817aClaude5676 </Layout>
5677 );
5678});
5679
79136bbClaude5680// Raw file download
5681web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5682 const { owner, repo } = c.req.param();
5bb52faccanty labs5683 const gate = await assertRepoReadable(c, owner, repo);
5684 if (gate) return gate;
79136bbClaude5685 const refAndPath = c.req.param("ref");
5686
5687 const branches = await listBranches(owner, repo);
5688 let ref = "";
5689 let filePath = "";
5690
5691 for (const branch of branches) {
5692 if (refAndPath.startsWith(branch + "/")) {
5693 ref = branch;
5694 filePath = refAndPath.slice(branch.length + 1);
5695 break;
5696 }
5697 }
5698
5699 if (!ref) {
5700 const slashIdx = refAndPath.indexOf("/");
5701 if (slashIdx === -1) return c.text("Not found", 404);
5702 ref = refAndPath.slice(0, slashIdx);
5703 filePath = refAndPath.slice(slashIdx + 1);
5704 }
5705
5706 const data = await getRawBlob(owner, repo, ref, filePath);
5707 if (!data) return c.text("Not found", 404);
5708
5709 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5710 return new Response(data as BodyInit, {
79136bbClaude5711 headers: {
5712 "Content-Type": "application/octet-stream",
5713 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5714 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5715 },
5716 });
5717});
5718
5719// Blame view
5720web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5721 const { owner, repo } = c.req.param();
5722 const user = c.get("user");
5bb52faccanty labs5723 const gate = await assertRepoReadable(c, owner, repo);
5724 if (gate) return gate;
79136bbClaude5725 const refAndPath = c.req.param("ref");
5726
5727 const branches = await listBranches(owner, repo);
5728 let ref = "";
5729 let filePath = "";
5730
5731 for (const branch of branches) {
5732 if (refAndPath.startsWith(branch + "/")) {
5733 ref = branch;
5734 filePath = refAndPath.slice(branch.length + 1);
5735 break;
5736 }
5737 }
5738
5739 if (!ref) {
5740 const slashIdx = refAndPath.indexOf("/");
5741 if (slashIdx === -1) return c.text("Not found", 404);
5742 ref = refAndPath.slice(0, slashIdx);
5743 filePath = refAndPath.slice(slashIdx + 1);
5744 }
5745
5746 const blameLines = await getBlame(owner, repo, ref, filePath);
5747 if (blameLines.length === 0) {
5748 return c.html(
5749 <Layout title="Not Found" user={user}>
5750 <div class="empty-state">
5751 <h2>File not found</h2>
5752 </div>
5753 </Layout>,
5754 404
5755 );
5756 }
5757
5758 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5759 // Unique contributors (by author) tracked once for the header chip.
5760 const blameAuthors = new Set<string>();
5761 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5762
5763 return c.html(
5764 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5765 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5766 <RepoHeader owner={owner} repo={repo} />
5767 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5768 <header class="blame-head">
5769 <div class="blame-eyebrow">
5770 <span class="blame-eyebrow-dot" aria-hidden="true" />
5771 Blame · Line-by-line history
5772 </div>
5773 <h1 class="blame-title">
5774 <code>{fileName}</code>
5775 </h1>
5776 <p class="blame-sub">
5777 Each line is annotated with the commit that last touched it.
5778 Click any SHA to jump to that commit and see the surrounding
5779 change.
5780 </p>
5781 </header>
efb11c5Claude5782 <div class="blame-toolbar">
5783 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5784 </div>
398a10cClaude5785 <div class="blame-card">
5786 <div class="blame-header">
efb11c5Claude5787 <div class="blame-header-meta">
5788 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5789 <span class="blame-header-name">{fileName}</span>
5790 <span class="blame-header-tag">Blame</span>
5791 <span class="blame-header-stats">
5792 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5793 {blameAuthors.size} contributor
5794 {blameAuthors.size === 1 ? "" : "s"}
5795 </span>
5796 </div>
5797 <div class="blame-header-actions">
5798 <a
5799 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5800 class="blob-pill"
5801 >
5802 Normal view
5803 </a>
5804 <a
5805 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5806 class="blob-pill"
5807 >
5808 Raw
5809 </a>
5810 </div>
79136bbClaude5811 </div>
398a10cClaude5812 <div style="overflow-x:auto">
5813 <table class="blame-table">
79136bbClaude5814 <tbody>
5815 {blameLines.map((line, i) => {
5816 const showInfo =
5817 i === 0 || blameLines[i - 1].sha !== line.sha;
5818 return (
398a10cClaude5819 <tr class={showInfo ? "blame-row-first" : ""}>
5820 <td class="blame-gutter">
79136bbClaude5821 {showInfo && (
398a10cClaude5822 <span class="blame-gutter-inner">
79136bbClaude5823 <a
5824 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5825 class="blame-gutter-sha"
5826 title={`Commit ${line.sha}`}
79136bbClaude5827 >
5828 {line.sha.slice(0, 7)}
398a10cClaude5829 </a>
5830 <span class="blame-gutter-author" title={line.author}>
5831 {line.author}
5832 </span>
5833 </span>
79136bbClaude5834 )}
5835 </td>
398a10cClaude5836 <td class="blame-line-num">{line.lineNum}</td>
5837 <td class="blame-line-content">{line.content}</td>
79136bbClaude5838 </tr>
5839 );
5840 })}
5841 </tbody>
5842 </table>
5843 </div>
5844 </div>
5845 </Layout>
5846 );
5847});
5848
53c9249Claude5849// Search — keyword + optional Claude semantic mode
79136bbClaude5850web.get("/:owner/:repo/search", async (c) => {
5851 const { owner, repo } = c.req.param();
5852 const user = c.get("user");
5bb52faccanty labs5853 const gate = await assertRepoReadable(c, owner, repo);
5854 if (gate) return gate;
79136bbClaude5855 const q = c.req.query("q") || "";
53c9249Claude5856 const aiAvailable = isAiAvailable();
5857 // Default to semantic when Claude is available and no explicit mode set.
5858 const modeParam = c.req.query("mode");
5859 const mode: "semantic" | "keyword" =
5860 modeParam === "keyword"
5861 ? "keyword"
5862 : modeParam === "semantic"
5863 ? "semantic"
5864 : aiAvailable
5865 ? "semantic"
5866 : "keyword";
5867 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5868
5869 if (!(await repoExists(owner, repo))) return c.notFound();
5870
5871 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5872
5873 // Keyword results (always available as fallback / when in keyword mode)
5874 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5875 // Semantic results (Claude-powered)
5876 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5877 let semanticHits: SemanticHit[] = [];
5878 let semanticMode: "semantic" | "keyword" = "semantic";
5879 let quotaExceeded = false;
79136bbClaude5880
5881 if (q.trim()) {
53c9249Claude5882 if (isSemantic) {
5883 // Resolve repo DB id for caching
5884 const [ownerRow] = await db
5885 .select({ id: users.id })
5886 .from(users)
5887 .where(eq(users.username, owner))
5888 .limit(1);
5889 const [repoRow] = ownerRow
5890 ? await db
5891 .select({ id: repositories.id })
5892 .from(repositories)
5893 .where(
5894 and(
5895 eq(repositories.ownerId, ownerRow.id),
5896 eq(repositories.name, repo)
5897 )
5898 )
5899 .limit(1)
5900 : [];
5901
5902 const rateLimitKey = user
5903 ? `user:${user.id}`
5904 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5905
5906 const searchResult = await claudeSemanticSearch(
5907 owner,
5908 repo,
5909 repoRow?.id ?? `${owner}/${repo}`,
5910 q.trim(),
5911 { branch: defaultBranch, rateLimitKey }
5912 );
5913 semanticHits = searchResult.results;
5914 semanticMode = searchResult.mode;
5915 quotaExceeded = searchResult.quotaExceeded;
5916 } else {
5917 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5918 }
79136bbClaude5919 }
5920
53c9249Claude5921 const aiSearchCss = `
5922 /* ─── Semantic search mode toggle ─── */
5923 .search-mode-bar {
5924 display: flex;
5925 align-items: center;
5926 gap: 8px;
5927 margin-bottom: 14px;
5928 flex-wrap: wrap;
5929 }
5930 .search-mode-label {
5931 font-size: 12.5px;
5932 color: var(--text-muted);
5933 font-weight: 500;
5934 }
5935 .search-mode-toggle {
5936 display: inline-flex;
5937 background: var(--bg-elevated);
5938 border: 1px solid var(--border);
5939 border-radius: 9999px;
5940 padding: 3px;
5941 gap: 2px;
5942 }
5943 .search-mode-btn {
5944 display: inline-flex;
5945 align-items: center;
5946 gap: 5px;
5947 padding: 5px 12px;
5948 border-radius: 9999px;
5949 font-size: 12.5px;
5950 font-weight: 500;
5951 color: var(--text-muted);
5952 text-decoration: none;
5953 transition: color 120ms ease, background 120ms ease;
5954 cursor: pointer;
5955 border: none;
5956 background: none;
5957 font: inherit;
5958 }
5959 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5960 .search-mode-btn.active {
6fd5915Claude5961 background: rgba(91,110,232,0.15);
53c9249Claude5962 color: var(--text-strong);
5963 }
5964 .search-mode-ai-badge {
5965 display: inline-flex;
5966 align-items: center;
5967 gap: 4px;
5968 padding: 2px 8px;
5969 border-radius: 9999px;
5970 font-size: 11px;
5971 font-weight: 600;
6fd5915Claude5972 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5973 color: #c4b5fd;
6fd5915Claude5974 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5975 margin-left: 2px;
5976 }
5977 .search-quota-warn {
5978 font-size: 12.5px;
5979 color: var(--text-muted);
5980 padding: 6px 12px;
5981 background: rgba(255,200,0,0.06);
5982 border: 1px solid rgba(255,200,0,0.20);
5983 border-radius: 8px;
5984 }
5985
5986 /* ─── Semantic result cards ─── */
5987 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5988 .sem-result {
5989 padding: 14px 16px;
5990 background: var(--bg-elevated);
5991 border: 1px solid var(--border);
5992 border-radius: 12px;
5993 transition: border-color 120ms ease;
5994 }
5995 .sem-result:hover { border-color: var(--border-strong); }
5996 .sem-result-head {
5997 display: flex;
5998 align-items: flex-start;
5999 justify-content: space-between;
6000 gap: 10px;
6001 flex-wrap: wrap;
6002 margin-bottom: 6px;
6003 }
6004 .sem-result-path {
6005 font-family: var(--font-mono);
6006 font-size: 13px;
6007 font-weight: 600;
6008 color: var(--text-strong);
6009 text-decoration: none;
6010 word-break: break-all;
6011 }
6012 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6013 .sem-result-conf {
6014 display: inline-flex;
6015 align-items: center;
6016 gap: 4px;
6017 padding: 2px 9px;
6018 border-radius: 9999px;
6019 font-size: 11.5px;
6020 font-weight: 600;
6021 white-space: nowrap;
6022 flex-shrink: 0;
6023 }
e589f77ccantynz-alt6024 .sem-conf-strong { background: rgba(52,211,153,0.12); color: var(--green); border: 1px solid rgba(52,211,153,0.30); }
6025 .sem-conf-possible { background: rgba(91,110,232,0.12); color: var(--accent); border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6026 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6027 .sem-result-reason {
6028 font-size: 12.5px;
6029 color: var(--text-muted);
6030 margin-bottom: 8px;
6031 line-height: 1.5;
6032 }
6033 .sem-result-snippet {
6034 margin: 0;
6035 padding: 10px 12px;
6036 background: rgba(0,0,0,0.22);
6037 border: 1px solid var(--border);
6038 border-radius: 8px;
6039 font-family: var(--font-mono);
6040 font-size: 12px;
6041 line-height: 1.55;
6042 color: var(--text);
6043 overflow-x: auto;
6044 white-space: pre-wrap;
6045 word-break: break-word;
6046 max-height: 240px;
6047 overflow-y: auto;
6048 }
6049 `;
6050
6051 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6052 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6053
79136bbClaude6054 return c.html(
6055 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6056 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6057 <RepoHeader owner={owner} repo={repo} />
6058 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6059 <div class="search-hero">
6060 <div class="search-eyebrow">
6061 <strong>Search</strong> · {owner}/{repo}
6062 </div>
6063 <h1 class="search-title">
53c9249Claude6064 {isSemantic ? (
6065 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6066 ) : (
6067 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6068 )}
efb11c5Claude6069 </h1>
6070 <form
6071 method="get"
6072 action={`/${owner}/${repo}/search`}
6073 class="search-form"
6074 role="search"
6075 >
53c9249Claude6076 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6077 <div class="search-input-wrap">
6078 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6079 <input
6080 type="text"
6081 name="q"
6082 value={q}
53c9249Claude6083 placeholder={
6084 isSemantic
6085 ? "Ask a question or describe what you're looking for…"
6086 : "Search code on the default branch…"
6087 }
efb11c5Claude6088 aria-label="Search code"
6089 class="search-input"
6090 autocomplete="off"
6091 autofocus
6092 />
6093 </div>
6094 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6095 Search
6096 </button>
efb11c5Claude6097 </form>
6098 </div>
53c9249Claude6099
6100 {/* Mode toggle */}
6101 <div class="search-mode-bar">
6102 <span class="search-mode-label">Mode:</span>
6103 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6104 {aiAvailable ? (
6105 <a
6106 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6107 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6108 aria-pressed={isSemantic ? "true" : "false"}
6109 >
6110 {"✨"} Semantic AI
6111 <span class="search-mode-ai-badge">AI-powered</span>
6112 </a>
6113 ) : null}
6114 <a
6115 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6116 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6117 aria-pressed={!isSemantic ? "true" : "false"}
6118 >
6119 {"⌕"} Keyword
6120 </a>
6121 </div>
6122 {isSemantic && aiAvailable && (
6123 <span style="font-size:12px;color:var(--text-muted)">
6124 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6125 </span>
53c9249Claude6126 )}
6127 {quotaExceeded && (
6128 <span class="search-quota-warn">
6129 Semantic search quota reached — showing keyword results
efb11c5Claude6130 </span>
53c9249Claude6131 )}
6132 </div>
6133
6134 {/* ─── Semantic results ─── */}
6135 {isSemantic && q && (
6136 <>
6137 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6138 <div class="search-quota-warn" style="margin-bottom:10px">
6139 Falling back to keyword search — Claude found no relevant files.
6140 </div>
6141 )}
6142 {semanticHits.length === 0 ? (
6143 <div class="search-empty">
6144 <p>
6145 No matches for <strong>"{q}"</strong>.{" "}
6146 Try a different phrasing or{" "}
6147 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6148 switch to keyword search
6149 </a>.
6150 </p>
6151 </div>
6152 ) : (
6153 <>
6154 <div class="search-results-head">
6155 <span class="search-results-count">
6156 <strong>{semanticHits.length}</strong> result
6157 {semanticHits.length !== 1 ? "s" : ""}
6158 </span>
6159 <span class="search-results-query">
6160 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6161 <span class="search-results-q">"{q}"</span>
6162 </span>
6163 </div>
6164 <div class="sem-results">
6165 {semanticHits.map((h) => {
6166 const confClass =
6167 h.confidence > 0.7
6168 ? "sem-conf-strong"
6169 : h.confidence > 0.4
6170 ? "sem-conf-possible"
6171 : "sem-conf-weak";
6172 const confLabel =
6173 h.confidence > 0.7
6174 ? "Strong match"
6175 : h.confidence > 0.4
6176 ? "Possible match"
6177 : "Weak match";
6178 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6179 const preview =
6180 h.snippet.length > 800
6181 ? h.snippet.slice(0, 800) + "\n…"
6182 : h.snippet;
6183 return (
6184 <div class="sem-result">
6185 <div class="sem-result-head">
6186 <a href={href} class="sem-result-path">
6187 {h.file}
6188 {h.lineNumber ? (
6189 <span style="color:var(--text-muted);font-weight:500">
6190 :{h.lineNumber}
6191 </span>
6192 ) : null}
6193 </a>
6194 <span class={`sem-result-conf ${confClass}`}>
6195 {confLabel}
6196 </span>
6197 </div>
6198 {h.reason && (
6199 <p class="sem-result-reason">{h.reason}</p>
6200 )}
6201 {preview && (
6202 <pre class="sem-result-snippet">{preview}</pre>
6203 )}
6204 </div>
6205 );
6206 })}
6207 </div>
6208 </>
6209 )}
6210 </>
79136bbClaude6211 )}
53c9249Claude6212
6213 {/* ─── Keyword results ─── */}
6214 {!isSemantic && q && (
6215 <>
6216 <div class="search-results-head">
6217 <span class="search-results-count">
6218 <strong>{keywordResults.length}</strong> result
6219 {keywordResults.length !== 1 ? "s" : ""}
6220 </span>
6221 <span class="search-results-query">
6222 for <span class="search-results-q">"{q}"</span> on{" "}
6223 <code>{defaultBranch}</code>
6224 </span>
6225 </div>
6226 {keywordResults.length === 0 ? (
6227 <div class="search-empty">
6228 <p>
6229 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6230 {aiAvailable && (
6231 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6232 try AI semantic search
79136bbClaude6233 </a>
53c9249Claude6234 )}.
6235 </p>
6236 </div>
6237 ) : (
6238 <div class="search-results">
6239 {(() => {
6240 const grouped: Record<
6241 string,
6242 Array<{ lineNum: number; line: string }>
6243 > = {};
6244 for (const r of keywordResults) {
6245 if (!grouped[r.file]) grouped[r.file] = [];
6246 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6247 }
6248 return Object.entries(grouped).map(([file, matches]) => (
6249 <div class="search-file diff-file">
6250 <div class="search-file-head diff-file-header">
6251 <a
6252 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6253 class="search-file-link"
6254 >
6255 {file}
6256 </a>
6257 <span class="search-file-count">
6258 {matches.length} match{matches.length === 1 ? "" : "es"}
6259 </span>
6260 </div>
6261 <div class="blob-code">
6262 <table>
6263 <tbody>
6264 {matches.map((m) => (
6265 <tr>
6266 <td class="line-num">{m.lineNum}</td>
6267 <td class="line-content">{m.line}</td>
6268 </tr>
6269 ))}
6270 </tbody>
6271 </table>
6272 </div>
6273 </div>
6274 ));
6275 })()}
6276 </div>
6277 )}
6278 </>
6279 )}
79136bbClaude6280 </Layout>
6281 );
6282});
6283
fc1817aClaude6284export default web;