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.tsxBlame6320 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 {
527e1e3ccantynz-alt136 // Case-insensitive slug match; carry back the CANONICAL owner/repo so we
137 // can (a) never feed the caller's raw casing to the git layer and (b)
138 // redirect to the canonical URL below.
28b52beccantynz-alt139 // Single joined lookup. This used to be two sequential round-trips
140 // (users, then repositories); on a managed Postgres every hop is real
141 // latency and the repo-home handler re-fetched these same two rows again
142 // further down. Fetch the FULL repo row once and publish it on the
143 // context (below) so downstream blocks can reuse it instead of re-querying.
144 const [joined] = await db
145 .select({ owner: users, repo: repositories })
5bb52faccanty labs146 .from(repositories)
28b52beccantynz-alt147 .innerJoin(users, eq(repositories.ownerId, users.id))
5bb52faccanty labs148 .where(
527e1e3ccantynz-alt149 and(
28b52beccantynz-alt150 sql`lower(${users.username}) = lower(${owner})`,
527e1e3ccantynz-alt151 sql`lower(${repositories.name}) = lower(${repo})`
152 )
5bb52faccanty labs153 )
154 .limit(1);
28b52beccantynz-alt155 if (!joined) return notFound();
156 const ownerRow = joined.owner;
157 const repoRow = joined.repo;
5bb52faccanty labs158
159 const access = await resolveRepoAccess({
160 repoId: repoRow.id,
161 userId: user?.id ?? null,
162 isPublic: !repoRow.isPrivate,
163 });
164 // Positive denial: the repo exists and the viewer lacks read access.
165 // Return 404 (not 403) so we never confirm a private repo's existence.
166 if (!satisfiesAccess(access, "read")) return notFound();
167
527e1e3ccantynz-alt168 // Canonical-casing redirect. If the URL used a different casing than the
169 // stored slug (e.g. /ccantynz/gluecron.com for Gluecron.com), 302 to the
170 // canonical path so every downstream git-on-disk op receives the real
171 // casing and never 404s on the case-sensitive filesystem (the "R3" trap).
172 // Done AFTER the access check so a private repo's exact casing is never
173 // revealed to a caller who can't read it.
174 if (ownerRow.username !== owner || repoRow.name !== repo) {
175 const segs = c.req.path.split("/");
176 // segs = ["", "<owner>", "<repo>", ...rest]
177 segs[1] = encodeURIComponent(ownerRow.username);
178 segs[2] = encodeURIComponent(repoRow.name);
179 const search = new URL(c.req.url).search;
180 return c.redirect(segs.join("/") + search, 302);
181 }
182
28b52beccantynz-alt183 // Publish the already-resolved rows for this request. Handlers that need
184 // the owner/repo row can read this instead of issuing their own lookups —
185 // repo-home alone was re-fetching `users` once and `repositories` twice
186 // more, all sequentially, for rows we already had in hand.
187 c.set("resolvedRepoCtx", { ownerRow, repoRow, access });
188
5bb52faccanty labs189 return null;
190 } catch (err) {
6b75ac1ccanty labs191 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
192 // lookup errored, so we cannot positively establish that this repo is
193 // public. Denying is the only safe choice for an access-control gate —
194 // failing open here would re-expose private repos during a DB hiccup,
195 // which is the exact vulnerability this function exists to close.
5bb52faccanty labs196 console.warn(
6b75ac1ccanty labs197 `[web] assertRepoReadable: access check failed for ${owner}/${repo} — denying:`,
5bb52faccanty labs198 err instanceof Error ? err.message : err
199 );
6b75ac1ccanty labs200 return notFound();
5bb52faccanty labs201 }
202}
203
ebaae0fClaude204// ---------------------------------------------------------------------------
205// Repo AI stats — computed once per repo home page load, best-effort.
206// Fast because every WHERE clause is bounded to a 7-day window.
207// ---------------------------------------------------------------------------
208interface RepoAiStats {
209 mergedCount: number;
210 reviewCount: number;
211 securityAlertCount: number;
212 hoursSaved: string;
213}
214
215async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
216 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
217
218 // 1. AI-merged PRs this week: activity_feed entries with action =
219 // 'auto_merge.merged' in the last 7 days for this repo.
220 // This is cheaper than a JOIN on pull_requests and covers both the
221 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
222 const [mergedRow] = await db
223 .select({ n: count() })
224 .from(activityFeed)
225 .where(
226 and(
227 eq(activityFeed.repositoryId, repoId),
228 eq(activityFeed.action, "auto_merge.merged"),
229 gte(activityFeed.createdAt, since)
230 )
231 );
232 const mergedCount = Number(mergedRow?.n ?? 0);
233
234 // 2. AI reviews this week: pr_comments where is_ai_review = true in
235 // the last 7 days, joined through pull_requests to scope to this repo.
236 const [reviewRow] = await db
237 .select({ n: count() })
238 .from(prComments)
239 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
240 .where(
241 and(
242 eq(pullRequests.repositoryId, repoId),
243 eq(prComments.isAiReview, true),
244 gte(prComments.createdAt, since)
245 )
246 );
247 const reviewCount = Number(reviewRow?.n ?? 0);
248
249 // 3. Open security alerts: open issues that carry a label whose name
250 // contains 'security' (case-insensitive) for this repo.
251 const [secRow] = await db
252 .select({ n: count() })
253 .from(issues)
254 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
255 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
256 .where(
257 and(
258 eq(issues.repositoryId, repoId),
259 eq(issues.state, "open"),
260 sql`lower(${labels.name}) like '%security%'`
261 )
262 );
263 const securityAlertCount = Number(secRow?.n ?? 0);
264
265 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
266 const hours = mergedCount * 1.5 + reviewCount * 0.5;
267 const hoursSaved = hours.toFixed(1);
268
269 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
270}
271
8c790e0Claude272/**
273 * Query the most recent push to a repo from the activity_feed table.
274 * Returns a RecentPush if there's been a push in the last 24 hours,
275 * or null otherwise. Used by the RepoHeader live/watch indicator.
276 *
277 * Queries activity_feed where action='push' and targetId holds the commit SHA.
278 */
279async function getRecentPush(repoId: string): Promise<RecentPush | null> {
280 try {
281 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
282 const [row] = await db
283 .select({
284 targetId: activityFeed.targetId,
285 createdAt: activityFeed.createdAt,
286 })
287 .from(activityFeed)
288 .where(
289 and(
290 eq(activityFeed.repositoryId, repoId),
291 eq(activityFeed.action, "push"),
292 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
293 )
294 )
295 .orderBy(desc(activityFeed.createdAt))
296 .limit(1);
297 if (!row || !row.targetId) return null;
298 return {
299 sha: row.targetId,
300 ageMs: Date.now() - new Date(row.createdAt).getTime(),
301 };
302 } catch {
303 // DB unavailable or schema mismatch — degrade gracefully.
304 return null;
305 }
306}
307
efb11c5Claude308/**
309 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
310 *
311 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
312 * scope is route-local and `layout.tsx` is locked. Each polished handler
313 * injects this via a `<style>` tag; the rules are namespaced by surface
314 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
315 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
316 * `.repo-home-*` styling Agent A already shipped.
317 */
318const codeBrowseCss = `
319 /* ───────── shared primitives ───────── */
320 .cb-hairline::before,
321 .new-repo-hero::before,
322 .profile-hero::before,
323 .commits-hero::before,
324 .commit-detail-card::before,
325 .search-hero::before {
326 content: '';
327 position: absolute;
328 top: 0; left: 0; right: 0;
329 height: 2px;
6fd5915Claude330 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude331 opacity: 0.7;
332 pointer-events: none;
333 }
334 @keyframes cbHeroOrb {
335 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
336 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
337 }
338 @media (prefers-reduced-motion: reduce) {
339 .new-repo-hero-orb,
340 .profile-hero-orb,
341 .commits-hero-orb { animation: none; }
342 }
343
344 /* ───────── new-repo ───────── */
345 .new-repo-hero {
346 position: relative;
347 margin-bottom: var(--space-5);
348 padding: var(--space-5) var(--space-6);
349 background: var(--bg-elevated);
350 border: 1px solid var(--border);
351 border-radius: 16px;
352 overflow: hidden;
353 }
354 .new-repo-hero-orb-wrap {
355 position: absolute;
356 inset: -25% -10% auto auto;
357 width: 360px;
358 height: 360px;
359 pointer-events: none;
360 z-index: 0;
361 }
362 .new-repo-hero-orb {
363 position: absolute;
364 inset: 0;
6fd5915Claude365 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude366 filter: blur(80px);
367 opacity: 0.7;
368 animation: cbHeroOrb 14s ease-in-out infinite;
369 }
370 .new-repo-hero-inner { position: relative; z-index: 1; }
371 .new-repo-eyebrow {
372 font-size: 12px;
373 font-family: var(--font-mono);
374 color: var(--text-muted);
375 letter-spacing: 0.1em;
376 text-transform: uppercase;
377 margin-bottom: var(--space-2);
378 }
379 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
380 .new-repo-title {
381 font-family: var(--font-display);
382 font-weight: 800;
383 letter-spacing: -0.028em;
384 font-size: clamp(28px, 4vw, 40px);
385 line-height: 1.05;
386 margin: 0 0 var(--space-2);
387 color: var(--text-strong);
388 }
389 .new-repo-sub {
390 font-size: 15px;
391 color: var(--text-muted);
392 margin: 0;
393 line-height: 1.55;
394 max-width: 620px;
395 }
396 .new-repo-form {
397 max-width: 680px;
398 }
399 .new-repo-error {
400 background: rgba(218, 54, 51, 0.12);
401 border: 1px solid rgba(218, 54, 51, 0.35);
402 color: #ffb3b3;
403 padding: 10px 14px;
404 border-radius: 10px;
405 margin-bottom: var(--space-4);
406 font-size: 14px;
407 }
408 .new-repo-form-grid {
409 display: flex;
410 flex-direction: column;
411 gap: var(--space-4);
412 }
413 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
414 .new-repo-label {
415 font-size: 13px;
416 color: var(--text-strong);
417 font-weight: 600;
418 }
419 .new-repo-label-optional {
420 color: var(--text-muted);
421 font-weight: 400;
422 font-size: 12px;
423 }
424 .new-repo-input {
425 appearance: none;
426 width: 100%;
427 background: var(--bg);
428 border: 1px solid var(--border);
429 color: var(--text-strong);
430 border-radius: 10px;
431 padding: 10px 12px;
432 font-size: 14px;
433 font-family: inherit;
434 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
435 }
436 .new-repo-input:focus {
437 outline: none;
438 border-color: var(--accent);
6fd5915Claude439 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude440 }
441 .new-repo-input-disabled {
442 color: var(--text-muted);
443 background: var(--bg-secondary);
444 cursor: not-allowed;
445 }
446 .new-repo-hint {
447 font-size: 12px;
448 color: var(--text-muted);
449 margin: 4px 0 0;
450 }
451 .new-repo-hint code {
452 font-family: var(--font-mono);
453 font-size: 11.5px;
454 background: var(--bg-secondary);
455 border: 1px solid var(--border);
456 border-radius: 5px;
457 padding: 1px 5px;
458 color: var(--text-strong);
459 }
460 .new-repo-visibility {
461 display: grid;
462 grid-template-columns: 1fr 1fr;
463 gap: var(--space-2);
464 }
465 @media (max-width: 600px) {
466 .new-repo-visibility { grid-template-columns: 1fr; }
467 }
468 .new-repo-vis-card {
469 display: flex;
470 gap: 10px;
471 padding: 14px;
472 background: var(--bg);
473 border: 1px solid var(--border);
474 border-radius: 12px;
475 cursor: pointer;
476 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
477 }
478 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
479 .new-repo-vis-card:has(input:checked) {
6fd5915Claude480 border-color: rgba(91,110,232,0.55);
481 background: rgba(91,110,232,0.06);
482 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude483 }
484 .new-repo-vis-radio {
485 margin-top: 3px;
486 accent-color: var(--accent);
487 }
488 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
489 .new-repo-vis-label {
490 font-size: 14px;
491 font-weight: 600;
492 color: var(--text-strong);
493 }
494 .new-repo-vis-desc {
495 font-size: 12.5px;
496 color: var(--text-muted);
497 line-height: 1.45;
498 }
499 .new-repo-callout {
500 margin-top: var(--space-2);
501 padding: var(--space-3) var(--space-4);
6fd5915Claude502 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
503 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude504 border-radius: 12px;
505 }
506 .new-repo-callout-eyebrow {
507 font-size: 11px;
508 font-family: var(--font-mono);
509 text-transform: uppercase;
510 letter-spacing: 0.12em;
511 color: var(--accent);
512 font-weight: 700;
513 margin-bottom: 4px;
514 }
515 .new-repo-callout-body {
516 font-size: 13px;
517 color: var(--text);
518 line-height: 1.5;
519 margin: 0;
520 }
521 .new-repo-callout-body code {
522 font-family: var(--font-mono);
523 font-size: 12px;
524 color: var(--accent);
6fd5915Claude525 background: rgba(91,110,232,0.1);
efb11c5Claude526 border-radius: 4px;
527 padding: 1px 5px;
528 }
398a10cClaude529 .new-repo-templates {
530 display: flex;
531 flex-wrap: wrap;
532 gap: 8px;
533 margin-top: 4px;
534 }
535 .new-repo-template-chip {
536 position: relative;
537 display: inline-flex;
538 align-items: center;
539 gap: 6px;
540 padding: 8px 14px;
541 background: var(--bg);
542 border: 1px solid var(--border);
543 border-radius: 999px;
544 font-size: 13px;
545 color: var(--text);
546 cursor: pointer;
547 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
548 }
549 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
550 .new-repo-template-chip:hover {
551 border-color: var(--border-strong, var(--border));
552 color: var(--text-strong);
553 }
554 .new-repo-template-chip:has(input:checked) {
6fd5915Claude555 border-color: rgba(91,110,232,0.55);
556 background: rgba(91,110,232,0.10);
398a10cClaude557 color: var(--text-strong);
6fd5915Claude558 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude559 }
560 .new-repo-template-chip-dot {
561 width: 8px; height: 8px;
562 border-radius: 999px;
563 background: var(--text-faint, var(--text-muted));
564 transition: background 140ms ease, box-shadow 140ms ease;
565 }
566 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude567 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
568 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude569 }
efb11c5Claude570 .new-repo-actions {
571 display: flex;
572 gap: var(--space-2);
573 margin-top: var(--space-2);
398a10cClaude574 align-items: center;
575 }
576 .new-repo-submit {
577 min-width: 180px;
6fd5915Claude578 border: 1px solid rgba(91,110,232,0.45);
579 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude580 color: #fff;
581 font-weight: 700;
6fd5915Claude582 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude583 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
584 }
585 .new-repo-submit:hover {
586 transform: translateY(-1px);
6fd5915Claude587 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude588 filter: brightness(1.06);
589 }
590 .new-repo-submit:focus-visible {
6fd5915Claude591 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude592 outline-offset: 2px;
efb11c5Claude593 }
594
595 /* ───────── profile ───────── */
596 .profile-hero {
597 position: relative;
598 margin-bottom: var(--space-5);
599 padding: var(--space-5) var(--space-6);
600 background: var(--bg-elevated);
601 border: 1px solid var(--border);
602 border-radius: 16px;
603 overflow: hidden;
604 }
605 .profile-hero-orb-wrap {
606 position: absolute;
607 inset: -25% -10% auto auto;
608 width: 360px;
609 height: 360px;
610 pointer-events: none;
611 z-index: 0;
612 }
613 .profile-hero-orb {
614 position: absolute;
615 inset: 0;
6fd5915Claude616 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude617 filter: blur(80px);
618 opacity: 0.7;
619 animation: cbHeroOrb 14s ease-in-out infinite;
620 }
621 .profile-hero-inner {
622 position: relative;
623 z-index: 1;
624 display: flex;
625 align-items: flex-start;
626 gap: var(--space-5);
627 }
628 .profile-hero-avatar {
629 flex: 0 0 auto;
630 width: 88px;
631 height: 88px;
632 border-radius: 50%;
6fd5915Claude633 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude634 color: #fff;
635 display: flex;
636 align-items: center;
637 justify-content: center;
638 font-size: 38px;
639 font-weight: 700;
640 font-family: var(--font-display);
6fd5915Claude641 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude642 }
643 .profile-hero-text { flex: 1; min-width: 0; }
644 .profile-eyebrow {
645 font-size: 12px;
646 font-family: var(--font-mono);
647 color: var(--text-muted);
648 letter-spacing: 0.1em;
649 text-transform: uppercase;
650 margin-bottom: var(--space-2);
651 }
652 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
653 .profile-name {
654 font-family: var(--font-display);
655 font-weight: 800;
656 letter-spacing: -0.028em;
657 font-size: clamp(28px, 3.6vw, 36px);
658 line-height: 1.05;
659 margin: 0 0 4px;
660 color: var(--text-strong);
661 }
662 .profile-handle {
663 font-family: var(--font-mono);
664 font-size: 13px;
665 color: var(--text-muted);
666 margin-bottom: var(--space-2);
667 }
668 .profile-bio {
669 font-size: 14.5px;
670 color: var(--text);
671 line-height: 1.55;
672 margin: 0 0 var(--space-3);
673 max-width: 640px;
674 }
675 .profile-meta {
676 display: flex;
677 align-items: center;
678 gap: var(--space-4);
679 flex-wrap: wrap;
680 font-size: 13px;
681 }
682 .profile-meta-link {
683 color: var(--text-muted);
684 transition: color var(--t-fast, 0.15s) ease;
685 }
686 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
687 .profile-meta-link strong {
688 color: var(--text-strong);
689 font-weight: 600;
690 font-variant-numeric: tabular-nums;
691 }
692 .profile-follow-form { margin: 0; }
693 @media (max-width: 600px) {
694 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
695 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
696 }
697 .profile-readme {
698 margin-bottom: var(--space-6);
699 background: var(--bg-elevated);
700 border: 1px solid var(--border);
701 border-radius: 12px;
702 overflow: hidden;
703 }
704 .profile-readme-head {
705 display: flex;
706 align-items: center;
707 gap: 8px;
708 padding: 10px 16px;
709 background: var(--bg-secondary);
710 border-bottom: 1px solid var(--border);
711 font-size: 13px;
712 color: var(--text-muted);
713 }
714 .profile-readme-icon { color: var(--accent); font-size: 14px; }
715 .profile-readme-body { padding: var(--space-5) var(--space-6); }
716 .profile-section-head {
717 display: flex;
718 align-items: baseline;
719 gap: var(--space-2);
720 margin-bottom: var(--space-3);
721 }
722 .profile-section-title {
723 font-family: var(--font-display);
724 font-weight: 700;
725 font-size: 20px;
726 letter-spacing: -0.015em;
727 margin: 0;
728 color: var(--text-strong);
729 }
730 .profile-section-count {
731 font-family: var(--font-mono);
732 font-size: 12px;
733 color: var(--text-muted);
734 background: var(--bg-secondary);
735 border: 1px solid var(--border);
736 border-radius: 999px;
737 padding: 2px 10px;
738 font-variant-numeric: tabular-nums;
739 }
740 .profile-empty {
741 display: flex;
742 align-items: center;
743 justify-content: space-between;
744 gap: var(--space-3);
745 padding: var(--space-4) var(--space-5);
746 background: var(--bg-elevated);
747 border: 1px dashed var(--border);
748 border-radius: 12px;
749 }
750 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
751
752 /* ───────── tree (file browser) ───────── */
753 .tree-header {
754 margin-bottom: var(--space-3);
755 display: flex;
756 flex-direction: column;
757 gap: var(--space-2);
758 }
759 .tree-header-row {
760 display: flex;
761 align-items: center;
762 justify-content: space-between;
763 gap: var(--space-3);
764 flex-wrap: wrap;
765 }
766 .tree-header-stats {
767 display: flex;
768 align-items: center;
769 gap: var(--space-3);
770 font-size: 12px;
771 color: var(--text-muted);
772 }
773 .tree-stat strong {
774 color: var(--text-strong);
775 font-weight: 600;
776 font-variant-numeric: tabular-nums;
777 }
778 .tree-stat-link {
779 color: var(--text-muted);
780 padding: 4px 10px;
781 border-radius: 999px;
782 border: 1px solid var(--border);
783 background: var(--bg-elevated);
784 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
785 }
786 .tree-stat-link:hover {
787 color: var(--accent);
6fd5915Claude788 border-color: rgba(91,110,232,0.45);
efb11c5Claude789 text-decoration: none;
790 }
791 .tree-breadcrumb-row {
792 font-size: 13px;
793 }
794
795 /* ───────── blob (file viewer) ───────── */
796 .blob-toolbar {
797 margin-bottom: var(--space-3);
798 display: flex;
799 flex-direction: column;
800 gap: var(--space-2);
801 }
802 .blob-breadcrumb { font-size: 13px; }
803 .blob-card {
804 background: var(--bg-elevated);
805 border: 1px solid var(--border);
806 border-radius: 12px;
807 overflow: hidden;
808 }
809 .blob-header-polished {
810 display: flex;
811 align-items: center;
812 justify-content: space-between;
813 gap: var(--space-3);
814 padding: 10px 14px;
815 background: var(--bg-secondary);
816 border-bottom: 1px solid var(--border);
817 }
818 .blob-header-meta {
819 display: flex;
820 align-items: center;
821 gap: var(--space-2);
822 min-width: 0;
823 flex: 1;
824 }
825 .blob-header-icon { font-size: 14px; opacity: 0.85; }
826 .blob-header-name {
827 font-family: var(--font-mono);
828 font-size: 13px;
829 color: var(--text-strong);
830 font-weight: 600;
831 overflow: hidden;
832 text-overflow: ellipsis;
833 white-space: nowrap;
834 }
835 .blob-header-size {
836 font-family: var(--font-mono);
837 font-size: 11.5px;
838 color: var(--text-muted);
839 font-variant-numeric: tabular-nums;
840 border-left: 1px solid var(--border);
841 padding-left: var(--space-2);
842 margin-left: 2px;
843 }
844 .blob-header-actions {
845 display: flex;
846 gap: 6px;
847 flex-shrink: 0;
848 }
849 .blob-pill {
850 display: inline-flex;
851 align-items: center;
852 padding: 4px 12px;
853 font-size: 12px;
854 font-weight: 500;
855 color: var(--text);
856 background: var(--bg-elevated);
857 border: 1px solid var(--border);
858 border-radius: 999px;
859 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
860 }
861 .blob-pill:hover {
862 color: var(--accent);
6fd5915Claude863 border-color: rgba(91,110,232,0.45);
efb11c5Claude864 text-decoration: none;
6fd5915Claude865 background: rgba(91,110,232,0.06);
efb11c5Claude866 }
867 .blob-pill-accent {
868 color: var(--accent);
6fd5915Claude869 border-color: rgba(91,110,232,0.35);
870 background: rgba(91,110,232,0.08);
efb11c5Claude871 }
872 .blob-pill-accent:hover {
6fd5915Claude873 background: rgba(91,110,232,0.14);
efb11c5Claude874 }
875 .blob-binary {
876 padding: var(--space-5);
877 color: var(--text-muted);
878 text-align: center;
879 font-size: 13px;
880 background: var(--bg);
881 }
882
883 /* ───────── commits list ───────── */
884 .commits-hero {
885 position: relative;
886 margin-bottom: var(--space-4);
887 padding: var(--space-5) var(--space-6);
888 background: var(--bg-elevated);
889 border: 1px solid var(--border);
890 border-radius: 16px;
891 overflow: hidden;
892 }
893 .commits-hero-orb-wrap {
894 position: absolute;
895 inset: -25% -10% auto auto;
896 width: 320px;
897 height: 320px;
898 pointer-events: none;
899 z-index: 0;
900 }
901 .commits-hero-orb {
902 position: absolute;
903 inset: 0;
6fd5915Claude904 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude905 filter: blur(80px);
906 opacity: 0.7;
907 animation: cbHeroOrb 14s ease-in-out infinite;
908 }
909 .commits-hero-inner { position: relative; z-index: 1; }
910 .commits-eyebrow {
911 font-size: 12px;
912 font-family: var(--font-mono);
913 color: var(--text-muted);
914 letter-spacing: 0.1em;
915 text-transform: uppercase;
916 margin-bottom: var(--space-2);
917 }
918 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
919 .commits-title {
920 font-family: var(--font-display);
921 font-weight: 800;
922 letter-spacing: -0.025em;
923 font-size: clamp(22px, 3vw, 30px);
924 line-height: 1.15;
925 margin: 0 0 var(--space-2);
926 color: var(--text-strong);
927 }
928 .commits-branch {
929 font-family: var(--font-mono);
930 font-size: 0.7em;
931 color: var(--text);
932 background: var(--bg-secondary);
933 border: 1px solid var(--border);
934 border-radius: 6px;
935 padding: 2px 8px;
936 font-weight: 500;
937 vertical-align: middle;
938 }
939 .commits-sub {
940 font-size: 14px;
941 color: var(--text-muted);
942 margin: 0;
943 line-height: 1.55;
944 max-width: 640px;
945 }
398a10cClaude946 .commits-toolbar {
947 display: flex;
948 justify-content: space-between;
949 align-items: center;
950 flex-wrap: wrap;
951 gap: var(--space-2);
952 margin-bottom: var(--space-3);
953 }
954 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
955 .commits-toolbar-link {
956 display: inline-flex;
957 align-items: center;
958 gap: 6px;
959 padding: 6px 12px;
960 font-size: 12.5px;
961 font-weight: 500;
962 color: var(--text-muted);
963 background: rgba(255,255,255,0.025);
964 border: 1px solid var(--border);
965 border-radius: 8px;
966 text-decoration: none;
967 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
968 }
969 .commits-toolbar-link:hover {
970 border-color: var(--border-strong);
971 color: var(--text-strong);
972 background: rgba(255,255,255,0.04);
973 text-decoration: none;
974 }
efb11c5Claude975 .commits-list-wrap {
976 background: var(--bg-elevated);
977 border: 1px solid var(--border);
398a10cClaude978 border-radius: 14px;
efb11c5Claude979 overflow: hidden;
398a10cClaude980 position: relative;
981 }
982 .commits-list-wrap::before {
983 content: '';
984 position: absolute;
985 top: 0; left: 0; right: 0;
986 height: 2px;
6fd5915Claude987 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude988 opacity: 0.55;
989 pointer-events: none;
990 }
991 .commits-day-head {
992 display: flex;
993 align-items: center;
994 gap: 10px;
995 padding: 10px 18px;
996 font-size: 11.5px;
997 font-family: var(--font-mono);
998 text-transform: uppercase;
999 letter-spacing: 0.08em;
1000 color: var(--text-muted);
1001 background: var(--bg-secondary);
1002 border-bottom: 1px solid var(--border);
1003 }
1004 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
1005 .commits-day-head-dot {
1006 width: 6px; height: 6px;
1007 border-radius: 9999px;
6fd5915Claude1008 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude1009 }
1010 .commits-row {
1011 display: flex;
1012 align-items: flex-start;
1013 gap: 14px;
1014 padding: 14px 18px;
1015 border-bottom: 1px solid var(--border-subtle);
1016 transition: background 120ms ease;
1017 }
1018 .commits-row:last-child { border-bottom: none; }
1019 .commits-row:hover { background: rgba(255,255,255,0.022); }
1020 .commits-avatar {
1021 width: 34px; height: 34px;
1022 border-radius: 9999px;
6fd5915Claude1023 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude1024 color: #fff;
1025 display: inline-flex;
1026 align-items: center;
1027 justify-content: center;
1028 font-family: var(--font-display);
1029 font-weight: 700;
1030 font-size: 13.5px;
1031 flex-shrink: 0;
1032 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
1033 }
1034 .commits-row-body { flex: 1; min-width: 0; }
1035 .commits-row-msg {
1036 display: flex;
1037 align-items: center;
1038 gap: 8px;
1039 flex-wrap: wrap;
1040 }
1041 .commits-row-msg a {
1042 font-family: var(--font-display);
1043 font-weight: 600;
1044 font-size: 14px;
1045 color: var(--text-strong);
1046 text-decoration: none;
1047 letter-spacing: -0.005em;
1048 }
1049 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1050 .commits-row-verified {
1051 font-size: 9.5px;
1052 padding: 1px 7px;
1053 border-radius: 9999px;
1054 background: rgba(52,211,153,0.16);
e589f77ccantynz-alt1055 color: var(--green);
398a10cClaude1056 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1057 text-transform: uppercase;
1058 letter-spacing: 0.06em;
1059 font-weight: 700;
1060 }
1061 .commits-row-meta {
1062 margin-top: 4px;
1063 display: flex;
1064 align-items: center;
1065 gap: 8px;
1066 flex-wrap: wrap;
1067 font-size: 12.5px;
1068 color: var(--text-muted);
1069 }
1070 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1071 .commits-row-meta .sep { opacity: 0.4; }
1072 .commits-row-time {
1073 font-variant-numeric: tabular-nums;
1074 }
1075 .commits-row-side {
1076 display: flex;
1077 align-items: center;
1078 gap: 8px;
1079 flex-shrink: 0;
1080 }
1081 .commits-row-sha {
1082 display: inline-flex;
1083 align-items: center;
1084 padding: 4px 10px;
1085 border-radius: 9999px;
1086 font-family: var(--font-mono);
1087 font-size: 11.5px;
1088 font-weight: 600;
1089 color: var(--text-strong);
1090 background: rgba(255,255,255,0.04);
1091 border: 1px solid var(--border);
1092 text-decoration: none;
1093 letter-spacing: 0.04em;
1094 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1095 }
1096 .commits-row-sha:hover {
6fd5915Claude1097 border-color: rgba(91,110,232,0.55);
398a10cClaude1098 color: var(--accent);
6fd5915Claude1099 background: rgba(91,110,232,0.08);
398a10cClaude1100 text-decoration: none;
1101 }
1102 .commits-row-copy {
1103 display: inline-flex;
1104 align-items: center;
1105 justify-content: center;
1106 width: 28px; height: 28px;
1107 padding: 0;
1108 border-radius: 8px;
1109 background: transparent;
1110 border: 1px solid var(--border);
1111 color: var(--text-muted);
1112 cursor: pointer;
1113 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1114 }
398a10cClaude1115 .commits-row-copy:hover {
1116 border-color: var(--border-strong);
1117 color: var(--text);
1118 background: rgba(255,255,255,0.04);
1119 }
e589f77ccantynz-alt1120 .commits-row-copy.is-copied { color: var(--green); border-color: rgba(52,211,153,0.35); }
8c790e0Claude1121 /* Push Watch link — eye icon next to the SHA chip */
1122 .commits-row-watch {
1123 display: inline-flex;
1124 align-items: center;
1125 justify-content: center;
1126 width: 28px;
1127 height: 28px;
1128 border-radius: 6px;
1129 border: 1px solid var(--border);
1130 color: var(--text-muted);
1131 background: transparent;
1132 cursor: pointer;
1133 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1134 text-decoration: none !important;
1135 }
1136 .commits-row-watch:hover {
6fd5915Claude1137 border-color: rgba(91,110,232,0.45);
8c790e0Claude1138 color: var(--accent);
6fd5915Claude1139 background: rgba(91,110,232,0.08);
8c790e0Claude1140 text-decoration: none !important;
1141 }
efb11c5Claude1142 .commits-empty {
398a10cClaude1143 position: relative;
1144 overflow: hidden;
1145 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1146 text-align: center;
398a10cClaude1147 background: var(--bg-elevated);
1148 border: 1px dashed var(--border-strong);
1149 border-radius: 16px;
1150 }
1151 .commits-empty-orb {
1152 position: absolute;
1153 inset: -40% 30% auto 30%;
1154 height: 280px;
6fd5915Claude1155 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1156 filter: blur(70px);
1157 opacity: 0.7;
1158 pointer-events: none;
1159 z-index: 0;
1160 }
1161 .commits-empty-inner { position: relative; z-index: 1; }
1162 .commits-empty-icon {
1163 width: 56px; height: 56px;
1164 border-radius: 9999px;
6fd5915Claude1165 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1166 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1167 display: inline-flex;
1168 align-items: center;
1169 justify-content: center;
1170 color: #c4b5fd;
1171 margin: 0 auto 14px;
1172 }
1173 .commits-empty-title {
1174 font-family: var(--font-display);
1175 font-size: 18px;
1176 font-weight: 700;
1177 margin: 0 0 6px;
1178 color: var(--text-strong);
1179 }
1180 .commits-empty-sub {
1181 margin: 0 auto 0;
1182 font-size: 13.5px;
efb11c5Claude1183 color: var(--text-muted);
398a10cClaude1184 max-width: 420px;
1185 line-height: 1.5;
1186 }
1187
1188 /* ───────── branches list ───────── */
1189 .branches-list {
efb11c5Claude1190 background: var(--bg-elevated);
398a10cClaude1191 border: 1px solid var(--border);
1192 border-radius: 14px;
1193 overflow: hidden;
1194 position: relative;
1195 }
1196 .branches-list::before {
1197 content: '';
1198 position: absolute;
1199 top: 0; left: 0; right: 0;
1200 height: 2px;
6fd5915Claude1201 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1202 opacity: 0.55;
1203 pointer-events: none;
1204 }
1205 .branches-row {
1206 display: flex;
1207 align-items: center;
1208 gap: var(--space-3);
1209 padding: 14px 18px;
1210 border-bottom: 1px solid var(--border-subtle);
1211 transition: background 120ms ease;
1212 flex-wrap: wrap;
1213 }
1214 .branches-row:last-child { border-bottom: none; }
1215 .branches-row:hover { background: rgba(255,255,255,0.022); }
1216 .branches-row-icon {
1217 width: 32px; height: 32px;
1218 border-radius: 8px;
6fd5915Claude1219 background: rgba(91,110,232,0.10);
398a10cClaude1220 color: #c4b5fd;
1221 display: inline-flex;
1222 align-items: center;
1223 justify-content: center;
1224 flex-shrink: 0;
6fd5915Claude1225 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1226 }
1227 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1228 .branches-row-name {
1229 display: inline-flex;
1230 align-items: center;
1231 gap: 8px;
1232 flex-wrap: wrap;
1233 }
1234 .branches-row-name a {
1235 font-family: var(--font-mono);
1236 font-size: 13.5px;
1237 color: var(--text-strong);
1238 font-weight: 600;
1239 text-decoration: none;
1240 }
1241 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1242 .branches-row-default {
1243 font-size: 10px;
1244 padding: 2px 8px;
1245 border-radius: 9999px;
6fd5915Claude1246 background: rgba(91,110,232,0.14);
398a10cClaude1247 color: #c4b5fd;
6fd5915Claude1248 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1249 text-transform: uppercase;
1250 letter-spacing: 0.08em;
1251 font-weight: 700;
1252 font-family: var(--font-mono);
1253 }
1254 .branches-row-meta {
1255 display: flex;
1256 align-items: center;
1257 gap: 8px;
1258 flex-wrap: wrap;
1259 font-size: 12.5px;
1260 color: var(--text-muted);
1261 font-variant-numeric: tabular-nums;
1262 }
1263 .branches-row-meta .sep { opacity: 0.4; }
1264 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1265 .branches-row-side {
1266 display: flex;
1267 align-items: center;
1268 gap: 10px;
1269 flex-shrink: 0;
1270 flex-wrap: wrap;
1271 }
1272 .branches-row-divergence {
1273 display: inline-flex;
1274 align-items: center;
1275 gap: 8px;
1276 font-family: var(--font-mono);
1277 font-size: 11.5px;
1278 color: var(--text-muted);
1279 padding: 4px 10px;
1280 border-radius: 9999px;
1281 background: rgba(255,255,255,0.035);
1282 border: 1px solid var(--border);
1283 font-variant-numeric: tabular-nums;
1284 }
e589f77ccantynz-alt1285 .branches-row-divergence .ahead { color: var(--green); }
1286 .branches-row-divergence .behind { color: var(--red); }
398a10cClaude1287 .branches-row-actions {
1288 display: flex;
1289 align-items: center;
1290 gap: 6px;
1291 }
1292 .branches-btn {
1293 display: inline-flex;
1294 align-items: center;
1295 gap: 5px;
1296 padding: 6px 12px;
1297 border-radius: 9999px;
1298 font-size: 12px;
1299 font-weight: 600;
1300 text-decoration: none;
1301 border: 1px solid var(--border);
1302 background: transparent;
1303 color: var(--text-muted);
1304 cursor: pointer;
1305 font: inherit;
1306 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1307 }
1308 .branches-btn:hover {
1309 border-color: var(--border-strong);
1310 color: var(--text);
1311 background: rgba(255,255,255,0.04);
1312 text-decoration: none;
1313 }
1314 .branches-btn-danger {
e589f77ccantynz-alt1315 color: var(--red);
398a10cClaude1316 border-color: rgba(248,113,113,0.30);
1317 }
1318 .branches-btn-danger:hover {
1319 border-style: dashed;
1320 border-color: rgba(248,113,113,0.65);
1321 background: rgba(248,113,113,0.06);
1322 color: #fecaca;
1323 }
1324
1325 /* ───────── tags list ───────── */
1326 .tags-list {
1327 background: var(--bg-elevated);
1328 border: 1px solid var(--border);
1329 border-radius: 14px;
1330 overflow: hidden;
1331 position: relative;
1332 }
1333 .tags-list::before {
1334 content: '';
1335 position: absolute;
1336 top: 0; left: 0; right: 0;
1337 height: 2px;
6fd5915Claude1338 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1339 opacity: 0.55;
1340 pointer-events: none;
1341 }
1342 .tags-row {
1343 display: flex;
1344 align-items: center;
1345 gap: var(--space-3);
1346 padding: 14px 18px;
1347 border-bottom: 1px solid var(--border-subtle);
1348 transition: background 120ms ease;
1349 flex-wrap: wrap;
1350 }
1351 .tags-row:last-child { border-bottom: none; }
1352 .tags-row:hover { background: rgba(255,255,255,0.022); }
1353 .tags-row-icon {
1354 width: 32px; height: 32px;
1355 border-radius: 8px;
6fd5915Claude1356 background: rgba(95,143,160,0.10);
398a10cClaude1357 color: #67e8f9;
1358 display: inline-flex;
1359 align-items: center;
1360 justify-content: center;
1361 flex-shrink: 0;
6fd5915Claude1362 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1363 }
1364 .tags-row-main { flex: 1; min-width: 240px; }
1365 .tags-row-name {
1366 display: inline-flex;
1367 align-items: center;
1368 gap: 8px;
1369 flex-wrap: wrap;
1370 }
1371 .tags-row-version {
1372 display: inline-flex;
1373 align-items: center;
1374 padding: 3px 11px;
1375 border-radius: 9999px;
1376 font-family: var(--font-mono);
1377 font-size: 13px;
1378 font-weight: 700;
1379 color: #67e8f9;
6fd5915Claude1380 background: rgba(95,143,160,0.12);
1381 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1382 letter-spacing: 0.01em;
1383 }
1384 .tags-row-meta {
1385 margin-top: 4px;
1386 display: flex;
1387 align-items: center;
1388 gap: 8px;
1389 flex-wrap: wrap;
1390 font-size: 12.5px;
1391 color: var(--text-muted);
1392 font-variant-numeric: tabular-nums;
1393 }
1394 .tags-row-meta .sep { opacity: 0.4; }
1395 .tags-row-sha {
1396 font-family: var(--font-mono);
1397 font-size: 11.5px;
1398 color: var(--text-strong);
1399 padding: 2px 8px;
1400 border-radius: 6px;
1401 background: rgba(255,255,255,0.04);
1402 border: 1px solid var(--border);
1403 text-decoration: none;
1404 letter-spacing: 0.04em;
1405 }
1406 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1407 .tags-row-side {
1408 display: flex;
1409 align-items: center;
1410 gap: 6px;
1411 flex-shrink: 0;
1412 flex-wrap: wrap;
1413 }
1414 .tags-row-link {
1415 display: inline-flex;
1416 align-items: center;
1417 gap: 5px;
1418 padding: 6px 12px;
1419 border-radius: 9999px;
1420 font-size: 12px;
1421 font-weight: 600;
1422 text-decoration: none;
1423 color: var(--text-muted);
1424 background: rgba(255,255,255,0.025);
1425 border: 1px solid var(--border);
1426 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1427 }
1428 .tags-row-link:hover {
6fd5915Claude1429 border-color: rgba(91,110,232,0.45);
398a10cClaude1430 color: var(--text-strong);
6fd5915Claude1431 background: rgba(91,110,232,0.06);
398a10cClaude1432 text-decoration: none;
efb11c5Claude1433 }
1434
1435 /* ───────── commit detail ───────── */
1436 .commit-detail-card {
1437 position: relative;
1438 margin-bottom: var(--space-5);
1439 padding: var(--space-5) var(--space-5);
1440 background: var(--bg-elevated);
1441 border: 1px solid var(--border);
1442 border-radius: 14px;
1443 overflow: hidden;
1444 }
1445 .commit-detail-eyebrow {
1446 display: flex;
1447 align-items: center;
1448 gap: var(--space-2);
1449 font-size: 12px;
1450 font-family: var(--font-mono);
1451 text-transform: uppercase;
1452 letter-spacing: 0.1em;
1453 color: var(--text-muted);
1454 margin-bottom: var(--space-2);
1455 }
1456 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1457 .commit-detail-sha-pill {
1458 font-family: var(--font-mono);
1459 font-size: 11.5px;
1460 color: var(--text-strong);
1461 background: var(--bg-secondary);
1462 border: 1px solid var(--border);
1463 border-radius: 999px;
1464 padding: 2px 10px;
1465 letter-spacing: 0.04em;
1466 }
1467 .commit-detail-verify {
1468 font-size: 10px;
1469 padding: 2px 8px;
1470 border-radius: 999px;
1471 text-transform: uppercase;
1472 letter-spacing: 0.06em;
1473 font-weight: 700;
1474 color: #fff;
1475 }
1476 .commit-detail-verify-ok {
1477 background: linear-gradient(135deg, #2ea043, #34d399);
1478 }
1479 .commit-detail-verify-warn {
1480 background: linear-gradient(135deg, #d29922, #f59e0b);
1481 }
1482 .commit-detail-title {
1483 font-family: var(--font-display);
1484 font-weight: 700;
1485 letter-spacing: -0.018em;
1486 font-size: clamp(20px, 2.5vw, 26px);
1487 line-height: 1.25;
1488 margin: 0 0 var(--space-2);
1489 color: var(--text-strong);
1490 }
1491 .commit-detail-body {
1492 white-space: pre-wrap;
1493 color: var(--text-muted);
1494 font-size: 14px;
1495 line-height: 1.55;
1496 margin: 0 0 var(--space-3);
1497 font-family: var(--font-mono);
1498 background: var(--bg);
1499 border: 1px solid var(--border);
1500 border-radius: 8px;
1501 padding: var(--space-3) var(--space-4);
1502 max-height: 280px;
1503 overflow: auto;
1504 }
1505 .commit-detail-meta {
1506 display: flex;
1507 flex-wrap: wrap;
1508 gap: var(--space-3);
1509 font-size: 13px;
1510 color: var(--text-muted);
1511 margin-bottom: var(--space-3);
1512 }
1513 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1514 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1515 .commit-detail-sha-link {
1516 font-family: var(--font-mono);
1517 font-size: 12.5px;
1518 color: var(--accent);
6fd5915Claude1519 background: rgba(91,110,232,0.08);
efb11c5Claude1520 border-radius: 6px;
1521 padding: 1px 6px;
1522 margin-left: 2px;
1523 }
6fd5915Claude1524 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1525 .commit-detail-stats {
1526 display: flex;
1527 flex-wrap: wrap;
1528 align-items: center;
1529 gap: var(--space-3);
1530 padding-top: var(--space-3);
1531 border-top: 1px solid var(--border);
1532 font-size: 13px;
1533 color: var(--text-muted);
1534 }
1535 .commit-detail-stat {
1536 display: inline-flex;
1537 align-items: center;
1538 gap: 4px;
1539 font-variant-numeric: tabular-nums;
1540 }
1541 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1542 .commit-detail-stat-add strong { color: var(--green); }
1543 .commit-detail-stat-del strong { color: var(--red); }
efb11c5Claude1544 .commit-detail-stat-mark {
1545 font-family: var(--font-mono);
1546 font-weight: 700;
1547 font-size: 14px;
1548 }
e589f77ccantynz-alt1549 .commit-detail-stat-add .commit-detail-stat-mark { color: var(--green); }
1550 .commit-detail-stat-del .commit-detail-stat-mark { color: var(--red); }
efb11c5Claude1551 .commit-detail-sha-full {
1552 margin-left: auto;
1553 font-family: var(--font-mono);
1554 font-size: 11.5px;
1555 color: var(--text-faint);
1556 letter-spacing: 0.02em;
1557 overflow: hidden;
1558 text-overflow: ellipsis;
1559 white-space: nowrap;
1560 max-width: 100%;
1561 }
1562 .commit-detail-checks {
1563 margin-top: var(--space-3);
1564 padding-top: var(--space-3);
1565 border-top: 1px solid var(--border);
1566 }
1567 .commit-detail-checks-head {
1568 display: flex;
1569 align-items: center;
1570 gap: var(--space-2);
1571 font-size: 13px;
1572 color: var(--text-muted);
1573 margin-bottom: 8px;
1574 }
1575 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1576 .commit-detail-check-state-success { color: var(--green); font-weight: 600; }
1577 .commit-detail-check-state-failure { color: var(--red); font-weight: 600; }
efb11c5Claude1578 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1579 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1580 .commit-detail-check {
1581 font-size: 11px;
1582 padding: 2px 8px;
1583 border-radius: 999px;
1584 color: #fff;
1585 font-weight: 500;
1586 }
398a10cClaude1587 .commit-detail-check a { color: inherit; text-decoration: none; }
1588 .commit-detail-check-success { background: #2ea043; }
1589 .commit-detail-check-pending { background: #d29922; }
1590 .commit-detail-check-failure { background: #da3633; }
1591
1592 /* ───────── blame ───────── */
1593 .blame-head { margin-bottom: var(--space-5); }
1594 .blame-eyebrow {
1595 display: inline-flex;
1596 align-items: center;
1597 gap: 8px;
1598 text-transform: uppercase;
1599 font-family: var(--font-mono);
1600 font-size: 11px;
1601 letter-spacing: 0.16em;
1602 color: var(--text-muted);
1603 font-weight: 600;
1604 margin-bottom: 10px;
1605 }
1606 .blame-eyebrow-dot {
1607 width: 8px; height: 8px;
1608 border-radius: 9999px;
6fd5915Claude1609 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1610 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1611 }
1612 .blame-title {
1613 font-family: var(--font-display);
1614 font-size: clamp(22px, 3vw, 30px);
1615 font-weight: 800;
1616 letter-spacing: -0.025em;
1617 line-height: 1.15;
1618 margin: 0 0 6px;
1619 color: var(--text-strong);
1620 }
1621 .blame-title code {
1622 font-family: var(--font-mono);
1623 font-size: 0.78em;
1624 color: var(--text-strong);
1625 font-weight: 700;
1626 }
1627 .blame-sub {
1628 margin: 0;
1629 font-size: 14px;
1630 color: var(--text-muted);
1631 line-height: 1.5;
1632 max-width: 700px;
1633 }
efb11c5Claude1634 .blame-toolbar {
398a10cClaude1635 display: flex;
1636 justify-content: space-between;
1637 align-items: center;
1638 gap: var(--space-3);
efb11c5Claude1639 margin-bottom: var(--space-3);
398a10cClaude1640 flex-wrap: wrap;
efb11c5Claude1641 font-size: 13px;
1642 }
398a10cClaude1643 .blame-toolbar-actions { display: flex; gap: 6px; }
1644 .blame-card {
1645 background: var(--bg-elevated);
1646 border: 1px solid var(--border);
1647 border-radius: 14px;
1648 overflow: hidden;
1649 position: relative;
1650 }
1651 .blame-card::before {
1652 content: '';
1653 position: absolute;
1654 top: 0; left: 0; right: 0;
1655 height: 2px;
6fd5915Claude1656 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1657 opacity: 0.55;
1658 pointer-events: none;
1659 }
efb11c5Claude1660 .blame-header {
1661 display: flex;
1662 align-items: center;
1663 justify-content: space-between;
1664 gap: var(--space-3);
1665 padding: 10px 14px;
1666 background: var(--bg-secondary);
1667 border-bottom: 1px solid var(--border);
1668 }
1669 .blame-header-meta {
1670 display: flex;
1671 align-items: center;
1672 gap: var(--space-2);
1673 min-width: 0;
1674 flex-wrap: wrap;
1675 }
1676 .blame-header-icon { color: var(--accent); font-size: 14px; }
1677 .blame-header-name {
1678 font-family: var(--font-mono);
1679 font-size: 13px;
1680 color: var(--text-strong);
1681 font-weight: 600;
1682 }
1683 .blame-header-tag {
1684 font-size: 10.5px;
1685 text-transform: uppercase;
1686 letter-spacing: 0.08em;
1687 font-family: var(--font-mono);
6fd5915Claude1688 background: rgba(91,110,232,0.12);
efb11c5Claude1689 color: var(--accent);
1690 border-radius: 999px;
1691 padding: 2px 8px;
1692 font-weight: 600;
1693 }
1694 .blame-header-stats {
1695 font-family: var(--font-mono);
1696 font-size: 11.5px;
1697 color: var(--text-muted);
1698 font-variant-numeric: tabular-nums;
1699 border-left: 1px solid var(--border);
1700 padding-left: var(--space-2);
1701 }
1702 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1703 .blame-table {
1704 width: 100%;
1705 border-collapse: collapse;
1706 font-family: var(--font-mono);
1707 font-size: 12.5px;
1708 line-height: 1.6;
1709 }
1710 .blame-table tr { border-bottom: 1px solid transparent; }
1711 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1712 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1713 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1714 .blame-gutter {
1715 width: 220px;
1716 min-width: 220px;
1717 padding: 0 12px;
1718 vertical-align: top;
1719 background: rgba(255,255,255,0.012);
1720 border-right: 1px solid var(--border-subtle);
1721 font-variant-numeric: tabular-nums;
1722 color: var(--text-muted);
1723 font-size: 11px;
1724 white-space: nowrap;
1725 overflow: hidden;
1726 text-overflow: ellipsis;
1727 padding-top: 2px;
1728 padding-bottom: 2px;
1729 }
1730 .blame-gutter-inner {
1731 display: inline-flex;
1732 align-items: center;
1733 gap: 7px;
1734 max-width: 100%;
1735 overflow: hidden;
1736 }
1737 .blame-gutter-sha {
1738 font-family: var(--font-mono);
1739 font-size: 10.5px;
1740 font-weight: 600;
1741 color: #c4b5fd;
6fd5915Claude1742 background: rgba(91,110,232,0.10);
398a10cClaude1743 padding: 1px 7px;
1744 border-radius: 9999px;
6fd5915Claude1745 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1746 text-decoration: none;
1747 letter-spacing: 0.04em;
1748 flex-shrink: 0;
1749 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1750 }
1751 .blame-gutter-sha:hover {
6fd5915Claude1752 background: rgba(91,110,232,0.22);
398a10cClaude1753 color: #ddd6fe;
6fd5915Claude1754 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1755 text-decoration: none;
1756 }
1757 .blame-gutter-author {
1758 color: var(--text);
1759 overflow: hidden;
1760 text-overflow: ellipsis;
1761 white-space: nowrap;
1762 font-size: 11px;
1763 font-family: var(--font-sans, inherit);
1764 }
1765 .blame-line-num {
1766 width: 1%;
1767 min-width: 50px;
1768 padding: 0 12px;
1769 text-align: right;
1770 color: var(--text-faint);
1771 user-select: none;
1772 border-right: 1px solid var(--border-subtle);
1773 font-variant-numeric: tabular-nums;
1774 }
1775 .blame-line-content {
1776 padding: 0 14px;
1777 white-space: pre;
1778 color: var(--text);
1779 transition: background 120ms ease;
1780 }
efb11c5Claude1781
1782 /* ───────── search ───────── */
1783 .search-hero {
1784 position: relative;
1785 margin-bottom: var(--space-4);
1786 padding: var(--space-5) var(--space-6);
1787 background: var(--bg-elevated);
1788 border: 1px solid var(--border);
1789 border-radius: 16px;
1790 overflow: hidden;
1791 }
1792 .search-eyebrow {
1793 font-size: 12px;
1794 font-family: var(--font-mono);
1795 color: var(--text-muted);
1796 letter-spacing: 0.1em;
1797 text-transform: uppercase;
1798 margin-bottom: var(--space-2);
1799 }
1800 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1801 .search-title {
1802 font-family: var(--font-display);
1803 font-weight: 800;
1804 letter-spacing: -0.025em;
1805 font-size: clamp(22px, 3vw, 30px);
1806 line-height: 1.15;
1807 margin: 0 0 var(--space-3);
1808 color: var(--text-strong);
1809 }
1810 .search-form {
1811 display: flex;
1812 gap: var(--space-2);
1813 align-items: stretch;
1814 }
1815 .search-input-wrap {
1816 position: relative;
1817 flex: 1;
1818 display: flex;
1819 align-items: center;
1820 }
1821 .search-input-icon {
1822 position: absolute;
1823 left: 12px;
1824 color: var(--text-muted);
1825 font-size: 15px;
1826 pointer-events: none;
1827 }
1828 .search-input {
1829 appearance: none;
1830 width: 100%;
1831 padding: 10px 12px 10px 34px;
1832 background: var(--bg);
1833 border: 1px solid var(--border);
1834 border-radius: 10px;
1835 color: var(--text-strong);
1836 font-size: 14px;
1837 font-family: inherit;
1838 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1839 }
1840 .search-input:focus {
1841 outline: none;
1842 border-color: var(--accent);
6fd5915Claude1843 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1844 }
1845 .search-submit { min-width: 96px; }
1846 .search-results-head {
1847 display: flex;
1848 align-items: baseline;
1849 gap: var(--space-2);
1850 margin-bottom: var(--space-3);
1851 font-size: 13px;
1852 color: var(--text-muted);
1853 }
1854 .search-results-count strong {
1855 color: var(--text-strong);
1856 font-weight: 600;
1857 font-variant-numeric: tabular-nums;
1858 }
1859 .search-results-q { color: var(--text-strong); font-weight: 600; }
1860 .search-results-head code {
1861 font-family: var(--font-mono);
1862 font-size: 12px;
1863 background: var(--bg-secondary);
1864 border: 1px solid var(--border);
1865 border-radius: 5px;
1866 padding: 1px 6px;
1867 color: var(--text);
1868 }
1869 .search-empty {
1870 padding: var(--space-5) var(--space-6);
1871 background: var(--bg-elevated);
1872 border: 1px dashed var(--border);
1873 border-radius: 12px;
1874 color: var(--text-muted);
1875 font-size: 14px;
1876 }
1877 .search-empty strong { color: var(--text-strong); }
1878 .search-results {
1879 display: flex;
1880 flex-direction: column;
1881 gap: var(--space-3);
1882 }
1883 .search-file-head {
1884 display: flex;
1885 align-items: center;
1886 justify-content: space-between;
1887 gap: var(--space-2);
1888 }
1889 .search-file-link {
1890 font-family: var(--font-mono);
1891 font-size: 13px;
1892 color: var(--text-strong);
1893 font-weight: 600;
1894 }
1895 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1896 .search-file-count {
1897 font-family: var(--font-mono);
1898 font-size: 11.5px;
1899 color: var(--text-muted);
1900 font-variant-numeric: tabular-nums;
1901 }
1902`;
1903
fc1817aClaude1904// Home page
06d5ffeClaude1905web.get("/", async (c) => {
1906 const user = c.get("user");
1907
1908 if (user) {
0316dbbClaude1909 return c.redirect("/dashboard");
06d5ffeClaude1910 }
1911
8e9f1d9Claude1912 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1913 let publicStats: PublicStats | null = null;
8e9f1d9Claude1914 try {
1915 const [repoRow] = await db
1916 .select({ n: sql<number>`count(*)::int` })
1917 .from(repositories)
1918 .where(eq(repositories.isPrivate, false));
1919 const [userRow] = await db
1920 .select({ n: sql<number>`count(*)::int` })
1921 .from(users);
1922 stats = {
1923 publicRepos: Number(repoRow?.n ?? 0),
1924 users: Number(userRow?.n ?? 0),
1925 };
1926 } catch {
1927 stats = undefined;
1928 }
1929
52ad8b1Claude1930 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1931 try {
1932 publicStats = await computePublicStats();
1933 } catch {
1934 publicStats = null;
1935 }
1936
534f04aClaude1937 // Block M1 — initial SSR snapshot for the live-now demo feed.
1938 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1939 // in try/catch so a freak module-level explosion can't take down /.
8ed88f2ccantynz-alt1940 let liveFeed: LandingProLiveFeed | null = null;
534f04aClaude1941 try {
1942 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1943 listQueuedAiBuildIssues(3),
1944 listRecentAutoMerges(3, 24),
1945 listRecentAiReviews(3, 24),
1946 countAiReviewsSince(24),
1947 listDemoActivityFeed(10),
1948 ]);
1949 liveFeed = {
1950 queued: queued.map((i) => ({
1951 repo: i.repo,
1952 number: i.number,
1953 title: i.title,
8ed88f2ccantynz-alt1954 createdAt: new Date(i.createdAt),
534f04aClaude1955 })),
1956 merges: merges.map((m) => ({
1957 repo: m.repo,
1958 number: m.number,
1959 title: m.title,
1960 mergedAt: m.mergedAt,
1961 })),
1962 reviews: reviewList.map((r) => ({
1963 repo: r.repo,
1964 prNumber: r.prNumber,
1965 commentSnippet: r.commentSnippet,
1966 createdAt: r.createdAt,
1967 })),
1968 reviewCount,
1969 feed: feed.map((e) => ({
1970 kind: e.kind,
1971 repo: e.repo,
1972 ref: e.ref,
1973 at: e.at,
1974 })),
1975 };
1976 } catch {
1977 liveFeed = null;
1978 }
1979
29924bcClaude1980 void LandingPage;
f8e5feaccanty labs1981 void Landing2030Page;
1982 return c.html(
1983 "<!DOCTYPE html>" +
1984 String(
1985 <LandingProPage
1986 stats={stats}
1987 publicStats={publicStats}
1988 liveFeed={liveFeed}
1989 />
1990 )
1991 );
fc1817aClaude1992});
1993
06d5ffeClaude1994// New repository form
1995web.get("/new", requireAuth, (c) => {
1996 const user = c.get("user")!;
1997 const error = c.req.query("error");
1998
1999 return c.html(
2000 <Layout title="New repository" user={user}>
efb11c5Claude2001 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2002 <div class="new-repo-hero">
2003 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
2004 <div class="new-repo-hero-orb" />
2005 </div>
2006 <div class="new-repo-hero-inner">
2007 <div class="new-repo-eyebrow">
2008 <strong>Create</strong> · {user.username}
2009 </div>
2010 <h1 class="new-repo-title">
2011 Spin up a <span class="gradient-text">repository</span>.
2012 </h1>
2013 <p class="new-repo-sub">
2014 Push your first commit, and Gluecron wires up gate checks, AI review,
2015 and auto-merge from the moment your branch lands.
2016 </p>
2017 </div>
2018 </div>
06d5ffeClaude2019 <div class="new-repo-form">
efb11c5Claude2020 {error && (
2021 <div class="new-repo-error" role="alert">
2022 {decodeURIComponent(error)}
2023 </div>
2024 )}
2025 <form method="post" action="/new" class="new-repo-form-grid">
2026 <div class="new-repo-row">
2027 <label class="new-repo-label">Owner</label>
2028 <input
2029 type="text"
2030 value={user.username}
2031 disabled
2032 aria-label="Owner"
2033 class="new-repo-input new-repo-input-disabled"
2034 />
06d5ffeClaude2035 </div>
efb11c5Claude2036 <div class="new-repo-row">
2037 <label class="new-repo-label" for="name">
2038 Repository name
2039 </label>
06d5ffeClaude2040 <input
2041 type="text"
2042 id="name"
2043 name="name"
2044 required
2045 pattern="^[a-zA-Z0-9._-]+$"
2046 placeholder="my-project"
2047 autocomplete="off"
efb11c5Claude2048 class="new-repo-input"
06d5ffeClaude2049 />
efb11c5Claude2050 <p class="new-repo-hint">
2051 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2052 <code>{user.username}/&lt;name&gt;</code>.
2053 </p>
06d5ffeClaude2054 </div>
efb11c5Claude2055 <div class="new-repo-row">
2056 <label class="new-repo-label" for="description">
2057 Description{" "}
2058 <span class="new-repo-label-optional">(optional)</span>
2059 </label>
06d5ffeClaude2060 <input
2061 type="text"
2062 id="description"
2063 name="description"
2064 placeholder="A short description of your repository"
efb11c5Claude2065 class="new-repo-input"
06d5ffeClaude2066 />
2067 </div>
efb11c5Claude2068 <div class="new-repo-row">
2069 <span class="new-repo-label">Visibility</span>
2070 <div class="new-repo-visibility">
2071 <label class="new-repo-vis-card">
2072 <input
2073 type="radio"
2074 name="visibility"
2075 value="public"
2076 checked
2077 class="new-repo-vis-radio"
2078 />
2079 <span class="new-repo-vis-body">
2080 <span class="new-repo-vis-label">Public</span>
2081 <span class="new-repo-vis-desc">
2082 Anyone can see this repository. You choose who can commit.
2083 </span>
2084 </span>
2085 </label>
2086 <label class="new-repo-vis-card">
2087 <input
2088 type="radio"
2089 name="visibility"
2090 value="private"
2091 class="new-repo-vis-radio"
2092 />
2093 <span class="new-repo-vis-body">
2094 <span class="new-repo-vis-label">Private</span>
2095 <span class="new-repo-vis-desc">
2096 Only you (and collaborators you invite) can see this
2097 repository.
2098 </span>
2099 </span>
2100 </label>
2101 </div>
2102 </div>
398a10cClaude2103 <div class="new-repo-row">
2104 <span class="new-repo-label">
2105 Starter content{" "}
2106 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2107 </span>
2108 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2109 <label class="new-repo-template-chip">
2110 <input type="radio" name="starter" value="empty" checked />
2111 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2112 Empty
2113 </label>
2114 <label class="new-repo-template-chip">
2115 <input type="radio" name="starter" value="readme" />
2116 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2117 README
2118 </label>
2119 <label class="new-repo-template-chip">
2120 <input type="radio" name="starter" value="readme-mit" />
2121 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2122 README + MIT
2123 </label>
2124 <label class="new-repo-template-chip">
2125 <input type="radio" name="starter" value="node" />
2126 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2127 Node + .gitignore
2128 </label>
2129 </div>
2130 <p class="new-repo-hint">
2131 Just a UI hint — push your own commits to fill the repo.
2132 </p>
2133 </div>
44f1a02Claude2134 <div class="new-repo-row">
2135 <label class="new-repo-label" for="data_region">
2136 Data region
2137 </label>
2138 <select
2139 id="data_region"
2140 name="data_region"
2141 class="new-repo-input"
2142 style="cursor: pointer;"
2143 >
2144 <option value="us" selected>US (default)</option>
2145 <option value="eu">EU (Frankfurt)</option>
2146 </select>
2147 <p class="new-repo-hint">
2148 EU data residency requires a{" "}
2149 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2150 Pro plan or higher
2151 </a>
2152 . Repositories cannot be moved between regions after creation.
2153 </p>
2154 </div>
efb11c5Claude2155 <div class="new-repo-callout">
2156 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2157 <p class="new-repo-callout-body">
2158 Every push is gate-checked and reviewed by Claude automatically.
2159 Label an issue <code>ai-build</code> and Gluecron will open the PR
2160 for you.
2161 </p>
2162 </div>
2163 <div class="new-repo-actions">
398a10cClaude2164 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2165 Create repository
2166 </button>
2167 <a href="/dashboard" class="btn new-repo-cancel">
2168 Cancel
2169 </a>
06d5ffeClaude2170 </div>
2171 </form>
2172 </div>
2173 </Layout>
2174 );
2175});
2176
2177web.post("/new", requireAuth, async (c) => {
2178 const user = c.get("user")!;
2179 const body = await c.req.parseBody();
2180 const name = String(body.name || "").trim();
2181 const description = String(body.description || "").trim();
2182 const isPrivate = body.visibility === "private";
44f1a02Claude2183 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2184
2185 if (!name) {
2186 return c.redirect("/new?error=Repository+name+is+required");
2187 }
2188
c63b860Claude2189 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2190 // outage never blocks repo creation.
2191 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2192 const gate = await checkRepoCreateAllowed(user.id);
2193 if (!gate.ok) {
2194 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2195 }
2196
06d5ffeClaude2197 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2198 return c.redirect("/new?error=Invalid+repository+name");
2199 }
2200
2201 if (await repoExists(user.username, name)) {
2202 return c.redirect("/new?error=Repository+already+exists");
2203 }
2204
2205 const diskPath = await initBareRepo(user.username, name);
2206
3ef4c9dClaude2207 const [newRepo] = await db
2208 .insert(repositories)
2209 .values({
2210 name,
2211 ownerId: user.id,
2212 description: description || null,
2213 isPrivate,
2214 diskPath,
44f1a02Claude2215 dataRegion,
3ef4c9dClaude2216 })
2217 .returning();
2218
2219 if (newRepo) {
2220 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2221 await bootstrapRepository({
2222 repositoryId: newRepo.id,
2223 ownerUserId: user.id,
2224 defaultBranch: "main",
2225 });
47b1dc7ccantynz-alt2226
2227 // bootstrapRepository sets up the DB side only — settings, protection,
2228 // labels, welcome issue — so the repository itself was still EMPTY. A new
2229 // user landed on an empty-state page telling them to go configure
2230 // something, and everything the platform does on push had nothing to act
2231 // on. Seed a README and a working CI workflow, then run the same workflow
2232 // discovery the post-receive hook runs, so the repo has a green CI run
2233 // before the user has done anything at all.
2234 //
2235 // Best-effort: a repo with no README is a disappointment, a failed repo
2236 // creation is a broken product. Never let this throw past here.
2237 try {
2238 const { scaffoldFirstRepo } = await import("../lib/first-repo-scaffold");
2239 await scaffoldFirstRepo({
2240 owner: user.username,
2241 repoName: name,
2242 repositoryId: newRepo.id,
2243 defaultBranch: "main",
2244 authorName: user.username,
2245 authorEmail: user.email || `${user.username}@users.noreply.gluecron.com`,
2246 userId: user.id,
2247 });
2248 } catch (err) {
2249 console.error("[new-repo] scaffold failed:", err);
2250 }
3ef4c9dClaude2251 }
06d5ffeClaude2252
2253 return c.redirect(`/${user.username}/${name}`);
2254});
2255
11c3ab6ccanty labs2256// Daily brief — GET /brief
2257web.get("/brief", (c) => {
2258 const user = c.get("user");
8ed88f2ccantynz-alt2259 return c.html(
2260 <DailyBrief
2261 user={user ? { name: user.displayName || user.username } : undefined}
2262 />
2263 );
11c3ab6ccanty labs2264});
2265
2266// Trust report — GET /trust (public)
2267web.get("/trust", (c) => {
2268 return c.html(<TrustReport />);
2269});
2270
2271// Production layers — GET /layers
2272web.get("/layers", (c) => {
2273 const user = c.get("user");
2274 return c.html(<ProductionLayers user={user} />);
2275});
2276
2277// Distribution — GET /distribute
2278web.get("/distribute", (c) => {
2279 const user = c.get("user");
2280 return c.html(<DistributionView user={user} />);
2281});
2282
06d5ffeClaude2283// User profile
fc1817aClaude2284web.get("/:owner", async (c) => {
06d5ffeClaude2285 const { owner: ownerName } = c.req.param();
2286 const user = c.get("user");
2287
2288 // Avoid clashing with fixed routes
2289 if (
2290 ["login", "register", "logout", "new", "settings", "api"].includes(
2291 ownerName
2292 )
2293 ) {
2294 return c.notFound();
2295 }
2296
2297 let ownerUser;
2298 try {
2299 const [found] = await db
2300 .select()
2301 .from(users)
2302 .where(eq(users.username, ownerName))
2303 .limit(1);
2304 ownerUser = found;
2305 } catch {
2306 // DB not available — check if repos exist on disk
2307 ownerUser = null;
2308 }
2309
2310 // Even without DB, show repos if they exist on disk
2311 let repos: any[] = [];
2312 if (ownerUser) {
2313 const allRepos = await db
2314 .select()
2315 .from(repositories)
2316 .where(eq(repositories.ownerId, ownerUser.id))
2317 .orderBy(desc(repositories.updatedAt));
2318
2319 // Show public repos to everyone, private only to owner
2320 repos =
2321 user?.id === ownerUser.id
2322 ? allRepos
2323 : allRepos.filter((r) => !r.isPrivate);
2324 }
2325
7aa8b99Claude2326 // Block J4 — follow counts + viewer's follow state
2327 let followState = {
2328 followers: 0,
2329 following: 0,
2330 viewerFollows: false,
2331 };
2332 if (ownerUser) {
2333 try {
2334 const { followCounts, isFollowing } = await import("../lib/follows");
2335 const counts = await followCounts(ownerUser.id);
2336 followState.followers = counts.followers;
2337 followState.following = counts.following;
2338 if (user && user.id !== ownerUser.id) {
2339 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2340 }
2341 } catch {
2342 // DB hiccup — fall back to zeros.
2343 }
2344 }
2345 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2346
d412586Claude2347 // Block J5 — profile README. Render owner/owner repo's README on the
2348 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2349 // back to "<user>/.github" for org-style profile repos.
2350 let profileReadmeHtml: string | null = null;
2351 try {
2352 const candidates = [ownerName, ".github"];
2353 for (const rname of candidates) {
2354 if (await repoExists(ownerName, rname)) {
2355 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2356 const md = await getReadme(ownerName, rname, ref);
2357 if (md) {
2358 profileReadmeHtml = renderMarkdown(md);
2359 break;
2360 }
2361 }
2362 }
2363 } catch {
2364 profileReadmeHtml = null;
2365 }
2366
efb11c5Claude2367 const displayName = ownerUser?.displayName || ownerName;
2368 const memberSince = ownerUser?.createdAt
2369 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2370 : null;
2371
fc1817aClaude2372 return c.html(
06d5ffeClaude2373 <Layout title={ownerName} user={user}>
efb11c5Claude2374 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2375 <div class="profile-hero">
2376 <div class="profile-hero-orb-wrap" aria-hidden="true">
2377 <div class="profile-hero-orb" />
06d5ffeClaude2378 </div>
efb11c5Claude2379 <div class="profile-hero-inner">
2380 <div class="profile-hero-avatar" aria-hidden="true">
2381 {displayName[0].toUpperCase()}
2382 </div>
2383 <div class="profile-hero-text">
2384 <div class="profile-eyebrow">
2385 <strong>Developer</strong>
2386 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2387 <>
2388 {" "}· Joined{" "}
2389 {memberSince.toLocaleDateString("en-US", {
2390 month: "short",
2391 year: "numeric",
2392 })}
2393 </>
2394 )}
2395 </div>
2396 <h1 class="profile-name">
2397 <span class="gradient-text">{displayName}</span>
2398 </h1>
2399 <div class="profile-handle">@{ownerName}</div>
2400 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2401 <div class="profile-meta">
2402 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2403 <strong>{followState.followers}</strong> follower
2404 {followState.followers === 1 ? "" : "s"}
2405 </a>
2406 <a href={`/${ownerName}/following`} class="profile-meta-link">
2407 <strong>{followState.following}</strong> following
2408 </a>
2409 <a href={`/${ownerName}`} class="profile-meta-link">
2410 <strong>{repos.length}</strong> repo
2411 {repos.length === 1 ? "" : "s"}
2412 </a>
2413 {canFollow && (
2414 <form
2415 method="post"
2416 action={`/${ownerName}/${
2417 followState.viewerFollows ? "unfollow" : "follow"
2418 }`}
2419 class="profile-follow-form"
7aa8b99Claude2420 >
efb11c5Claude2421 <button
2422 type="submit"
2423 class={`btn ${
2424 followState.viewerFollows ? "" : "btn-primary"
2425 } btn-sm`}
2426 >
2427 {followState.viewerFollows ? "Unfollow" : "Follow"}
2428 </button>
2429 </form>
2430 )}
2431 </div>
7aa8b99Claude2432 </div>
06d5ffeClaude2433 </div>
2434 </div>
d412586Claude2435 {profileReadmeHtml && (
efb11c5Claude2436 <div class="profile-readme">
2437 <div class="profile-readme-head">
2438 <span class="profile-readme-icon">{"☰"}</span>
2439 <span>{ownerName}/{ownerName} README.md</span>
2440 </div>
2441 <div
2442 class="markdown-body profile-readme-body"
2443 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2444 />
2445 </div>
d412586Claude2446 )}
efb11c5Claude2447 <div class="profile-section-head">
2448 <h2 class="profile-section-title">Repositories</h2>
2449 <span class="profile-section-count">{repos.length}</span>
2450 </div>
06d5ffeClaude2451 {repos.length === 0 ? (
efb11c5Claude2452 <div class="profile-empty">
2453 <p class="profile-empty-text">
2454 No repositories yet
2455 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2456 </p>
2457 {user?.id === ownerUser?.id && (
2458 <a href="/new" class="btn btn-primary btn-sm">
2459 + Create your first
2460 </a>
2461 )}
2462 </div>
06d5ffeClaude2463 ) : (
2464 <div class="card-grid">
2465 {repos.map((repo) => (
2466 <RepoCard repo={repo} ownerName={ownerName} />
2467 ))}
2468 </div>
2469 )}
fc1817aClaude2470 </Layout>
2471 );
2472});
2473
06d5ffeClaude2474// Star/unstar a repo
2475web.post("/:owner/:repo/star", requireAuth, async (c) => {
2476 const { owner: ownerName, repo: repoName } = c.req.param();
2477 const user = c.get("user")!;
2478
2479 try {
2480 const [ownerUser] = await db
2481 .select()
2482 .from(users)
2483 .where(eq(users.username, ownerName))
2484 .limit(1);
2485 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2486
2487 const [repo] = await db
2488 .select()
2489 .from(repositories)
2490 .where(
2491 and(
2492 eq(repositories.ownerId, ownerUser.id),
2493 eq(repositories.name, repoName)
2494 )
2495 )
2496 .limit(1);
2497 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2498
2499 // Toggle star
2500 const [existing] = await db
2501 .select()
2502 .from(stars)
2503 .where(
2504 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2505 )
2506 .limit(1);
2507
2508 if (existing) {
2509 await db.delete(stars).where(eq(stars.id, existing.id));
2510 await db
2511 .update(repositories)
2512 .set({ starCount: Math.max(0, repo.starCount - 1) })
2513 .where(eq(repositories.id, repo.id));
2514 } else {
2515 await db.insert(stars).values({
2516 userId: user.id,
2517 repositoryId: repo.id,
2518 });
2519 await db
2520 .update(repositories)
2521 .set({ starCount: repo.starCount + 1 })
2522 .where(eq(repositories.id, repo.id));
a74f4edccanty labs2523 void fireWebhooks(repo.id, "star", { action: "created" });
06d5ffeClaude2524 }
2525 } catch {
2526 // DB error — ignore
2527 }
2528
2529 return c.redirect(`/${ownerName}/${repoName}`);
2530});
2531
641aa42Claude2532// ---------------------------------------------------------------------------
2533// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2534// ---------------------------------------------------------------------------
2535const obCss = `
2536 .ob-card {
2537 position: relative;
2538 margin: 14px 0 18px;
6fd5915Claude2539 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2540 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2541 border-radius: 14px;
2542 overflow: hidden;
2543 }
2544 .ob-card::before {
2545 content: '';
2546 position: absolute;
2547 top: 0; left: 0; right: 0;
2548 height: 2px;
6fd5915Claude2549 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2550 pointer-events: none;
2551 }
2552 .ob-header {
2553 padding: 16px 20px 10px;
2554 border-bottom: 1px solid var(--border);
2555 }
2556 .ob-header h3 {
2557 font-size: 16px;
2558 font-weight: 700;
2559 margin: 0 0 4px;
2560 color: var(--text-strong);
2561 }
2562 .ob-header p {
2563 font-size: 13px;
2564 color: var(--text-muted);
2565 margin: 0;
2566 }
2567 .ob-sections {
2568 display: grid;
2569 grid-template-columns: repeat(3, 1fr);
2570 gap: 0;
2571 }
2572 @media (max-width: 760px) {
2573 .ob-sections { grid-template-columns: 1fr; }
2574 }
2575 .ob-section {
2576 padding: 14px 20px;
2577 border-right: 1px solid var(--border);
2578 }
2579 .ob-section:last-child { border-right: none; }
2580 .ob-section h4 {
2581 font-size: 12px;
2582 font-weight: 700;
2583 text-transform: uppercase;
2584 letter-spacing: 0.08em;
2585 color: var(--accent);
2586 margin: 0 0 8px;
2587 }
2588 .ob-preview {
2589 font-family: var(--font-mono);
2590 font-size: 11.5px;
2591 background: var(--bg);
2592 border: 1px solid var(--border);
2593 border-radius: 6px;
2594 padding: 8px 10px;
2595 color: var(--text-muted);
2596 line-height: 1.55;
2597 margin-bottom: 8px;
2598 white-space: pre-wrap;
2599 overflow: hidden;
2600 max-height: 80px;
2601 position: relative;
2602 }
2603 .ob-preview::after {
2604 content: '';
2605 position: absolute;
2606 bottom: 0; left: 0; right: 0;
2607 height: 24px;
2608 background: linear-gradient(transparent, var(--bg));
2609 pointer-events: none;
2610 }
2611 .ob-labels {
2612 display: flex;
2613 flex-wrap: wrap;
2614 gap: 5px;
2615 margin-bottom: 8px;
2616 }
2617 .ob-label-chip {
2618 display: inline-flex;
2619 align-items: center;
2620 padding: 2px 8px;
2621 border-radius: 9999px;
2622 font-size: 11.5px;
2623 font-weight: 600;
2624 line-height: 1.5;
2625 border: 1px solid transparent;
2626 }
2627 .ob-section ul {
2628 margin: 0;
2629 padding: 0 0 0 16px;
2630 font-size: 13px;
2631 color: var(--text-muted);
2632 line-height: 1.7;
2633 }
2634 .ob-section ul li { margin-bottom: 2px; }
2635 .ob-footer {
2636 display: flex;
2637 align-items: center;
2638 justify-content: flex-end;
2639 padding: 10px 20px;
2640 border-top: 1px solid var(--border);
2641 gap: 10px;
2642 }
2643 .ob-dismiss {
2644 appearance: none;
2645 background: transparent;
2646 border: 1px solid var(--border);
2647 color: var(--text-muted);
2648 border-radius: 6px;
2649 padding: 5px 12px;
2650 font-size: 12.5px;
2651 font-family: inherit;
2652 cursor: pointer;
2653 transition: background var(--t-fast), color var(--t-fast);
2654 }
2655 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2656 .btn-sm {
2657 appearance: none;
2658 background: var(--bg-elevated);
2659 border: 1px solid var(--border);
2660 color: var(--text-strong);
2661 border-radius: 6px;
2662 padding: 5px 12px;
2663 font-size: 12.5px;
2664 font-weight: 600;
2665 font-family: inherit;
2666 cursor: pointer;
2667 text-decoration: none;
2668 display: inline-flex;
2669 align-items: center;
2670 transition: background var(--t-fast);
2671 }
2672 .btn-sm:hover { background: var(--bg-hover); }
2673 .btn-sm.btn-primary {
2674 background: var(--accent);
2675 color: #fff;
2676 border-color: var(--accent);
2677 }
2678 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2679`;
2680
2681// Onboarding card component — shown to repo owner until dismissed
2682function RepoOnboardingCard({
2683 owner,
2684 repo,
2685 data,
2686}: {
2687 owner: string;
2688 repo: string;
2689 data: typeof repoOnboardingData.$inferSelect;
2690}) {
2691 const labels = (data.suggestedLabels ?? []) as Array<{
2692 name: string;
2693 color: string;
2694 description: string;
2695 }>;
2696 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2697 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2698
2699 return (
2700 <>
2701 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2702 <div class="ob-card" id="repo-onboarding">
2703 <div class="ob-header">
2704 <h3>Get started with {owner}/{repo}</h3>
2705 <p>
2706 Detected: {data.detectedLanguage ?? "Unknown"}
2707 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2708 Here&rsquo;s what we suggest to hit the ground running.
2709 </p>
2710 </div>
2711 <div class="ob-sections">
2712 <div class="ob-section">
2713 <h4>Suggested README</h4>
2714 <div class="ob-preview">{readmePreview}</div>
2715 <button
2716 class="btn-sm"
2717 type="button"
2718 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(_){};})()`}
2719 aria-label="Copy suggested README to clipboard"
2720 >
2721 Copy to clipboard
2722 </button>
2723 </div>
2724 <div class="ob-section">
2725 <h4>Suggested labels ({labels.length})</h4>
2726 <div class="ob-labels">
2727 {labels.slice(0, 6).map((l) => (
2728 <span
2729 class="ob-label-chip"
2730 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2731 title={l.description}
2732 >
2733 {l.name}
2734 </span>
2735 ))}
2736 </div>
2737 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2738 <button class="btn-sm btn-primary" type="submit">
2739 Create all labels
2740 </button>
2741 </form>
2742 </div>
2743 <div class="ob-section">
2744 <h4>First steps</h4>
2745 <ul>
2746 {suggestions.map((s) => (
2747 <li>{s}</li>
2748 ))}
2749 </ul>
2750 </div>
2751 </div>
2752 <div class="ob-footer">
2753 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2754 <button class="ob-dismiss" type="submit">
2755 Dismiss
2756 </button>
2757 </form>
2758 </div>
2759 <script
2760 dangerouslySetInnerHTML={{
2761 __html: `
2762 (function(){
2763 var card = document.getElementById('repo-onboarding');
2764 if (!card) return;
2765 document.addEventListener('keydown', function(e){
2766 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2767 card.style.display = 'none';
2768 }
2769 });
2770 })();
2771 `,
2772 }}
2773 />
2774 </div>
2775 </>
2776 );
2777}
2778
2779// ---------------------------------------------------------------------------
2780// Setup routes — create suggested labels + dismiss onboarding
2781// ---------------------------------------------------------------------------
2782
2783// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2784// requireAuth + write access. Idempotent (label UPSERT by name).
2785web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2786 const { owner, repo } = c.req.param();
2787 const user = c.get("user")!;
2788
2789 // Resolve repo + verify write access
2790 let repoRow: { id: string; ownerId: string } | null = null;
2791 try {
2792 const [ownerUser] = await db
2793 .select({ id: users.id })
2794 .from(users)
2795 .where(eq(users.username, owner))
2796 .limit(1);
2797 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2798 const [r] = await db
2799 .select({ id: repositories.id, ownerId: repositories.ownerId })
2800 .from(repositories)
2801 .where(
2802 and(
2803 eq(repositories.ownerId, ownerUser.id),
2804 eq(repositories.name, repo)
2805 )
2806 )
2807 .limit(1);
2808 repoRow = r ?? null;
2809 } catch {
2810 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2811 }
2812 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2813 if (repoRow.ownerId !== user.id) {
2814 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2815 }
2816
2817 // Fetch the onboarding data
2818 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2819 try {
2820 const [r] = await db
2821 .select()
2822 .from(repoOnboardingData)
2823 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2824 .limit(1);
2825 obRow = r ?? null;
2826 } catch {
2827 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2828 }
2829 if (!obRow) {
2830 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2831 }
2832
2833 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2834 name: string;
2835 color: string;
2836 description: string;
2837 }>;
2838
2839 // Create labels — import the labels table which was already imported
2840 let created = 0;
2841 for (const l of suggestedLabels) {
2842 try {
2843 await db
2844 .insert(labels)
2845 .values({
2846 repositoryId: repoRow.id,
2847 name: l.name,
2848 color: l.color,
2849 description: l.description ?? "",
2850 })
2851 .onConflictDoNothing();
2852 created++;
2853 } catch {
2854 /* skip duplicates */
2855 }
2856 }
2857
2858 // Mark onboarding dismissed
2859 try {
2860 await db
2861 .update(repositories)
2862 .set({ onboardingShown: true })
2863 .where(eq(repositories.id, repoRow.id));
2864 } catch {
2865 /* ignore */
2866 }
2867
2868 return c.redirect(
2869 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2870 );
2871});
2872
2873// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2874web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2875 const { owner, repo } = c.req.param();
2876 const user = c.get("user")!;
2877
2878 try {
2879 const [ownerUser] = await db
2880 .select({ id: users.id })
2881 .from(users)
2882 .where(eq(users.username, owner))
2883 .limit(1);
2884 if (ownerUser) {
2885 const [r] = await db
2886 .select({ id: repositories.id, ownerId: repositories.ownerId })
2887 .from(repositories)
2888 .where(
2889 and(
2890 eq(repositories.ownerId, ownerUser.id),
2891 eq(repositories.name, repo)
2892 )
2893 )
2894 .limit(1);
2895 if (r && r.ownerId === user.id) {
2896 await db
2897 .update(repositories)
2898 .set({ onboardingShown: true })
2899 .where(eq(repositories.id, r.id));
2900 }
2901 }
2902 } catch {
2903 /* swallow — dismiss should never fail visibly */
2904 }
2905
2906 return c.redirect(`/${owner}/${repo}`);
2907});
2908
11c3ab6ccanty labs2909// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2910web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2911 const { owner, repo } = c.req.param();
2912 const user = c.get("user");
5bb52faccanty labs2913 const gate = await assertRepoReadable(c, owner, repo);
2914 if (gate) return gate;
11c3ab6ccanty labs2915 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2916});
2917
2918// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2919web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2920 const { owner, repo } = c.req.param();
2921 const user = c.get("user");
5bb52faccanty labs2922 const gate = await assertRepoReadable(c, owner, repo);
2923 if (gate) return gate;
11c3ab6ccanty labs2924 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2925});
2926
fc1817aClaude2927// Repository overview — file tree at HEAD
2928web.get("/:owner/:repo", async (c) => {
2929 const { owner, repo } = c.req.param();
06d5ffeClaude2930 const user = c.get("user");
fc1817aClaude2931
5bb52faccanty labs2932 // SECURITY: gate private-repo content before any render (incl. skeleton).
2933 const gate = await assertRepoReadable(c, owner, repo);
2934 if (gate) return gate;
2935
f1dc7c7Claude2936 // ── Loading skeleton (flag-gated) ──
2937 // Renders an SSR'd shell with file-tree + README placeholders when
2938 // `?skeleton=1` is set. Lets the user see the page structure before
2939 // git ops finish. Behind a flag for now so we never flash before the
2940 // real content lands.
2941 if (c.req.query("skeleton") === "1") {
2942 return c.html(
2943 <Layout title={`${owner}/${repo}`} user={user}>
2944 <style
2945 dangerouslySetInnerHTML={{
2946 __html: `
2947 .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; }
2948 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2949 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2950 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2951 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2952 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2953 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2954 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2955 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2956 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2957 .repo-skel-readme { height: 320px; border-radius: 12px; }
2958 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2959 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2960 `,
2961 }}
2962 />
2963 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2964 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2965 <div class="repo-skel-grid" aria-hidden="true">
2966 <div>
2967 <div class="repo-skel repo-skel-branch" />
2968 <div class="repo-skel-tree">
2969 {Array.from({ length: 8 }).map(() => (
2970 <div class="repo-skel repo-skel-tree-row" />
2971 ))}
2972 </div>
2973 <div class="repo-skel repo-skel-readme" />
2974 </div>
2975 <aside class="repo-skel-side">
2976 <div class="repo-skel repo-skel-side-card" />
2977 <div class="repo-skel repo-skel-side-card" />
2978 </aside>
2979 </div>
2980 <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">
2981 Loading {owner}/{repo}…
2982 </span>
2983 </Layout>
2984 );
2985 }
2986
8f50ed0Claude2987 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2988 trackByName(owner, repo, "view", {
2989 userId: user?.id || null,
2990 path: `/${owner}/${repo}`,
2991 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2992 userAgent: c.req.header("user-agent") || null,
2993 referer: c.req.header("referer") || null,
a28cedeClaude2994 }).catch((err) => {
2995 console.warn(
2996 `[web] view tracking failed for ${owner}/${repo}:`,
2997 err instanceof Error ? err.message : err
2998 );
2999 });
8f50ed0Claude3000
fc1817aClaude3001 if (!(await repoExists(owner, repo))) {
3002 return c.html(
06d5ffeClaude3003 <Layout title="Not Found" user={user}>
fc1817aClaude3004 <div class="empty-state">
3005 <h2>Repository not found</h2>
3006 <p>
3007 {owner}/{repo} does not exist.
3008 </p>
3009 </div>
3010 </Layout>,
3011 404
3012 );
3013 }
3014
05b973eClaude3015 // Parallelize all independent operations
3016 const [defaultBranch, branches] = await Promise.all([
3017 getDefaultBranch(owner, repo).then((b) => b || "main"),
3018 listBranches(owner, repo),
3019 ]);
3020 const [tree, starInfo] = await Promise.all([
3021 getTree(owner, repo, defaultBranch),
3022 // Star info fetched in parallel with tree
3023 (async () => {
3024 try {
28b52beccantynz-alt3025 // assertRepoReadable already resolved (and access-checked) both rows
3026 // for this request — reuse them instead of repeating the two lookups.
3027 const resolved = c.get("resolvedRepoCtx") as
3028 | { repoRow: typeof repositories.$inferSelect }
3029 | undefined;
3030 const repoRow = resolved?.repoRow;
71cd5ecClaude3031 if (!repoRow)
3032 return {
3033 starCount: 0,
3034 starred: false,
3035 archived: false,
3036 isTemplate: false,
544d842Claude3037 forkCount: 0,
3038 description: null as string | null,
3039 pushedAt: null as Date | null,
3040 createdAt: null as Date | null,
cb5a796Claude3041 repoId: null as string | null,
3042 repoOwnerId: null as string | null,
71cd5ecClaude3043 };
05b973eClaude3044 let starred = false;
06d5ffeClaude3045 if (user) {
3046 const [star] = await db
3047 .select()
3048 .from(stars)
3049 .where(
3050 and(
3051 eq(stars.userId, user.id),
3052 eq(stars.repositoryId, repoRow.id)
3053 )
3054 )
3055 .limit(1);
3056 starred = !!star;
3057 }
71cd5ecClaude3058 return {
3059 starCount: repoRow.starCount,
3060 starred,
3061 archived: repoRow.isArchived,
3062 isTemplate: repoRow.isTemplate,
544d842Claude3063 forkCount: repoRow.forkCount,
3064 description: repoRow.description as string | null,
3065 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3066 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3067 repoId: repoRow.id as string,
3068 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3069 };
05b973eClaude3070 } catch {
71cd5ecClaude3071 return {
3072 starCount: 0,
3073 starred: false,
3074 archived: false,
3075 isTemplate: false,
544d842Claude3076 forkCount: 0,
3077 description: null as string | null,
3078 pushedAt: null as Date | null,
3079 createdAt: null as Date | null,
cb5a796Claude3080 repoId: null as string | null,
3081 repoOwnerId: null as string | null,
71cd5ecClaude3082 };
06d5ffeClaude3083 }
05b973eClaude3084 })(),
3085 ]);
544d842Claude3086 const {
3087 starCount,
3088 starred,
3089 archived,
3090 isTemplate,
3091 forkCount,
3092 description,
3093 pushedAt,
3094 createdAt,
cb5a796Claude3095 repoId,
3096 repoOwnerId,
544d842Claude3097 } = starInfo;
3098
91a0204Claude3099 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3100 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3101 // and the badge simply doesn't render.
3102 let healthScore: HealthScore | null = null;
3103 if (repoId) {
3104 try {
3105 healthScore = await computeHealthScore(repoId);
3106 } catch {
3107 // swallow — badge is optional
3108 }
3109 }
3110
cb5a796Claude3111 // Pending-comments banner data (lazy + best-effort). Only the repo
3112 // owner sees the banner, so non-owner views skip the DB hit entirely.
3113 let repoHomePendingCount = 0;
3114 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3115 try {
3116 const { countPendingForRepo } = await import(
3117 "../lib/comment-moderation"
3118 );
3119 repoHomePendingCount = await countPendingForRepo(repoId);
3120 } catch {
3121 /* swallow */
3122 }
3123 }
3124
8c790e0Claude3125 // Push Watch discoverability — fetch the most recent push within 24 h.
3126 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3127 const recentPush: RecentPush | null = repoId
3128 ? await getRecentPush(repoId)
3129 : null;
3130
ebaae0fClaude3131 // AI stats strip — best-effort, fail-open to zero values.
3132 let aiStats: RepoAiStats = {
3133 mergedCount: 0,
3134 reviewCount: 0,
3135 securityAlertCount: 0,
3136 hoursSaved: "0.0",
3137 };
3138 if (repoId) {
3139 try {
3140 aiStats = await getRepoAiStats(repoId);
3141 } catch {
3142 /* swallow — show zero values */
3143 }
3144 }
3145
641aa42Claude3146 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3147 // if the DB is down the card simply won't appear. Only the owner sees it.
3148 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3149 let showOnboarding = false;
8102dd4ccantynz-alt3150 // The card is an EMPTY-REPO nudge ("add a README, add a .gitignore"). It must
3151 // only appear while the repo actually is empty — otherwise a populated repo
3152 // (files + branches pushed) keeps rendering "Get started" above the fold
3153 // forever, because `onboarding_shown` only flips on an explicit dismiss.
3154 const repoLooksEmpty = tree.length === 0 && branches.length === 0;
3155 if (repoLooksEmpty && repoId && user && repoOwnerId && user.id === repoOwnerId) {
641aa42Claude3156 try {
28b52beccantynz-alt3157 // onboarding_shown comes from the row assertRepoReadable already
3158 // resolved — no need for a third lookup of the same repository.
3159 const resolvedForOnboarding = c.get("resolvedRepoCtx") as
3160 | { repoRow: typeof repositories.$inferSelect }
3161 | undefined;
3162 const repoRow2 = resolvedForOnboarding?.repoRow;
641aa42Claude3163 if (repoRow2 && !repoRow2.onboardingShown) {
3164 const [obRow] = await db
3165 .select()
3166 .from(repoOnboardingData)
3167 .where(eq(repoOnboardingData.repositoryId, repoId))
3168 .limit(1);
3169 onboardingRow = obRow ?? null;
3170 showOnboarding = !!onboardingRow;
3171 }
3172 } catch {
3173 /* swallow — onboarding is optional */
3174 }
3175 }
3176
544d842Claude3177 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3178 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3179 const repoHomeCss = `
3180 .repo-home-hero {
3181 position: relative;
3182 margin-bottom: var(--space-5);
3183 padding: var(--space-5) var(--space-6);
3184 background: var(--bg-elevated);
3185 border: 1px solid var(--border);
3186 border-radius: 16px;
3187 overflow: hidden;
3188 }
3189 .repo-home-hero::before {
3190 content: '';
3191 position: absolute;
3192 top: 0; left: 0; right: 0;
3193 height: 2px;
6fd5915Claude3194 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3195 opacity: 0.7;
3196 pointer-events: none;
3197 }
3198 .repo-home-hero-orb-wrap {
3199 position: absolute;
3200 inset: -25% -10% auto auto;
3201 width: 360px;
3202 height: 360px;
3203 pointer-events: none;
3204 z-index: 0;
3205 }
3206 .repo-home-hero-orb {
3207 position: absolute;
3208 inset: 0;
6fd5915Claude3209 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3210 filter: blur(80px);
3211 opacity: 0.7;
3212 animation: repoHomeOrb 14s ease-in-out infinite;
3213 }
3214 @keyframes repoHomeOrb {
3215 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3216 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3217 }
3218 @media (prefers-reduced-motion: reduce) {
3219 .repo-home-hero-orb { animation: none; }
3220 }
3221 .repo-home-hero-inner {
3222 position: relative;
3223 z-index: 1;
3224 }
3225 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3226 .repo-home-eyebrow {
3227 font-size: 12px;
3228 font-family: var(--font-mono);
3229 color: var(--text-muted);
3230 letter-spacing: 0.1em;
3231 text-transform: uppercase;
3232 margin-bottom: var(--space-2);
3233 }
3234 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3235 .repo-home-description {
3236 font-size: 15px;
3237 line-height: 1.55;
3238 color: var(--text);
3239 margin: 0;
3240 max-width: 720px;
3241 }
3242 .repo-home-description-empty {
3243 font-size: 14px;
3244 color: var(--text-muted);
3245 font-style: italic;
3246 margin: 0;
3247 }
3248 .repo-home-stat-row {
3249 display: flex;
3250 flex-wrap: wrap;
3251 gap: var(--space-4);
3252 margin-top: var(--space-3);
3253 font-size: 13px;
3254 color: var(--text-muted);
3255 }
3256 .repo-home-stat {
3257 display: inline-flex;
3258 align-items: center;
3259 gap: 6px;
3260 }
3261 .repo-home-stat strong {
3262 color: var(--text-strong);
3263 font-weight: 600;
3264 font-variant-numeric: tabular-nums;
3265 }
3266 .repo-home-stat .repo-home-stat-icon {
3267 color: var(--text-faint);
3268 font-size: 14px;
3269 line-height: 1;
3270 }
3271 .repo-home-stat a {
3272 color: var(--text-muted);
3273 transition: color var(--t-fast) var(--ease);
3274 }
3275 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3276
3277 /* Two-column layout: file tree + sidebar */
3278 .repo-home-grid {
3279 display: grid;
3280 grid-template-columns: minmax(0, 1fr) 280px;
3281 gap: var(--space-5);
3282 align-items: start;
3283 }
3284 @media (max-width: 960px) {
3285 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3286 }
3287 .repo-home-main { min-width: 0; }
3288
3289 /* Sidebar card */
3290 .repo-home-side {
3291 display: flex;
3292 flex-direction: column;
3293 gap: var(--space-4);
3294 }
3295 .repo-home-side-card {
3296 background: var(--bg-elevated);
3297 border: 1px solid var(--border);
3298 border-radius: 12px;
3299 padding: var(--space-4);
3300 }
3301 .repo-home-side-title {
3302 font-size: 11px;
3303 font-family: var(--font-mono);
3304 letter-spacing: 0.12em;
3305 text-transform: uppercase;
3306 color: var(--text-muted);
3307 margin: 0 0 var(--space-3);
3308 font-weight: 600;
3309 }
3310 .repo-home-side-row {
3311 display: flex;
3312 justify-content: space-between;
3313 align-items: center;
3314 gap: var(--space-2);
3315 font-size: 13px;
3316 padding: 6px 0;
3317 border-top: 1px solid var(--border);
3318 }
3319 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3320 .repo-home-side-key {
3321 color: var(--text-muted);
3322 display: inline-flex;
3323 align-items: center;
3324 gap: 6px;
3325 }
3326 .repo-home-side-val {
3327 color: var(--text-strong);
3328 font-weight: 500;
3329 font-variant-numeric: tabular-nums;
3330 max-width: 60%;
3331 text-align: right;
3332 overflow: hidden;
3333 text-overflow: ellipsis;
3334 white-space: nowrap;
3335 }
3336 .repo-home-side-val a { color: var(--text-strong); }
3337 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3338
3339 /* Clone / Code tabs */
3340 .repo-home-clone {
3341 background: var(--bg-elevated);
3342 border: 1px solid var(--border);
3343 border-radius: 12px;
3344 overflow: hidden;
3345 }
3346 .repo-home-clone-tabs {
3347 display: flex;
3348 gap: 0;
3349 background: var(--bg-secondary);
3350 border-bottom: 1px solid var(--border);
3351 padding: 0 var(--space-2);
3352 }
3353 .repo-home-clone-tab {
3354 appearance: none;
3355 background: transparent;
3356 border: 0;
3357 border-bottom: 2px solid transparent;
3358 padding: 9px 12px;
3359 font-size: 12px;
3360 font-weight: 500;
3361 color: var(--text-muted);
3362 cursor: pointer;
3363 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3364 font-family: inherit;
3365 margin-bottom: -1px;
3366 }
3367 .repo-home-clone-tab:hover { color: var(--text-strong); }
3368 .repo-home-clone-tab[aria-selected="true"] {
3369 color: var(--text-strong);
3370 border-bottom-color: var(--accent);
3371 }
3372 .repo-home-clone-body {
3373 padding: var(--space-3);
3374 display: flex;
3375 align-items: center;
3376 gap: var(--space-2);
3377 }
3378 .repo-home-clone-input {
3379 flex: 1;
3380 min-width: 0;
3381 font-family: var(--font-mono);
3382 font-size: 12px;
3383 background: var(--bg);
3384 border: 1px solid var(--border);
3385 border-radius: 8px;
3386 padding: 8px 10px;
3387 color: var(--text-strong);
3388 overflow: hidden;
3389 text-overflow: ellipsis;
3390 white-space: nowrap;
3391 }
3392 .repo-home-clone-copy {
3393 appearance: none;
3394 background: var(--bg-secondary);
3395 border: 1px solid var(--border);
3396 color: var(--text-strong);
3397 border-radius: 8px;
3398 padding: 8px 12px;
3399 font-size: 12px;
3400 font-weight: 600;
3401 cursor: pointer;
3402 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3403 font-family: inherit;
3404 }
3405 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3406 .repo-home-clone-pane { display: none; }
3407 .repo-home-clone-pane[data-active="true"] { display: flex; }
3408
3409 /* README card */
3410 .repo-home-readme {
3411 margin-top: var(--space-5);
3412 background: var(--bg-elevated);
3413 border: 1px solid var(--border);
3414 border-radius: 12px;
3415 overflow: hidden;
3416 }
3417 .repo-home-readme-head {
3418 display: flex;
3419 align-items: center;
3420 gap: 8px;
3421 padding: 10px 16px;
3422 background: var(--bg-secondary);
3423 border-bottom: 1px solid var(--border);
3424 font-size: 13px;
3425 color: var(--text-muted);
3426 }
3427 .repo-home-readme-head .repo-home-readme-icon {
3428 color: var(--accent);
3429 font-size: 14px;
3430 }
3431 .repo-home-readme-body {
3432 padding: var(--space-5) var(--space-6);
3433 }
3434
3435 /* Empty-state CTA */
3436 .repo-home-empty {
3437 position: relative;
3438 margin-top: var(--space-4);
3439 background: var(--bg-elevated);
3440 border: 1px solid var(--border);
3441 border-radius: 16px;
3442 padding: var(--space-6);
3443 overflow: hidden;
3444 }
3445 .repo-home-empty::before {
3446 content: '';
3447 position: absolute;
3448 top: 0; left: 0; right: 0;
3449 height: 2px;
6fd5915Claude3450 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3451 opacity: 0.7;
3452 pointer-events: none;
3453 }
3454 .repo-home-empty-eyebrow {
3455 font-size: 12px;
3456 font-family: var(--font-mono);
3457 color: var(--accent);
3458 letter-spacing: 0.12em;
3459 text-transform: uppercase;
3460 margin-bottom: var(--space-2);
3461 font-weight: 600;
3462 }
3463 .repo-home-empty-title {
3464 font-family: var(--font-display);
3465 font-weight: 800;
3466 letter-spacing: -0.025em;
3467 font-size: clamp(22px, 3vw, 30px);
3468 line-height: 1.1;
3469 margin: 0 0 var(--space-2);
3470 color: var(--text-strong);
3471 }
3472 .repo-home-empty-sub {
3473 color: var(--text-muted);
3474 font-size: 14px;
3475 line-height: 1.55;
3476 max-width: 640px;
3477 margin: 0 0 var(--space-4);
3478 }
3479 .repo-home-empty-snippet {
3480 background: var(--bg);
3481 border: 1px solid var(--border);
3482 border-radius: 10px;
3483 padding: var(--space-3) var(--space-4);
3484 font-family: var(--font-mono);
3485 font-size: 12.5px;
3486 line-height: 1.7;
3487 color: var(--text-strong);
3488 overflow-x: auto;
3489 white-space: pre;
3490 margin: 0;
3491 }
3492 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3493 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3494
91a0204Claude3495 /* Health score badge */
3496 .repo-health-badge {
3497 display: inline-flex;
3498 align-items: center;
3499 gap: 5px;
3500 padding: 3px 10px;
3501 border-radius: 999px;
3502 font-size: 11.5px;
3503 font-weight: 700;
3504 font-family: var(--font-mono);
3505 text-decoration: none;
3506 border: 1px solid transparent;
3507 transition: filter 120ms ease, opacity 120ms ease;
3508 vertical-align: middle;
3509 }
3510 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3511 .repo-health-badge-elite {
3512 background: rgba(52,211,153,0.15);
e589f77ccantynz-alt3513 color: var(--green);
91a0204Claude3514 border-color: rgba(52,211,153,0.30);
3515 }
3516 .repo-health-badge-strong {
3517 background: rgba(96,165,250,0.15);
3518 color: #93c5fd;
3519 border-color: rgba(96,165,250,0.30);
3520 }
3521 .repo-health-badge-improving {
3522 background: rgba(251,191,36,0.15);
3523 color: #fde68a;
3524 border-color: rgba(251,191,36,0.30);
3525 }
3526 .repo-health-badge-needs-attention {
3527 background: rgba(248,113,113,0.15);
e589f77ccantynz-alt3528 color: var(--red);
91a0204Claude3529 border-color: rgba(248,113,113,0.30);
3530 }
3531
3532 /* Three-option empty-state panel */
3533 .repo-empty-options {
3534 display: grid;
3535 grid-template-columns: repeat(3, 1fr);
3536 gap: var(--space-3);
3537 margin-top: var(--space-5);
3538 }
3539 @media (max-width: 760px) {
3540 .repo-empty-options { grid-template-columns: 1fr; }
3541 }
3542 .repo-empty-option {
3543 position: relative;
3544 background: var(--bg-elevated);
3545 border: 1px solid var(--border);
3546 border-radius: 14px;
3547 padding: var(--space-4) var(--space-4);
3548 display: flex;
3549 flex-direction: column;
3550 gap: var(--space-2);
3551 overflow: hidden;
3552 transition: border-color 140ms ease, background 140ms ease;
3553 }
3554 .repo-empty-option:hover {
6fd5915Claude3555 border-color: rgba(91,110,232,0.45);
3556 background: rgba(91,110,232,0.04);
91a0204Claude3557 }
3558 .repo-empty-option::before {
3559 content: '';
3560 position: absolute;
3561 top: 0; left: 0; right: 0;
3562 height: 2px;
6fd5915Claude3563 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3564 opacity: 0.55;
3565 pointer-events: none;
3566 }
3567 .repo-empty-option-label {
3568 font-size: 10px;
3569 font-family: var(--font-mono);
3570 font-weight: 700;
3571 letter-spacing: 0.12em;
3572 text-transform: uppercase;
3573 color: var(--accent);
3574 margin-bottom: 2px;
3575 }
3576 .repo-empty-option-title {
3577 font-family: var(--font-display);
3578 font-weight: 700;
3579 font-size: 16px;
3580 letter-spacing: -0.01em;
3581 color: var(--text-strong);
3582 margin: 0;
3583 }
3584 .repo-empty-option-sub {
3585 font-size: 13px;
3586 color: var(--text-muted);
3587 line-height: 1.5;
3588 margin: 0;
3589 flex: 1;
3590 }
3591 .repo-empty-option-snippet {
3592 background: var(--bg);
3593 border: 1px solid var(--border);
3594 border-radius: 8px;
3595 padding: var(--space-2) var(--space-3);
3596 font-family: var(--font-mono);
3597 font-size: 11.5px;
3598 line-height: 1.75;
3599 color: var(--text-strong);
3600 overflow-x: auto;
3601 white-space: pre;
3602 margin: var(--space-1) 0 0;
3603 }
3604 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3605 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3606 .repo-empty-option-cta {
3607 display: inline-flex;
3608 align-items: center;
3609 gap: 6px;
3610 margin-top: auto;
3611 padding-top: var(--space-2);
3612 font-size: 13px;
3613 font-weight: 600;
3614 color: var(--accent);
3615 text-decoration: none;
3616 transition: color 120ms ease;
3617 }
3618 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3619
544d842Claude3620 @media (max-width: 720px) {
3621 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3622 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3623 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3624 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3625 .repo-home-side-val { max-width: 55%; }
3626 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3627 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3628 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3629 }
ebaae0fClaude3630
3631 /* AI stats strip */
3632 .repo-ai-stats-strip {
3633 display: flex;
3634 align-items: center;
3635 gap: 6px;
3636 margin-top: 12px;
3637 margin-bottom: 4px;
3638 font-size: 12px;
3639 color: var(--text-muted);
3640 flex-wrap: wrap;
3641 }
3642 .repo-ai-stats-strip a {
3643 color: var(--text-muted);
3644 text-decoration: underline;
3645 text-decoration-color: transparent;
3646 transition: color 120ms ease, text-decoration-color 120ms ease;
3647 }
3648 .repo-ai-stats-strip a:hover {
3649 color: var(--accent);
3650 text-decoration-color: currentColor;
3651 }
3652 .repo-ai-stats-sep {
3653 opacity: 0.4;
3654 user-select: none;
3655 }
544d842Claude3656 `;
3657 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3658 // SSH URL — port-aware:
3659 // Standard port 22 → git@host:owner/repo.git
3660 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3661 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3662 try {
3663 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3664 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3665 ? host
3666 : "localhost";
3667 const sshPort = config.sshPort;
3668 if (sshPort === 22) {
3669 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3670 } else {
3671 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3672 }
3673 } catch {
3674 // Fall through to default.
3675 }
3676 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3677 const formatRelative = (date: Date | null): string => {
3678 if (!date) return "never";
3679 const ms = Date.now() - date.getTime();
3680 const s = Math.max(0, Math.round(ms / 1000));
3681 if (s < 60) return "just now";
3682 const m = Math.round(s / 60);
3683 if (m < 60) return `${m} min ago`;
3684 const h = Math.round(m / 60);
3685 if (h < 24) return `${h}h ago`;
3686 const d = Math.round(h / 24);
3687 if (d < 30) return `${d}d ago`;
3688 const mo = Math.round(d / 30);
3689 if (mo < 12) return `${mo}mo ago`;
3690 const y = Math.round(d / 365);
3691 return `${y}y ago`;
3692 };
06d5ffeClaude3693
fc1817aClaude3694 if (tree.length === 0) {
5acce80Claude3695 const repoOgDesc = description
3696 ? `${owner}/${repo} on Gluecron — ${description}`
3697 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3698 return c.html(
5acce80Claude3699 <Layout
3700 title={`${owner}/${repo}`}
3701 user={user}
3702 description={repoOgDesc}
3703 ogTitle={`${owner}/${repo} — Gluecron`}
3704 ogDescription={repoOgDesc}
3705 twitterCard="summary"
3706 >
544d842Claude3707 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3708 <style dangerouslySetInnerHTML={{ __html: `
3709 .empty-options-grid {
3710 display: grid;
3711 grid-template-columns: repeat(3, 1fr);
3712 gap: var(--space-4);
3713 margin-top: var(--space-4);
3714 }
3715 @media (max-width: 800px) {
3716 .empty-options-grid { grid-template-columns: 1fr; }
3717 }
3718 .empty-option-card {
3719 position: relative;
3720 background: var(--bg-elevated);
3721 border: 1px solid var(--border);
3722 border-radius: 14px;
3723 padding: var(--space-5);
3724 display: flex;
3725 flex-direction: column;
3726 gap: var(--space-3);
3727 overflow: hidden;
3728 transition: border-color 140ms ease, box-shadow 140ms ease;
3729 }
3730 .empty-option-card:hover {
6fd5915Claude3731 border-color: rgba(91,110,232,0.45);
3732 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3733 }
3734 .empty-option-card::before {
3735 content: '';
3736 position: absolute;
3737 top: 0; left: 0; right: 0;
3738 height: 2px;
6fd5915Claude3739 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3740 opacity: 0.55;
3741 pointer-events: none;
3742 }
3743 .empty-option-label {
3744 font-size: 10.5px;
3745 font-family: var(--font-mono);
3746 text-transform: uppercase;
3747 letter-spacing: 0.14em;
3748 color: var(--accent);
3749 font-weight: 700;
3750 }
3751 .empty-option-title {
3752 font-family: var(--font-display);
3753 font-weight: 700;
3754 font-size: 17px;
3755 letter-spacing: -0.01em;
3756 color: var(--text-strong);
3757 margin: 0;
3758 line-height: 1.25;
3759 }
3760 .empty-option-body {
3761 font-size: 13px;
3762 color: var(--text-muted);
3763 line-height: 1.55;
3764 margin: 0;
3765 flex: 1;
3766 }
3767 .empty-option-snippet {
3768 background: var(--bg);
3769 border: 1px solid var(--border);
3770 border-radius: 8px;
3771 padding: var(--space-3) var(--space-4);
3772 font-family: var(--font-mono);
3773 font-size: 11.5px;
3774 line-height: 1.75;
3775 color: var(--text-strong);
3776 overflow-x: auto;
3777 white-space: pre;
3778 margin: 0;
3779 }
3780 .empty-option-snippet .ec { color: var(--text-faint); }
3781 .empty-option-snippet .em { color: var(--accent); }
3782 ` }} />
f6730d0ccantynz-alt3783 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
544d842Claude3784 <div class="repo-home-hero">
3785 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3786 <div class="repo-home-hero-orb" />
3787 </div>
3788 <div class="repo-home-hero-inner">
3789 <div class="repo-home-eyebrow">
3790 <strong>Repository</strong> · {owner}
3791 </div>
91a0204Claude3792 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3793 <RepoHeader
3794 owner={owner}
3795 repo={repo}
3796 starCount={starCount}
3797 starred={starred}
3798 forkCount={forkCount}
3799 currentUser={user?.username}
3800 archived={archived}
3801 isTemplate={isTemplate}
8c790e0Claude3802 recentPush={recentPush}
91a0204Claude3803 />
3804 {healthScore && (() => {
3805 const gradeLabel: Record<string, string> = {
3806 elite: "Elite", strong: "Strong",
3807 improving: "Improving", "needs-attention": "Needs Attention",
3808 };
3809 return (
3810 <a
3811 href={`/${owner}/${repo}/insights/health`}
3812 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3813 title={`Health score: ${healthScore!.total}/100`}
3814 >
3815 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3816 </a>
3817 );
3818 })()}
3819 </div>
544d842Claude3820 {description ? (
3821 <p class="repo-home-description">{description}</p>
3822 ) : (
3823 <p class="repo-home-description-empty">
3824 No description yet — push a README to tell the world what this
3825 ships.
3826 </p>
3827 )}
3828 </div>
3829 </div>
fc1817aClaude3830 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3831 <div style="margin-top:var(--space-4)">
3832 <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)">
3833 Getting started
3834 </div>
544d842Claude3835 <h2 class="repo-home-empty-title">
91a0204Claude3836 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3837 </h2>
3838 <p class="repo-home-empty-sub">
91a0204Claude3839 Choose how you want to kick things off. Every push wires up gate
3840 checks and AI review automatically.
544d842Claude3841 </p>
91a0204Claude3842 <div class="empty-options-grid">
3843 <div class="empty-option-card">
3844 <div class="empty-option-label">Option A</div>
3845 <h3 class="empty-option-title">Push your first commit</h3>
3846 <p class="empty-option-body">
3847 Wire up an existing project in seconds. Run these commands from
3848 your project directory.
3849 </p>
3850 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3851<span class="em">git remote add</span> origin {cloneHttpsUrl}
3852<span class="em">git branch</span> -M main
3853<span class="em">git push</span> -u origin main</pre>
3854 </div>
3855 <div class="empty-option-card">
3856 <div class="empty-option-label">Option B</div>
3857 <h3 class="empty-option-title">Import from GitHub</h3>
3858 <p class="empty-option-body">
3859 Mirror an existing GitHub repository here in one click. Gluecron
3860 syncs the full history and branches.
3861 </p>
f6730d0ccantynz-alt3862 <Button href="/import" variant="secondary" style="align-self:flex-start">
91a0204Claude3863 Import repository
f6730d0ccantynz-alt3864 </Button>
91a0204Claude3865 </div>
3866 <div class="empty-option-card">
3867 <div class="empty-option-label">Option C</div>
3868 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3869 <p class="empty-option-body">
3870 Let AI write your first feature. Describe what you want to build
3871 and Gluecron opens a pull request with the code.
3872 </p>
f6730d0ccantynz-alt3873 <Button href={`/${owner}/${repo}/specs`} variant="primary" style="align-self:flex-start">
91a0204Claude3874 Let AI write your first feature
f6730d0ccantynz-alt3875 </Button>
91a0204Claude3876 </div>
3877 </div>
fc1817aClaude3878 </div>
3879 </Layout>
3880 );
3881 }
3882
3883 const readme = await getReadme(owner, repo, defaultBranch);
3884
544d842Claude3885 // Sidebar facts — derived from data we already have.
3886 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3887 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3888
5acce80Claude3889 const repoOgDesc = description
3890 ? `${owner}/${repo} on Gluecron — ${description}`
3891 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3892
fc1817aClaude3893 return c.html(
5acce80Claude3894 <Layout
3895 title={`${owner}/${repo}`}
3896 user={user}
3897 description={repoOgDesc}
3898 ogTitle={`${owner}/${repo} — Gluecron`}
3899 ogDescription={repoOgDesc}
3900 twitterCard="summary"
3901 >
544d842Claude3902 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3903 {/* Repo-context commands for the command palette (Feature 2) */}
3904 <script
3905 id="cmdk-repo-context"
3906 dangerouslySetInnerHTML={{
3907 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3908 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3909 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3910 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3911 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3912 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3913 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3914 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3915 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3916 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3917 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3918 ])};`,
3919 }}
3920 />
544d842Claude3921 <div class="repo-home-hero">
3922 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3923 <div class="repo-home-hero-orb" />
3924 </div>
3925 <div class="repo-home-hero-inner">
3926 <div class="repo-home-eyebrow">
3927 <strong>Repository</strong> · {owner}
3928 </div>
91a0204Claude3929 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3930 <RepoHeader
3931 owner={owner}
3932 repo={repo}
3933 starCount={starCount}
3934 starred={starred}
3935 forkCount={forkCount}
3936 currentUser={user?.username}
3937 archived={archived}
3938 isTemplate={isTemplate}
8c790e0Claude3939 recentPush={recentPush}
91a0204Claude3940 />
3941 {healthScore && (() => {
3942 const gradeLabel: Record<string, string> = {
3943 elite: "Elite", strong: "Strong",
3944 improving: "Improving", "needs-attention": "Needs Attention",
3945 };
3946 return (
3947 <a
3948 href={`/${owner}/${repo}/insights/health`}
3949 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3950 title={`Health score: ${healthScore!.total}/100`}
3951 >
3952 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3953 </a>
3954 );
3955 })()}
3956 </div>
544d842Claude3957 {description ? (
3958 <p class="repo-home-description">{description}</p>
3959 ) : (
3960 <p class="repo-home-description-empty">
3961 No description yet.
3962 </p>
3963 )}
3964 <div class="repo-home-stat-row" aria-label="Repository stats">
3965 <span class="repo-home-stat" title="Default branch">
3966 <span class="repo-home-stat-icon">{"⎇"}</span>
3967 <strong>{defaultBranch}</strong>
3968 </span>
3969 <a
3970 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3971 class="repo-home-stat"
3972 title="Browse all branches"
3973 >
3974 <span class="repo-home-stat-icon">{"⊢"}</span>
3975 <strong>{branches.length}</strong>{" "}
3976 branch{branches.length === 1 ? "" : "es"}
3977 </a>
3978 <span class="repo-home-stat" title="Top-level entries">
3979 <span class="repo-home-stat-icon">{"■"}</span>
3980 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3981 {dirCount > 0 && (
3982 <>
3983 {" · "}
3984 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3985 </>
3986 )}
3987 </span>
3988 {pushedAt && (
3989 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3990 <span class="repo-home-stat-icon">{"↻"}</span>
3991 Updated <strong>{formatRelative(pushedAt)}</strong>
3992 </span>
3993 )}
3994 </div>
3995 </div>
3996 </div>
71cd5ecClaude3997 {isTemplate && user && user.username !== owner && (
3998 <div
3999 class="panel"
dc26881CC LABS App4000 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude4001 >
4002 <div style="font-size:13px">
4003 <strong>Template repository.</strong> Create a new repository from
4004 this template's files.
4005 </div>
4006 <form
001af43Claude4007 method="post"
71cd5ecClaude4008 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App4009 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude4010 >
4011 <input
4012 type="text"
4013 name="name"
4014 placeholder="new-repo-name"
4015 required
2c3ba6ecopilot-swe-agent[bot]4016 aria-label="New repository name"
71cd5ecClaude4017 style="width:200px"
4018 />
4019 <button type="submit" class="btn btn-primary">
4020 Use this template
4021 </button>
4022 </form>
4023 </div>
4024 )}
fc1817aClaude4025 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4026 <RepoHomePendingBanner
4027 owner={owner}
4028 repo={repo}
4029 count={repoHomePendingCount}
4030 />
641aa42Claude4031 {showOnboarding && onboardingRow && (
4032 <RepoOnboardingCard
4033 owner={owner}
4034 repo={repo}
4035 data={onboardingRow}
4036 />
4037 )}
c6018a5Claude4038 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4039 row sits just below the nav as a slim CTA strip. Scoped under
4040 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4041 <style
4042 dangerouslySetInnerHTML={{
4043 __html: `
4044 .repo-ai-cta-row {
4045 display: flex;
4046 flex-wrap: wrap;
4047 gap: 8px;
4048 margin: 12px 0 18px;
4049 padding: 10px 14px;
6fd5915Claude4050 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4051 border: 1px solid var(--border);
4052 border-radius: 10px;
4053 position: relative;
4054 overflow: hidden;
4055 }
4056 .repo-ai-cta-row::before {
4057 content: '';
4058 position: absolute;
4059 top: 0; left: 0; right: 0;
4060 height: 1px;
6fd5915Claude4061 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4062 opacity: 0.7;
4063 pointer-events: none;
4064 }
4065 .repo-ai-cta-label {
4066 font-size: 11px;
4067 font-weight: 600;
4068 letter-spacing: 0.06em;
4069 text-transform: uppercase;
4070 color: var(--accent);
4071 align-self: center;
4072 padding-right: 8px;
4073 border-right: 1px solid var(--border);
4074 margin-right: 4px;
4075 }
4076 .repo-ai-cta {
4077 display: inline-flex;
4078 align-items: center;
4079 gap: 6px;
4080 padding: 5px 10px;
4081 font-size: 12.5px;
4082 font-weight: 500;
4083 color: var(--text);
4084 background: var(--bg);
4085 border: 1px solid var(--border);
4086 border-radius: 7px;
4087 text-decoration: none;
4088 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4089 }
4090 .repo-ai-cta:hover {
6fd5915Claude4091 border-color: rgba(91,110,232,0.45);
c6018a5Claude4092 color: var(--text-strong);
6fd5915Claude4093 background: rgba(91,110,232,0.06);
c6018a5Claude4094 text-decoration: none;
4095 }
4096 .repo-ai-cta-icon {
4097 opacity: 0.75;
4098 font-size: 12px;
4099 }
4100 @media (max-width: 640px) {
4101 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4102 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4103 }
4104 `,
4105 }}
4106 />
4107 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4108 <span class="repo-ai-cta-label">AI surfaces</span>
4109 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4110 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4111 Chat
4112 </a>
4113 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4114 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4115 Previews
4116 </a>
4117 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4118 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4119 Migrations
4120 </a>
53c9249Claude4121 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4122 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4123 AI Search
c6018a5Claude4124 </a>
4125 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4126 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4127 AI release notes
4128 </a>
3646bfeClaude4129 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4130 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4131 Dev environment
4132 </a>
c6018a5Claude4133 </nav>
544d842Claude4134 <div class="repo-home-grid">
4135 <div class="repo-home-main">
4136 <BranchSwitcher
4137 owner={owner}
4138 repo={repo}
4139 currentRef={defaultBranch}
4140 branches={branches}
4141 pathType="tree"
4142 />
4143 <FileTable
4144 entries={tree}
4145 owner={owner}
4146 repo={repo}
4147 ref={defaultBranch}
4148 path=""
4149 />
ebaae0fClaude4150 {/* AI stats strip — one-line summary of AI value for this repo */}
4151 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4152 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4153 <span>{"⚡"}</span>
4154 {aiStats.mergedCount > 0 ? (
4155 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4156 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4157 </a>
4158 ) : (
4159 <span>0 AI merges this week</span>
4160 )}
4161 <span class="repo-ai-stats-sep">{"·"}</span>
4162 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4163 <span class="repo-ai-stats-sep">{"·"}</span>
4164 {aiStats.securityAlertCount > 0 ? (
4165 <a href={`/${owner}/${repo}/issues?label=security`}>
4166 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4167 </a>
4168 ) : (
4169 <span>0 open security alerts</span>
4170 )}
4171 </div>
4172 ) : (
4173 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4174 <span>{"⚡"}</span>
4175 <span>AI is watching — no activity this week</span>
4176 </div>
4177 )}
544d842Claude4178 {readme && (() => {
4179 const readmeHtml = renderMarkdown(readme);
4180 return (
4181 <div class="repo-home-readme">
4182 <div class="repo-home-readme-head">
4183 <span class="repo-home-readme-icon">{"☰"}</span>
4184 <span>README.md</span>
4185 </div>
4186 <style>{markdownCss}</style>
4187 <div class="markdown-body repo-home-readme-body">
4188 {html([readmeHtml] as unknown as TemplateStringsArray)}
4189 </div>
4190 </div>
4191 );
4192 })()}
4193 </div>
4194 <aside class="repo-home-side" aria-label="Repository details">
4195 <div class="repo-home-clone">
4196 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4197 <button
4198 type="button"
4199 class="repo-home-clone-tab"
4200 role="tab"
4201 aria-selected="true"
4202 data-pane="https"
4203 data-repo-home-clone-tab
4204 >
4205 HTTPS
4206 </button>
4207 <button
4208 type="button"
4209 class="repo-home-clone-tab"
4210 role="tab"
4211 aria-selected="false"
4212 data-pane="ssh"
4213 data-repo-home-clone-tab
4214 >
4215 SSH
4216 </button>
4217 <button
4218 type="button"
4219 class="repo-home-clone-tab"
4220 role="tab"
4221 aria-selected="false"
4222 data-pane="cli"
4223 data-repo-home-clone-tab
4224 >
4225 CLI
4226 </button>
4227 </div>
4228 <div
4229 class="repo-home-clone-pane"
4230 data-pane="https"
4231 data-active="true"
4232 role="tabpanel"
4233 >
4234 <div class="repo-home-clone-body">
4235 <input
4236 class="repo-home-clone-input"
4237 type="text"
4238 value={cloneHttpsUrl}
4239 readonly
4240 aria-label="HTTPS clone URL"
4241 data-repo-home-clone-input
4242 />
4243 <button
4244 type="button"
4245 class="repo-home-clone-copy"
4246 data-repo-home-copy={cloneHttpsUrl}
4247 >
4248 Copy
4249 </button>
4250 </div>
4251 </div>
4252 <div
4253 class="repo-home-clone-pane"
4254 data-pane="ssh"
4255 data-active="false"
4256 role="tabpanel"
4257 >
4258 <div class="repo-home-clone-body">
4259 <input
4260 class="repo-home-clone-input"
4261 type="text"
4262 value={cloneSshUrl}
4263 readonly
4264 aria-label="SSH clone URL"
4265 data-repo-home-clone-input
4266 />
4267 <button
4268 type="button"
4269 class="repo-home-clone-copy"
4270 data-repo-home-copy={cloneSshUrl}
4271 >
4272 Copy
4273 </button>
4274 </div>
4275 </div>
4276 <div
4277 class="repo-home-clone-pane"
4278 data-pane="cli"
4279 data-active="false"
4280 role="tabpanel"
4281 >
4282 <div class="repo-home-clone-body">
4283 <input
4284 class="repo-home-clone-input"
4285 type="text"
4286 value={cloneCliCmd}
4287 readonly
4288 aria-label="Gluecron CLI clone command"
4289 data-repo-home-clone-input
4290 />
4291 <button
4292 type="button"
4293 class="repo-home-clone-copy"
4294 data-repo-home-copy={cloneCliCmd}
4295 >
4296 Copy
4297 </button>
4298 </div>
79136bbClaude4299 </div>
fc1817aClaude4300 </div>
544d842Claude4301 <div class="repo-home-side-card">
4302 <h3 class="repo-home-side-title">About</h3>
4303 <div class="repo-home-side-row">
4304 <span class="repo-home-side-key">Default branch</span>
4305 <span class="repo-home-side-val">{defaultBranch}</span>
4306 </div>
4307 <div class="repo-home-side-row">
4308 <span class="repo-home-side-key">Branches</span>
4309 <span class="repo-home-side-val">
4310 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4311 {branches.length}
4312 </a>
4313 </span>
4314 </div>
4315 <div class="repo-home-side-row">
4316 <span class="repo-home-side-key">Stars</span>
4317 <span class="repo-home-side-val">{starCount}</span>
4318 </div>
4319 <div class="repo-home-side-row">
4320 <span class="repo-home-side-key">Forks</span>
4321 <span class="repo-home-side-val">{forkCount}</span>
4322 </div>
4323 {pushedAt && (
4324 <div class="repo-home-side-row">
4325 <span class="repo-home-side-key">Last push</span>
4326 <span class="repo-home-side-val">
4327 {formatRelative(pushedAt)}
4328 </span>
4329 </div>
4330 )}
4331 {createdAt && (
4332 <div class="repo-home-side-row">
4333 <span class="repo-home-side-key">Created</span>
4334 <span class="repo-home-side-val">
4335 {formatRelative(createdAt)}
4336 </span>
4337 </div>
4338 )}
4339 {(archived || isTemplate) && (
4340 <div class="repo-home-side-row">
4341 <span class="repo-home-side-key">State</span>
4342 <span class="repo-home-side-val">
4343 {archived ? "Archived" : "Template"}
4344 </span>
4345 </div>
4346 )}
4347 </div>
f1dc38bClaude4348 {/* Claude AI — quick-access card for authenticated users */}
4349 {user && (
4350 <div class="repo-home-side-card" style="margin-top:12px">
4351 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4352 ✨ Claude AI
4353 </h3>
4354 <a
4355 href={`/${owner}/${repo}/claude`}
e589f77ccantynz-alt4356 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"
f1dc38bClaude4357 >
4358 Claude Code sessions
4359 </a>
4360 <a
4361 href={`/${owner}/${repo}/ask`}
4362 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4363 >
4364 Ask AI about this repo
4365 </a>
4366 </div>
4367 )}
544d842Claude4368 </aside>
4369 </div>
4370 <script
4371 dangerouslySetInnerHTML={{
4372 __html: `
4373 (function(){
4374 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4375 tabs.forEach(function(tab){
4376 tab.addEventListener('click', function(){
4377 var target = tab.getAttribute('data-pane');
4378 tabs.forEach(function(t){
4379 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4380 });
4381 var panes = document.querySelectorAll('.repo-home-clone-pane');
4382 panes.forEach(function(p){
4383 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4384 });
4385 });
4386 });
4387 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4388 copyBtns.forEach(function(btn){
4389 btn.addEventListener('click', function(){
4390 var text = btn.getAttribute('data-repo-home-copy') || '';
4391 var done = function(){
4392 var prev = btn.textContent;
4393 btn.textContent = 'Copied';
4394 setTimeout(function(){ btn.textContent = prev; }, 1200);
4395 };
4396 if (navigator.clipboard && navigator.clipboard.writeText) {
4397 navigator.clipboard.writeText(text).then(done, done);
4398 } else {
4399 var ta = document.createElement('textarea');
4400 ta.value = text;
4401 document.body.appendChild(ta);
4402 ta.select();
4403 try { document.execCommand('copy'); } catch (e) {}
4404 document.body.removeChild(ta);
4405 done();
4406 }
4407 });
4408 });
4409 })();
4410 `,
4411 }}
4412 />
fc1817aClaude4413 </Layout>
4414 );
4415});
4416
4417// Browse tree at ref/path
4418web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4419 const { owner, repo } = c.req.param();
06d5ffeClaude4420 const user = c.get("user");
5bb52faccanty labs4421 const gate = await assertRepoReadable(c, owner, repo);
4422 if (gate) return gate;
fc1817aClaude4423 const refAndPath = c.req.param("ref");
4424
4425 const branches = await listBranches(owner, repo);
4426 let ref = "";
4427 let treePath = "";
4428
4429 for (const branch of branches) {
4430 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4431 ref = branch;
4432 treePath = refAndPath.slice(branch.length + 1);
4433 break;
4434 }
4435 }
4436
4437 if (!ref) {
4438 const slashIdx = refAndPath.indexOf("/");
4439 if (slashIdx === -1) {
4440 ref = refAndPath;
4441 } else {
4442 ref = refAndPath.slice(0, slashIdx);
4443 treePath = refAndPath.slice(slashIdx + 1);
4444 }
4445 }
4446
4447 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4448 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4449 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4450
4451 return c.html(
06d5ffeClaude4452 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4453 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4454 <RepoHeader owner={owner} repo={repo} />
4455 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4456 <div class="tree-header">
4457 <div class="tree-header-row">
4458 <BranchSwitcher
4459 owner={owner}
4460 repo={repo}
4461 currentRef={ref}
4462 branches={branches}
4463 pathType="tree"
4464 subPath={treePath}
4465 />
4466 <div class="tree-header-stats">
4467 <span class="tree-stat" title="Entries in this directory">
4468 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4469 {dirCount > 0 && (
4470 <>
4471 {" · "}
4472 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4473 </>
4474 )}
4475 </span>
4476 <a
4477 href={`/${owner}/${repo}/search`}
4478 class="tree-stat tree-stat-link"
4479 title="Search code in this repository"
4480 >
4481 {"⌕"} Search
4482 </a>
4483 </div>
4484 </div>
4485 <div class="tree-breadcrumb-row">
4486 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4487 </div>
4488 </div>
fc1817aClaude4489 <FileTable
4490 entries={tree}
4491 owner={owner}
4492 repo={repo}
4493 ref={ref}
4494 path={treePath}
4495 />
4496 </Layout>
4497 );
4498});
4499
06d5ffeClaude4500// View file blob with syntax highlighting
fc1817aClaude4501web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4502 const { owner, repo } = c.req.param();
06d5ffeClaude4503 const user = c.get("user");
5bb52faccanty labs4504 const gate = await assertRepoReadable(c, owner, repo);
4505 if (gate) return gate;
fc1817aClaude4506 const refAndPath = c.req.param("ref");
4507
4508 const branches = await listBranches(owner, repo);
4509 let ref = "";
4510 let filePath = "";
4511
4512 for (const branch of branches) {
4513 if (refAndPath.startsWith(branch + "/")) {
4514 ref = branch;
4515 filePath = refAndPath.slice(branch.length + 1);
4516 break;
4517 }
4518 }
4519
4520 if (!ref) {
4521 const slashIdx = refAndPath.indexOf("/");
4522 if (slashIdx === -1) return c.text("Not found", 404);
4523 ref = refAndPath.slice(0, slashIdx);
4524 filePath = refAndPath.slice(slashIdx + 1);
4525 }
4526
4527 const blob = await getBlob(owner, repo, ref, filePath);
4528 if (!blob) {
4529 return c.html(
06d5ffeClaude4530 <Layout title="Not Found" user={user}>
fc1817aClaude4531 <div class="empty-state">
4532 <h2>File not found</h2>
4533 </div>
4534 </Layout>,
4535 404
4536 );
4537 }
4538
06d5ffeClaude4539 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4540 const lineCount = blob.isBinary
4541 ? 0
4542 : (blob.content.endsWith("\n")
4543 ? blob.content.split("\n").length - 1
4544 : blob.content.split("\n").length);
4545 const formatBytes = (n: number): string => {
4546 if (n < 1024) return `${n} B`;
4547 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4548 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4549 };
fc1817aClaude4550
4551 return c.html(
06d5ffeClaude4552 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4553 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4554 <RepoHeader owner={owner} repo={repo} />
4555 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4556 <div class="blob-toolbar">
4557 <BranchSwitcher
4558 owner={owner}
4559 repo={repo}
4560 currentRef={ref}
4561 branches={branches}
4562 pathType="blob"
4563 subPath={filePath}
4564 />
4565 <div class="blob-breadcrumb">
4566 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4567 </div>
4568 </div>
4569 <div class="blob-view blob-card">
4570 <div class="blob-header blob-header-polished">
4571 <div class="blob-header-meta">
4572 <span class="blob-header-icon" aria-hidden="true">
4573 {"📄"}
4574 </span>
4575 <span class="blob-header-name">{fileName}</span>
4576 <span class="blob-header-size">
4577 {formatBytes(blob.size)}
4578 {!blob.isBinary && (
4579 <>
4580 {" · "}
4581 {lineCount} line{lineCount === 1 ? "" : "s"}
4582 </>
4583 )}
4584 </span>
4585 </div>
4586 <div class="blob-header-actions">
4587 <a
4588 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4589 class="blob-pill"
4590 >
79136bbClaude4591 Raw
4592 </a>
efb11c5Claude4593 <a
4594 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4595 class="blob-pill"
4596 >
79136bbClaude4597 Blame
4598 </a>
efb11c5Claude4599 <a
4600 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4601 class="blob-pill"
4602 >
16b325cClaude4603 History
4604 </a>
0074234Claude4605 {user && (
efb11c5Claude4606 <a
4607 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4608 class="blob-pill blob-pill-accent"
4609 >
0074234Claude4610 Edit
4611 </a>
4612 )}
efb11c5Claude4613 </div>
fc1817aClaude4614 </div>
4615 {blob.isBinary ? (
efb11c5Claude4616 <div class="blob-binary">
fc1817aClaude4617 Binary file not shown.
4618 </div>
06d5ffeClaude4619 ) : (() => {
4620 const { html: highlighted, language } = highlightCode(
4621 blob.content,
4622 fileName
4623 );
4624 if (language) {
4625 return (
4626 <HighlightedCode
4627 highlightedHtml={highlighted}
efb11c5Claude4628 lineCount={lineCount}
06d5ffeClaude4629 />
4630 );
4631 }
4632 const lines = blob.content.split("\n");
4633 if (lines[lines.length - 1] === "") lines.pop();
4634 return <PlainCode lines={lines} />;
4635 })()}
fc1817aClaude4636 </div>
4637 </Layout>
4638 );
4639});
4640
398a10cClaude4641// ─── Branches list ────────────────────────────────────────────────────────
4642// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4643// author + relative time + ahead/behind vs the default branch. No DB. All
4644// data comes from git plumbing; failures degrade gracefully (counts omitted).
4645web.get("/:owner/:repo/branches", async (c) => {
4646 const { owner, repo } = c.req.param();
4647 const user = c.get("user");
5bb52faccanty labs4648 const gate = await assertRepoReadable(c, owner, repo);
4649 if (gate) return gate;
398a10cClaude4650
4651 if (!(await repoExists(owner, repo))) return c.notFound();
4652
4653 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4654 const branches = await listBranches(owner, repo);
4655 const repoDir = getRepoPath(owner, repo);
4656
4657 type BranchRow = {
4658 name: string;
4659 isDefault: boolean;
4660 sha: string;
4661 subject: string;
4662 author: string;
4663 date: string;
4664 ahead: number;
4665 behind: number;
4666 };
4667
4668 const runGit = async (args: string[]): Promise<string> => {
4669 try {
4670 const proc = Bun.spawn(["git", ...args], {
4671 cwd: repoDir,
4672 stdout: "pipe",
4673 stderr: "pipe",
4674 });
4675 const out = await new Response(proc.stdout).text();
4676 await proc.exited;
4677 return out;
4678 } catch {
4679 return "";
4680 }
4681 };
4682
4683 const meta = await runGit([
4684 "for-each-ref",
4685 "--sort=-committerdate",
4686 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4687 "refs/heads/",
4688 ]);
4689 const metaByName: Record<
4690 string,
4691 { sha: string; subject: string; author: string; date: string }
4692 > = {};
4693 for (const line of meta.split("\n").filter(Boolean)) {
4694 const [name, sha, subject, author, date] = line.split("\0");
4695 metaByName[name] = { sha, subject, author, date };
4696 }
4697
4698 const branchOrder = [...branches].sort((a, b) => {
4699 if (a === defaultBranch) return -1;
4700 if (b === defaultBranch) return 1;
4701 const aDate = metaByName[a]?.date || "";
4702 const bDate = metaByName[b]?.date || "";
4703 return bDate.localeCompare(aDate);
4704 });
4705
4706 const rows: BranchRow[] = [];
4707 for (const name of branchOrder) {
4708 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4709 let ahead = 0;
4710 let behind = 0;
4711 if (name !== defaultBranch && metaByName[defaultBranch]) {
4712 const out = await runGit([
4713 "rev-list",
4714 "--left-right",
4715 "--count",
4716 `${defaultBranch}...${name}`,
4717 ]);
4718 const parts = out.trim().split(/\s+/);
4719 if (parts.length === 2) {
4720 behind = parseInt(parts[0], 10) || 0;
4721 ahead = parseInt(parts[1], 10) || 0;
4722 }
4723 }
4724 rows.push({
4725 name,
4726 isDefault: name === defaultBranch,
4727 sha: m.sha,
4728 subject: m.subject,
4729 author: m.author,
4730 date: m.date,
4731 ahead,
4732 behind,
4733 });
4734 }
4735
4736 const relative = (iso: string): string => {
4737 if (!iso) return "—";
4738 const d = new Date(iso);
4739 if (Number.isNaN(d.getTime())) return "—";
4740 const diff = Date.now() - d.getTime();
4741 const m = Math.floor(diff / 60000);
4742 if (m < 1) return "just now";
4743 if (m < 60) return `${m} min ago`;
4744 const h = Math.floor(m / 60);
4745 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4746 const dd = Math.floor(h / 24);
4747 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4748 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4749 };
4750
4751 const success = c.req.query("success");
4752 const error = c.req.query("error");
4753
4754 return c.html(
4755 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4756 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4757 <RepoHeader owner={owner} repo={repo} />
4758 <RepoNav owner={owner} repo={repo} active="code" />
4759 <div
4760 class="branches-wrap"
eed4684Claude4761 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4762 >
4763 <header class="branches-head" style="margin-bottom:var(--space-5)">
4764 <div
4765 class="branches-eyebrow"
4766 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"
4767 >
4768 <span
4769 class="branches-eyebrow-dot"
4770 aria-hidden="true"
6fd5915Claude4771 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)"
398a10cClaude4772 />
4773 Repository · Branches
4774 </div>
4775 <h1
4776 class="branches-title"
4777 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)"
4778 >
4779 <span class="gradient-text">{rows.length}</span>{" "}
4780 branch{rows.length === 1 ? "" : "es"}
4781 </h1>
4782 <p
4783 class="branches-sub"
4784 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4785 >
4786 All work-in-progress lines for{" "}
4787 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4788 counts are relative to{" "}
4789 <code style="font-size:12.5px">{defaultBranch}</code>.
4790 </p>
4791 </header>
4792
4793 {success && (
4794 <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">
4795 {decodeURIComponent(success)}
4796 </div>
4797 )}
4798 {error && (
4799 <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">
4800 {decodeURIComponent(error)}
4801 </div>
4802 )}
4803
4804 {rows.length === 0 ? (
4805 <div class="commits-empty">
4806 <div class="commits-empty-orb" aria-hidden="true" />
4807 <div class="commits-empty-inner">
4808 <div class="commits-empty-icon" aria-hidden="true">
4809 <svg
4810 width="22"
4811 height="22"
4812 viewBox="0 0 24 24"
4813 fill="none"
4814 stroke="currentColor"
4815 stroke-width="2"
4816 stroke-linecap="round"
4817 stroke-linejoin="round"
4818 >
4819 <line x1="6" y1="3" x2="6" y2="15" />
4820 <circle cx="18" cy="6" r="3" />
4821 <circle cx="6" cy="18" r="3" />
4822 <path d="M18 9a9 9 0 0 1-9 9" />
4823 </svg>
4824 </div>
4825 <h3 class="commits-empty-title">No branches yet</h3>
4826 <p class="commits-empty-sub">
4827 Push your first commit to create the default branch.
4828 </p>
4829 </div>
4830 </div>
4831 ) : (
4832 <div class="branches-list">
4833 {rows.map((r) => (
4834 <div class="branches-row">
4835 <div class="branches-row-icon" aria-hidden="true">
4836 <svg
4837 width="14"
4838 height="14"
4839 viewBox="0 0 24 24"
4840 fill="none"
4841 stroke="currentColor"
4842 stroke-width="2"
4843 stroke-linecap="round"
4844 stroke-linejoin="round"
4845 >
4846 <line x1="6" y1="3" x2="6" y2="15" />
4847 <circle cx="18" cy="6" r="3" />
4848 <circle cx="6" cy="18" r="3" />
4849 <path d="M18 9a9 9 0 0 1-9 9" />
4850 </svg>
4851 </div>
4852 <div class="branches-row-main">
4853 <div class="branches-row-name">
4854 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4855 {r.isDefault && (
4856 <span
4857 class="branches-row-default"
4858 title="Default branch"
4859 >
4860 Default
4861 </span>
4862 )}
4863 </div>
4864 <div class="branches-row-meta">
4865 {r.subject ? (
4866 <>
4867 <strong>{r.author || "—"}</strong>
4868 <span class="sep">·</span>
4869 <span
4870 title={r.date ? new Date(r.date).toISOString() : ""}
4871 >
4872 updated {relative(r.date)}
4873 </span>
4874 {r.sha && (
4875 <>
4876 <span class="sep">·</span>
4877 <a
4878 href={`/${owner}/${repo}/commit/${r.sha}`}
4879 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4880 >
4881 {r.sha.slice(0, 7)}
4882 </a>
4883 </>
4884 )}
4885 </>
4886 ) : (
4887 <span>No commit metadata</span>
4888 )}
4889 </div>
4890 </div>
4891 <div class="branches-row-side">
4892 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4893 <span
4894 class="branches-row-divergence"
4895 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4896 >
4897 <span class="ahead">{r.ahead} ahead</span>
4898 <span style="opacity:0.4">|</span>
4899 <span class="behind">{r.behind} behind</span>
4900 </span>
4901 )}
4902 <div class="branches-row-actions">
4903 <a
4904 href={`/${owner}/${repo}/commits/${r.name}`}
4905 class="branches-btn"
4906 title="View commits on this branch"
4907 >
4908 Commits
4909 </a>
4910 {!r.isDefault &&
4911 user &&
4912 user.username === owner && (
4913 <form
4914 method="post"
4915 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4916 style="margin:0"
4917 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4918 >
4919 <button
4920 type="submit"
4921 class="branches-btn branches-btn-danger"
4922 >
4923 Delete
4924 </button>
4925 </form>
4926 )}
4927 </div>
4928 </div>
4929 </div>
4930 ))}
4931 </div>
4932 )}
4933 </div>
4934 </Layout>
4935 );
4936});
4937
4938// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4939// that are not merged into the default branch — matches the explicit
4940// confirmation on the row's delete button.
4941web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4942 const { owner, repo } = c.req.param();
4943 const branchName = decodeURIComponent(c.req.param("name"));
4944 const user = c.get("user")!;
4945
4946 // Owner-only check (mirrors collaborators.tsx pattern).
4947 const [ownerRow] = await db
4948 .select()
4949 .from(users)
4950 .where(eq(users.username, owner))
4951 .limit(1);
4952 if (!ownerRow || ownerRow.id !== user.id) {
4953 return c.redirect(
4954 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4955 );
4956 }
4957
4958 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4959 if (branchName === defaultBranch) {
4960 return c.redirect(
4961 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4962 );
4963 }
4964
4965 const branches = await listBranches(owner, repo);
4966 if (!branches.includes(branchName)) {
4967 return c.redirect(
4968 `/${owner}/${repo}/branches?error=Branch+not+found`
4969 );
4970 }
4971
4972 try {
4973 const repoDir = getRepoPath(owner, repo);
4974 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4975 cwd: repoDir,
4976 stdout: "pipe",
4977 stderr: "pipe",
4978 });
4979 await proc.exited;
4980 if (proc.exitCode !== 0) {
4981 return c.redirect(
4982 `/${owner}/${repo}/branches?error=Delete+failed`
4983 );
4984 }
4985 } catch {
4986 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4987 }
4988
4989 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4990});
4991
4992// ─── Tags list ────────────────────────────────────────────────────────────
4993// Pulls from `listTags` (sorted newest first). For each tag we look up an
4994// associated release row so the "View release" CTA can deep-link directly
4995// without making the user hunt for it.
4996web.get("/:owner/:repo/tags", async (c) => {
4997 const { owner, repo } = c.req.param();
4998 const user = c.get("user");
5bb52faccanty labs4999 const gate = await assertRepoReadable(c, owner, repo);
5000 if (gate) return gate;
398a10cClaude5001
5002 if (!(await repoExists(owner, repo))) return c.notFound();
5003
5004 const tags = await listTags(owner, repo);
5005
5006 // Map tags -> releases. Best-effort; releases table may not exist in
5007 // every test setup, so any error falls through with an empty set.
5008 const tagsWithReleases = new Set<string>();
5009 try {
5010 const [ownerRow] = await db
5011 .select()
5012 .from(users)
5013 .where(eq(users.username, owner))
5014 .limit(1);
5015 if (ownerRow) {
5016 const [repoRow] = await db
5017 .select()
5018 .from(repositories)
5019 .where(
5020 and(
5021 eq(repositories.ownerId, ownerRow.id),
5022 eq(repositories.name, repo)
5023 )
5024 )
5025 .limit(1);
5026 if (repoRow) {
5027 // Raw SQL so we don't need to import the releases schema here.
5028 const result = await db.execute(
5029 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5030 );
5031 const rows: any[] = (result as any).rows || (result as any) || [];
5032 for (const row of rows) {
5033 const tag = row?.tag;
5034 if (typeof tag === "string") tagsWithReleases.add(tag);
5035 }
5036 }
5037 }
5038 } catch {
5039 // No releases table or DB error — leave set empty.
5040 }
5041
5042 const relative = (iso: string): string => {
5043 if (!iso) return "—";
5044 const d = new Date(iso);
5045 if (Number.isNaN(d.getTime())) return "—";
5046 const diff = Date.now() - d.getTime();
5047 const m = Math.floor(diff / 60000);
5048 if (m < 1) return "just now";
5049 if (m < 60) return `${m} min ago`;
5050 const h = Math.floor(m / 60);
5051 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5052 const dd = Math.floor(h / 24);
5053 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5054 return d.toLocaleDateString("en-US", {
5055 month: "short",
5056 day: "numeric",
5057 year: "numeric",
5058 });
5059 };
5060
5061 return c.html(
5062 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5063 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5064 <RepoHeader owner={owner} repo={repo} />
5065 <RepoNav owner={owner} repo={repo} active="code" />
5066 <div
5067 class="tags-wrap"
eed4684Claude5068 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5069 >
5070 <header class="tags-head" style="margin-bottom:var(--space-5)">
5071 <div
5072 class="tags-eyebrow"
5073 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"
5074 >
5075 <span
5076 class="tags-eyebrow-dot"
5077 aria-hidden="true"
6fd5915Claude5078 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)"
398a10cClaude5079 />
5080 Repository · Tags
5081 </div>
5082 <h1
5083 class="tags-title"
5084 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)"
5085 >
5086 <span class="gradient-text">{tags.length}</span>{" "}
5087 tag{tags.length === 1 ? "" : "s"}
5088 </h1>
5089 <p
5090 class="tags-sub"
5091 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5092 >
5093 Named points in the history — typically releases, milestones,
5094 or shipped versions. Click a tag to browse the tree at that
5095 revision.
5096 </p>
5097 </header>
5098
5099 {tags.length === 0 ? (
5100 <div class="commits-empty">
5101 <div class="commits-empty-orb" aria-hidden="true" />
5102 <div class="commits-empty-inner">
5103 <div class="commits-empty-icon" aria-hidden="true">
5104 <svg
5105 width="22"
5106 height="22"
5107 viewBox="0 0 24 24"
5108 fill="none"
5109 stroke="currentColor"
5110 stroke-width="2"
5111 stroke-linecap="round"
5112 stroke-linejoin="round"
5113 >
5114 <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" />
5115 <line x1="7" y1="7" x2="7.01" y2="7" />
5116 </svg>
5117 </div>
5118 <h3 class="commits-empty-title">No tags yet</h3>
5119 <p class="commits-empty-sub">
5120 Tag a commit to mark a release or milestone. From the CLI:{" "}
5121 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5122 </p>
5123 </div>
5124 </div>
5125 ) : (
5126 <div class="tags-list">
5127 {tags.map((t) => {
5128 const hasRelease = tagsWithReleases.has(t.name);
5129 return (
5130 <div class="tags-row">
5131 <div class="tags-row-icon" aria-hidden="true">
5132 <svg
5133 width="14"
5134 height="14"
5135 viewBox="0 0 24 24"
5136 fill="none"
5137 stroke="currentColor"
5138 stroke-width="2"
5139 stroke-linecap="round"
5140 stroke-linejoin="round"
5141 >
5142 <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" />
5143 <line x1="7" y1="7" x2="7.01" y2="7" />
5144 </svg>
5145 </div>
5146 <div class="tags-row-main">
5147 <div class="tags-row-name">
5148 <a
5149 href={`/${owner}/${repo}/tree/${t.name}`}
5150 style="text-decoration:none"
5151 >
5152 <span class="tags-row-version">{t.name}</span>
5153 </a>
5154 </div>
5155 <div class="tags-row-meta">
5156 <span
5157 title={t.date ? new Date(t.date).toISOString() : ""}
5158 >
5159 Tagged {relative(t.date)}
5160 </span>
5161 <span class="sep">·</span>
5162 <a
5163 href={`/${owner}/${repo}/commit/${t.sha}`}
5164 class="tags-row-sha"
5165 title={t.sha}
5166 >
5167 {t.sha.slice(0, 7)}
5168 </a>
5169 </div>
5170 </div>
5171 <div class="tags-row-side">
5172 <a
5173 href={`/${owner}/${repo}/tree/${t.name}`}
5174 class="tags-row-link"
5175 >
5176 Browse files
5177 </a>
5178 {hasRelease && (
5179 <a
5180 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5181 class="tags-row-link"
6fd5915Claude5182 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5183 >
5184 View release
5185 </a>
5186 )}
5187 </div>
5188 </div>
5189 );
5190 })}
5191 </div>
5192 )}
5193 </div>
5194 </Layout>
5195 );
5196});
5197
fc1817aClaude5198// Commit log
5199web.get("/:owner/:repo/commits/:ref?", async (c) => {
5200 const { owner, repo } = c.req.param();
06d5ffeClaude5201 const user = c.get("user");
5bb52faccanty labs5202 const gate = await assertRepoReadable(c, owner, repo);
5203 if (gate) return gate;
fc1817aClaude5204 const ref =
5205 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5206 const branches = await listBranches(owner, repo);
fc1817aClaude5207
5208 const commits = await listCommits(owner, repo, ref, 50);
5209
3951454Claude5210 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5211 // Also resolve repoId here so we can query the recent push for the
5212 // Push Watch live/watch indicator in the header.
3951454Claude5213 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5214 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5215 try {
5216 const [ownerRow] = await db
5217 .select()
5218 .from(users)
5219 .where(eq(users.username, owner))
5220 .limit(1);
5221 if (ownerRow) {
5222 const [repoRow] = await db
5223 .select()
5224 .from(repositories)
5225 .where(
5226 and(
5227 eq(repositories.ownerId, ownerRow.id),
5228 eq(repositories.name, repo)
5229 )
5230 )
5231 .limit(1);
8c790e0Claude5232 if (repoRow) {
5233 // Fetch verifications and recent push in parallel.
5234 const [verRows, rp] = await Promise.all([
5235 commits.length > 0
5236 ? db
5237 .select()
5238 .from(commitVerifications)
5239 .where(
5240 and(
5241 eq(commitVerifications.repositoryId, repoRow.id),
5242 inArray(
5243 commitVerifications.commitSha,
5244 commits.map((c) => c.sha)
5245 )
5246 )
5247 )
5248 : Promise.resolve([]),
5249 getRecentPush(repoRow.id),
5250 ]);
5251 for (const r of verRows) {
3951454Claude5252 verifications[r.commitSha] = {
5253 verified: r.verified,
5254 reason: r.reason,
5255 };
5256 }
8c790e0Claude5257 commitsPageRecentPush = rp;
3951454Claude5258 }
5259 }
5260 } catch {
5261 // DB unavailable — skip the badges gracefully.
5262 }
5263
fc1817aClaude5264 return c.html(
06d5ffeClaude5265 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5266 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5267 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5268 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5269 <div class="commits-hero">
5270 <div class="commits-hero-orb-wrap" aria-hidden="true">
5271 <div class="commits-hero-orb" />
fc1817aClaude5272 </div>
efb11c5Claude5273 <div class="commits-hero-inner">
5274 <div class="commits-eyebrow">
5275 <strong>History</strong> · {owner}/{repo}
5276 </div>
5277 <h1 class="commits-title">
5278 <span class="gradient-text">{commits.length}</span> recent commit
5279 {commits.length === 1 ? "" : "s"} on{" "}
5280 <span class="commits-branch">{ref}</span>
5281 </h1>
5282 <p class="commits-sub">
5283 Browse the project's history. Click any commit to see the full
5284 diff, AI review notes, and signature status.
5285 </p>
5286 </div>
5287 </div>
5288 <div class="commits-toolbar">
5289 <BranchSwitcher
3951454Claude5290 owner={owner}
5291 repo={repo}
efb11c5Claude5292 currentRef={ref}
5293 branches={branches}
5294 pathType="commits"
3951454Claude5295 />
398a10cClaude5296 <div class="commits-toolbar-actions">
5297 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5298 {"⊢"} Branches
5299 </a>
5300 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5301 {"#"} Tags
5302 </a>
5303 </div>
efb11c5Claude5304 </div>
5305 {commits.length === 0 ? (
5306 <div class="commits-empty">
398a10cClaude5307 <div class="commits-empty-orb" aria-hidden="true" />
5308 <div class="commits-empty-inner">
5309 <div class="commits-empty-icon" aria-hidden="true">
5310 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5311 <circle cx="12" cy="12" r="4" />
5312 <line x1="1.05" y1="12" x2="7" y2="12" />
5313 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5314 </svg>
5315 </div>
5316 <h3 class="commits-empty-title">No commits yet</h3>
5317 <p class="commits-empty-sub">
5318 This branch is empty. Push your first commit, or use the
5319 web editor to create a file.
5320 </p>
5321 </div>
efb11c5Claude5322 </div>
5323 ) : (
5324 <div class="commits-list-wrap">
398a10cClaude5325 {(() => {
5326 // Group commits by day for the section headers.
5327 const dayLabel = (iso: string): string => {
5328 const d = new Date(iso);
5329 if (Number.isNaN(d.getTime())) return "Unknown";
5330 const today = new Date();
5331 const yesterday = new Date(today);
5332 yesterday.setDate(today.getDate() - 1);
5333 const sameDay = (a: Date, b: Date) =>
5334 a.getFullYear() === b.getFullYear() &&
5335 a.getMonth() === b.getMonth() &&
5336 a.getDate() === b.getDate();
5337 if (sameDay(d, today)) return "Today";
5338 if (sameDay(d, yesterday)) return "Yesterday";
5339 return d.toLocaleDateString("en-US", {
5340 month: "long",
5341 day: "numeric",
5342 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5343 });
5344 };
5345 const relative = (iso: string): string => {
5346 const d = new Date(iso);
5347 if (Number.isNaN(d.getTime())) return "";
5348 const diff = Date.now() - d.getTime();
5349 const m = Math.floor(diff / 60000);
5350 if (m < 1) return "just now";
5351 if (m < 60) return `${m} min ago`;
5352 const h = Math.floor(m / 60);
5353 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5354 const dd = Math.floor(h / 24);
5355 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5356 return d.toLocaleDateString("en-US", {
5357 month: "short",
5358 day: "numeric",
5359 });
5360 };
5361 const initial = (name: string): string =>
5362 (name || "?").trim().charAt(0).toUpperCase() || "?";
5363 // Build groups preserving order.
5364 const groups: Array<{ label: string; items: typeof commits }> = [];
5365 let lastLabel = "";
5366 for (const cm of commits) {
5367 const label = dayLabel(cm.date);
5368 if (label !== lastLabel) {
5369 groups.push({ label, items: [] });
5370 lastLabel = label;
5371 }
5372 groups[groups.length - 1].items.push(cm);
5373 }
5374 return groups.map((g) => (
5375 <>
5376 <div class="commits-day-head">
5377 <span class="commits-day-head-dot" aria-hidden="true" />
5378 Commits on {g.label}
5379 </div>
5380 {g.items.map((cm) => {
5381 const v = verifications[cm.sha];
5382 return (
5383 <div class="commits-row">
5384 <div class="commits-avatar" aria-hidden="true">
5385 {initial(cm.author)}
5386 </div>
5387 <div class="commits-row-body">
5388 <div class="commits-row-msg">
5389 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5390 {cm.message}
5391 </a>
5392 {v?.verified && (
5393 <span
5394 class="commits-row-verified"
5395 title="Signed with a registered key"
5396 >
5397 Verified
5398 </span>
5399 )}
5400 </div>
5401 <div class="commits-row-meta">
5402 <strong>{cm.author}</strong>
5403 <span class="sep">·</span>
5404 <span
5405 class="commits-row-time"
5406 title={new Date(cm.date).toISOString()}
5407 >
5408 committed {relative(cm.date)}
5409 </span>
5410 {cm.parentShas.length > 1 && (
5411 <>
5412 <span class="sep">·</span>
5413 <span>merge of {cm.parentShas.length} parents</span>
5414 </>
5415 )}
5416 </div>
5417 </div>
5418 <div class="commits-row-side">
5419 <a
5420 href={`/${owner}/${repo}/commit/${cm.sha}`}
5421 class="commits-row-sha"
5422 title={cm.sha}
5423 >
5424 {cm.sha.slice(0, 7)}
5425 </a>
8c790e0Claude5426 <a
5427 href={`/${owner}/${repo}/push/${cm.sha}`}
5428 class="commits-row-watch"
5429 title="Watch gate + deploy results for this push"
5430 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5431 >
5432 <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">
5433 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5434 <circle cx="12" cy="12" r="3" />
5435 </svg>
5436 </a>
398a10cClaude5437 <button
5438 type="button"
5439 class="commits-row-copy"
5440 data-copy-sha={cm.sha}
5441 title="Copy full SHA"
5442 aria-label={`Copy SHA ${cm.sha}`}
5443 >
5444 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5445 <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" />
5446 <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" />
5447 </svg>
5448 </button>
5449 </div>
5450 </div>
5451 );
5452 })}
5453 </>
5454 ));
5455 })()}
efb11c5Claude5456 </div>
fc1817aClaude5457 )}
398a10cClaude5458 <script
5459 dangerouslySetInnerHTML={{
5460 __html: `
5461 (function(){
5462 document.addEventListener('click', function(e){
5463 var t = e.target; if (!t) return;
5464 var btn = t.closest && t.closest('[data-copy-sha]');
5465 if (!btn) return;
5466 e.preventDefault();
5467 var sha = btn.getAttribute('data-copy-sha') || '';
5468 if (!navigator.clipboard) return;
5469 navigator.clipboard.writeText(sha).then(function(){
5470 btn.classList.add('is-copied');
5471 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5472 }).catch(function(){});
5473 });
5474 })();
5475 `,
5476 }}
5477 />
fc1817aClaude5478 </Layout>
5479 );
5480});
5481
5482// Single commit with diff
5483web.get("/:owner/:repo/commit/:sha", async (c) => {
5484 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5485 const user = c.get("user");
5bb52faccanty labs5486 const gate = await assertRepoReadable(c, owner, repo);
5487 if (gate) return gate;
fc1817aClaude5488
05b973eClaude5489 // Fetch commit, full message, and diff in parallel
5490 const [commit, fullMessage, diffResult] = await Promise.all([
5491 getCommit(owner, repo, sha),
5492 getCommitFullMessage(owner, repo, sha),
5493 getDiff(owner, repo, sha),
5494 ]);
fc1817aClaude5495 if (!commit) {
5496 return c.html(
06d5ffeClaude5497 <Layout title="Not Found" user={user}>
fc1817aClaude5498 <div class="empty-state">
5499 <h2>Commit not found</h2>
5500 </div>
5501 </Layout>,
5502 404
5503 );
5504 }
5505
3951454Claude5506 // Block J3 — try to verify this commit's signature.
5507 let verification:
5508 | { verified: boolean; reason: string; signatureType: string | null }
5509 | null = null;
0cdfd89Claude5510 // Block J8 — external CI commit statuses rollup.
5511 let statusCombined:
5512 | {
5513 state: "pending" | "success" | "failure";
5514 total: number;
5515 contexts: Array<{
5516 context: string;
5517 state: string;
5518 description: string | null;
5519 targetUrl: string | null;
5520 }>;
5521 }
5522 | null = null;
3951454Claude5523 try {
5524 const [ownerRow] = await db
5525 .select()
5526 .from(users)
5527 .where(eq(users.username, owner))
5528 .limit(1);
5529 if (ownerRow) {
5530 const [repoRow] = await db
5531 .select()
5532 .from(repositories)
5533 .where(
5534 and(
5535 eq(repositories.ownerId, ownerRow.id),
5536 eq(repositories.name, repo)
5537 )
5538 )
5539 .limit(1);
5540 if (repoRow) {
5541 const { verifyCommit } = await import("../lib/signatures");
5542 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5543 verification = {
5544 verified: v.verified,
5545 reason: v.reason,
5546 signatureType: v.signatureType,
5547 };
0cdfd89Claude5548 try {
5549 const { combinedStatus } = await import("../lib/commit-statuses");
5550 const combined = await combinedStatus(repoRow.id, commit.sha);
5551 if (combined.total > 0) {
5552 statusCombined = {
5553 state: combined.state as any,
5554 total: combined.total,
5555 contexts: combined.contexts.map((c) => ({
5556 context: c.context,
5557 state: c.state,
5558 description: c.description,
5559 targetUrl: c.targetUrl,
5560 })),
5561 };
5562 }
5563 } catch {
5564 statusCombined = null;
5565 }
3951454Claude5566 }
5567 }
5568 } catch {
5569 verification = null;
5570 }
5571
05b973eClaude5572 const { files, raw } = diffResult;
fc1817aClaude5573
efb11c5Claude5574 // Diff stats: count additions / deletions across all files for the
5575 // header summary bar. Computed here from the parsed diff so we don't
5576 // touch the DiffView component.
5577 let additions = 0;
5578 let deletions = 0;
5579 for (const f of files) {
5580 const hunks = (f as any).hunks as Array<any> | undefined;
5581 if (Array.isArray(hunks)) {
5582 for (const h of hunks) {
5583 const lines = (h?.lines || []) as Array<any>;
5584 for (const ln of lines) {
5585 const t = ln?.type || ln?.kind;
5586 if (t === "add" || t === "added" || t === "+") additions += 1;
5587 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5588 deletions += 1;
5589 }
5590 }
5591 }
5592 }
5593 // Fall back: scan raw if file-level counting yielded zero (it's just a
5594 // header polish — never let a parsing miss break the page).
5595 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5596 for (const line of raw.split("\n")) {
5597 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5598 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5599 }
5600 }
5601 const fileCount = files.length;
5602
fc1817aClaude5603 return c.html(
06d5ffeClaude5604 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5605 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5606 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5607 <div class="commit-detail-card">
5608 <div class="commit-detail-eyebrow">
5609 <strong>Commit</strong>
5610 <span class="commit-detail-sha-pill" title={commit.sha}>
5611 {commit.sha.slice(0, 7)}
5612 </span>
3951454Claude5613 {verification && verification.reason !== "unsigned" && (
5614 <span
efb11c5Claude5615 class={`commit-detail-verify ${
3951454Claude5616 verification.verified
efb11c5Claude5617 ? "commit-detail-verify-ok"
5618 : "commit-detail-verify-warn"
3951454Claude5619 }`}
5620 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5621 >
5622 {verification.verified ? "Verified" : verification.reason}
5623 </span>
5624 )}
fc1817aClaude5625 </div>
efb11c5Claude5626 <h1 class="commit-detail-title">{commit.message}</h1>
5627 {fullMessage !== commit.message && (
5628 <pre class="commit-detail-body">{fullMessage}</pre>
5629 )}
5630 <div class="commit-detail-meta">
5631 <span class="commit-detail-author">
5632 <strong>{commit.author}</strong> committed on{" "}
5633 {new Date(commit.date).toLocaleDateString("en-US", {
5634 month: "long",
5635 day: "numeric",
5636 year: "numeric",
5637 })}
5638 </span>
fc1817aClaude5639 {commit.parentShas.length > 0 && (
efb11c5Claude5640 <span class="commit-detail-parents">
5641 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5642 {commit.parentShas.map((p, idx) => (
5643 <>
5644 {idx > 0 && " "}
5645 <a
5646 href={`/${owner}/${repo}/commit/${p}`}
5647 class="commit-detail-sha-link"
5648 >
5649 {p.slice(0, 7)}
5650 </a>
5651 </>
fc1817aClaude5652 ))}
5653 </span>
5654 )}
5655 </div>
efb11c5Claude5656 <div class="commit-detail-stats">
5657 <span class="commit-detail-stat">
5658 <strong>{fileCount}</strong>{" "}
5659 file{fileCount === 1 ? "" : "s"} changed
5660 </span>
5661 <span class="commit-detail-stat commit-detail-stat-add">
5662 <span class="commit-detail-stat-mark">+</span>
5663 <strong>{additions}</strong>
5664 </span>
5665 <span class="commit-detail-stat commit-detail-stat-del">
5666 <span class="commit-detail-stat-mark">−</span>
5667 <strong>{deletions}</strong>
5668 </span>
5669 <span class="commit-detail-sha-full" title="Full SHA">
5670 {commit.sha}
5671 </span>
5672 </div>
0cdfd89Claude5673 {statusCombined && (
efb11c5Claude5674 <div class="commit-detail-checks">
5675 <div class="commit-detail-checks-head">
5676 <strong>Checks</strong>
5677 <span class="commit-detail-checks-summary">
5678 {statusCombined.total} total ·{" "}
5679 <span
5680 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5681 >
5682 {statusCombined.state}
5683 </span>
0cdfd89Claude5684 </span>
efb11c5Claude5685 </div>
5686 <div class="commit-detail-check-row">
0cdfd89Claude5687 {statusCombined.contexts.map((cx) => (
5688 <span
efb11c5Claude5689 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5690 title={cx.description || cx.context}
5691 >
5692 {cx.targetUrl ? (
efb11c5Claude5693 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5694 {cx.context}: {cx.state}
5695 </a>
5696 ) : (
5697 <>
5698 {cx.context}: {cx.state}
5699 </>
5700 )}
5701 </span>
5702 ))}
5703 </div>
5704 </div>
5705 )}
fc1817aClaude5706 </div>
ea9ed4cClaude5707 <DiffView
5708 raw={raw}
5709 files={files}
5710 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5711 />
fc1817aClaude5712 </Layout>
5713 );
5714});
5715
79136bbClaude5716// Raw file download
5717web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5718 const { owner, repo } = c.req.param();
5bb52faccanty labs5719 const gate = await assertRepoReadable(c, owner, repo);
5720 if (gate) return gate;
79136bbClaude5721 const refAndPath = c.req.param("ref");
5722
5723 const branches = await listBranches(owner, repo);
5724 let ref = "";
5725 let filePath = "";
5726
5727 for (const branch of branches) {
5728 if (refAndPath.startsWith(branch + "/")) {
5729 ref = branch;
5730 filePath = refAndPath.slice(branch.length + 1);
5731 break;
5732 }
5733 }
5734
5735 if (!ref) {
5736 const slashIdx = refAndPath.indexOf("/");
5737 if (slashIdx === -1) return c.text("Not found", 404);
5738 ref = refAndPath.slice(0, slashIdx);
5739 filePath = refAndPath.slice(slashIdx + 1);
5740 }
5741
5742 const data = await getRawBlob(owner, repo, ref, filePath);
5743 if (!data) return c.text("Not found", 404);
5744
5745 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5746 return new Response(data as BodyInit, {
79136bbClaude5747 headers: {
5748 "Content-Type": "application/octet-stream",
5749 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5750 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5751 },
5752 });
5753});
5754
5755// Blame view
5756web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5757 const { owner, repo } = c.req.param();
5758 const user = c.get("user");
5bb52faccanty labs5759 const gate = await assertRepoReadable(c, owner, repo);
5760 if (gate) return gate;
79136bbClaude5761 const refAndPath = c.req.param("ref");
5762
5763 const branches = await listBranches(owner, repo);
5764 let ref = "";
5765 let filePath = "";
5766
5767 for (const branch of branches) {
5768 if (refAndPath.startsWith(branch + "/")) {
5769 ref = branch;
5770 filePath = refAndPath.slice(branch.length + 1);
5771 break;
5772 }
5773 }
5774
5775 if (!ref) {
5776 const slashIdx = refAndPath.indexOf("/");
5777 if (slashIdx === -1) return c.text("Not found", 404);
5778 ref = refAndPath.slice(0, slashIdx);
5779 filePath = refAndPath.slice(slashIdx + 1);
5780 }
5781
5782 const blameLines = await getBlame(owner, repo, ref, filePath);
5783 if (blameLines.length === 0) {
5784 return c.html(
5785 <Layout title="Not Found" user={user}>
5786 <div class="empty-state">
5787 <h2>File not found</h2>
5788 </div>
5789 </Layout>,
5790 404
5791 );
5792 }
5793
5794 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5795 // Unique contributors (by author) tracked once for the header chip.
5796 const blameAuthors = new Set<string>();
5797 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5798
5799 return c.html(
5800 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5801 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5802 <RepoHeader owner={owner} repo={repo} />
5803 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5804 <header class="blame-head">
5805 <div class="blame-eyebrow">
5806 <span class="blame-eyebrow-dot" aria-hidden="true" />
5807 Blame · Line-by-line history
5808 </div>
5809 <h1 class="blame-title">
5810 <code>{fileName}</code>
5811 </h1>
5812 <p class="blame-sub">
5813 Each line is annotated with the commit that last touched it.
5814 Click any SHA to jump to that commit and see the surrounding
5815 change.
5816 </p>
5817 </header>
efb11c5Claude5818 <div class="blame-toolbar">
5819 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5820 </div>
398a10cClaude5821 <div class="blame-card">
5822 <div class="blame-header">
efb11c5Claude5823 <div class="blame-header-meta">
5824 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5825 <span class="blame-header-name">{fileName}</span>
5826 <span class="blame-header-tag">Blame</span>
5827 <span class="blame-header-stats">
5828 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5829 {blameAuthors.size} contributor
5830 {blameAuthors.size === 1 ? "" : "s"}
5831 </span>
5832 </div>
5833 <div class="blame-header-actions">
5834 <a
5835 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5836 class="blob-pill"
5837 >
5838 Normal view
5839 </a>
5840 <a
5841 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5842 class="blob-pill"
5843 >
5844 Raw
5845 </a>
5846 </div>
79136bbClaude5847 </div>
398a10cClaude5848 <div style="overflow-x:auto">
5849 <table class="blame-table">
79136bbClaude5850 <tbody>
5851 {blameLines.map((line, i) => {
5852 const showInfo =
5853 i === 0 || blameLines[i - 1].sha !== line.sha;
5854 return (
398a10cClaude5855 <tr class={showInfo ? "blame-row-first" : ""}>
5856 <td class="blame-gutter">
79136bbClaude5857 {showInfo && (
398a10cClaude5858 <span class="blame-gutter-inner">
79136bbClaude5859 <a
5860 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5861 class="blame-gutter-sha"
5862 title={`Commit ${line.sha}`}
79136bbClaude5863 >
5864 {line.sha.slice(0, 7)}
398a10cClaude5865 </a>
5866 <span class="blame-gutter-author" title={line.author}>
5867 {line.author}
5868 </span>
5869 </span>
79136bbClaude5870 )}
5871 </td>
398a10cClaude5872 <td class="blame-line-num">{line.lineNum}</td>
5873 <td class="blame-line-content">{line.content}</td>
79136bbClaude5874 </tr>
5875 );
5876 })}
5877 </tbody>
5878 </table>
5879 </div>
5880 </div>
5881 </Layout>
5882 );
5883});
5884
53c9249Claude5885// Search — keyword + optional Claude semantic mode
79136bbClaude5886web.get("/:owner/:repo/search", async (c) => {
5887 const { owner, repo } = c.req.param();
5888 const user = c.get("user");
5bb52faccanty labs5889 const gate = await assertRepoReadable(c, owner, repo);
5890 if (gate) return gate;
79136bbClaude5891 const q = c.req.query("q") || "";
53c9249Claude5892 const aiAvailable = isAiAvailable();
5893 // Default to semantic when Claude is available and no explicit mode set.
5894 const modeParam = c.req.query("mode");
5895 const mode: "semantic" | "keyword" =
5896 modeParam === "keyword"
5897 ? "keyword"
5898 : modeParam === "semantic"
5899 ? "semantic"
5900 : aiAvailable
5901 ? "semantic"
5902 : "keyword";
5903 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5904
5905 if (!(await repoExists(owner, repo))) return c.notFound();
5906
5907 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5908
5909 // Keyword results (always available as fallback / when in keyword mode)
5910 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5911 // Semantic results (Claude-powered)
5912 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5913 let semanticHits: SemanticHit[] = [];
5914 let semanticMode: "semantic" | "keyword" = "semantic";
5915 let quotaExceeded = false;
79136bbClaude5916
5917 if (q.trim()) {
53c9249Claude5918 if (isSemantic) {
5919 // Resolve repo DB id for caching
5920 const [ownerRow] = await db
5921 .select({ id: users.id })
5922 .from(users)
5923 .where(eq(users.username, owner))
5924 .limit(1);
5925 const [repoRow] = ownerRow
5926 ? await db
5927 .select({ id: repositories.id })
5928 .from(repositories)
5929 .where(
5930 and(
5931 eq(repositories.ownerId, ownerRow.id),
5932 eq(repositories.name, repo)
5933 )
5934 )
5935 .limit(1)
5936 : [];
5937
5938 const rateLimitKey = user
5939 ? `user:${user.id}`
5940 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5941
5942 const searchResult = await claudeSemanticSearch(
5943 owner,
5944 repo,
5945 repoRow?.id ?? `${owner}/${repo}`,
5946 q.trim(),
5947 { branch: defaultBranch, rateLimitKey }
5948 );
5949 semanticHits = searchResult.results;
5950 semanticMode = searchResult.mode;
5951 quotaExceeded = searchResult.quotaExceeded;
5952 } else {
5953 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5954 }
79136bbClaude5955 }
5956
53c9249Claude5957 const aiSearchCss = `
5958 /* ─── Semantic search mode toggle ─── */
5959 .search-mode-bar {
5960 display: flex;
5961 align-items: center;
5962 gap: 8px;
5963 margin-bottom: 14px;
5964 flex-wrap: wrap;
5965 }
5966 .search-mode-label {
5967 font-size: 12.5px;
5968 color: var(--text-muted);
5969 font-weight: 500;
5970 }
5971 .search-mode-toggle {
5972 display: inline-flex;
5973 background: var(--bg-elevated);
5974 border: 1px solid var(--border);
5975 border-radius: 9999px;
5976 padding: 3px;
5977 gap: 2px;
5978 }
5979 .search-mode-btn {
5980 display: inline-flex;
5981 align-items: center;
5982 gap: 5px;
5983 padding: 5px 12px;
5984 border-radius: 9999px;
5985 font-size: 12.5px;
5986 font-weight: 500;
5987 color: var(--text-muted);
5988 text-decoration: none;
5989 transition: color 120ms ease, background 120ms ease;
5990 cursor: pointer;
5991 border: none;
5992 background: none;
5993 font: inherit;
5994 }
5995 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5996 .search-mode-btn.active {
6fd5915Claude5997 background: rgba(91,110,232,0.15);
53c9249Claude5998 color: var(--text-strong);
5999 }
6000 .search-mode-ai-badge {
6001 display: inline-flex;
6002 align-items: center;
6003 gap: 4px;
6004 padding: 2px 8px;
6005 border-radius: 9999px;
6006 font-size: 11px;
6007 font-weight: 600;
6fd5915Claude6008 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude6009 color: #c4b5fd;
6fd5915Claude6010 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude6011 margin-left: 2px;
6012 }
6013 .search-quota-warn {
6014 font-size: 12.5px;
6015 color: var(--text-muted);
6016 padding: 6px 12px;
6017 background: rgba(255,200,0,0.06);
6018 border: 1px solid rgba(255,200,0,0.20);
6019 border-radius: 8px;
6020 }
6021
6022 /* ─── Semantic result cards ─── */
6023 .sem-results { display: flex; flex-direction: column; gap: 10px; }
6024 .sem-result {
6025 padding: 14px 16px;
6026 background: var(--bg-elevated);
6027 border: 1px solid var(--border);
6028 border-radius: 12px;
6029 transition: border-color 120ms ease;
6030 }
6031 .sem-result:hover { border-color: var(--border-strong); }
6032 .sem-result-head {
6033 display: flex;
6034 align-items: flex-start;
6035 justify-content: space-between;
6036 gap: 10px;
6037 flex-wrap: wrap;
6038 margin-bottom: 6px;
6039 }
6040 .sem-result-path {
6041 font-family: var(--font-mono);
6042 font-size: 13px;
6043 font-weight: 600;
6044 color: var(--text-strong);
6045 text-decoration: none;
6046 word-break: break-all;
6047 }
6048 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6049 .sem-result-conf {
6050 display: inline-flex;
6051 align-items: center;
6052 gap: 4px;
6053 padding: 2px 9px;
6054 border-radius: 9999px;
6055 font-size: 11.5px;
6056 font-weight: 600;
6057 white-space: nowrap;
6058 flex-shrink: 0;
6059 }
e589f77ccantynz-alt6060 .sem-conf-strong { background: rgba(52,211,153,0.12); color: var(--green); border: 1px solid rgba(52,211,153,0.30); }
6061 .sem-conf-possible { background: rgba(91,110,232,0.12); color: var(--accent); border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6062 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6063 .sem-result-reason {
6064 font-size: 12.5px;
6065 color: var(--text-muted);
6066 margin-bottom: 8px;
6067 line-height: 1.5;
6068 }
6069 .sem-result-snippet {
6070 margin: 0;
6071 padding: 10px 12px;
6072 background: rgba(0,0,0,0.22);
6073 border: 1px solid var(--border);
6074 border-radius: 8px;
6075 font-family: var(--font-mono);
6076 font-size: 12px;
6077 line-height: 1.55;
6078 color: var(--text);
6079 overflow-x: auto;
6080 white-space: pre-wrap;
6081 word-break: break-word;
6082 max-height: 240px;
6083 overflow-y: auto;
6084 }
6085 `;
6086
6087 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6088 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6089
79136bbClaude6090 return c.html(
6091 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6092 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6093 <RepoHeader owner={owner} repo={repo} />
6094 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6095 <div class="search-hero">
6096 <div class="search-eyebrow">
6097 <strong>Search</strong> · {owner}/{repo}
6098 </div>
6099 <h1 class="search-title">
53c9249Claude6100 {isSemantic ? (
6101 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6102 ) : (
6103 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6104 )}
efb11c5Claude6105 </h1>
6106 <form
6107 method="get"
6108 action={`/${owner}/${repo}/search`}
6109 class="search-form"
6110 role="search"
6111 >
53c9249Claude6112 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6113 <div class="search-input-wrap">
6114 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6115 <input
6116 type="text"
6117 name="q"
6118 value={q}
53c9249Claude6119 placeholder={
6120 isSemantic
6121 ? "Ask a question or describe what you're looking for…"
6122 : "Search code on the default branch…"
6123 }
efb11c5Claude6124 aria-label="Search code"
6125 class="search-input"
6126 autocomplete="off"
6127 autofocus
6128 />
6129 </div>
6130 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6131 Search
6132 </button>
efb11c5Claude6133 </form>
6134 </div>
53c9249Claude6135
6136 {/* Mode toggle */}
6137 <div class="search-mode-bar">
6138 <span class="search-mode-label">Mode:</span>
6139 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6140 {aiAvailable ? (
6141 <a
6142 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6143 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6144 aria-pressed={isSemantic ? "true" : "false"}
6145 >
6146 {"✨"} Semantic AI
6147 <span class="search-mode-ai-badge">AI-powered</span>
6148 </a>
6149 ) : null}
6150 <a
6151 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6152 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6153 aria-pressed={!isSemantic ? "true" : "false"}
6154 >
6155 {"⌕"} Keyword
6156 </a>
6157 </div>
6158 {isSemantic && aiAvailable && (
6159 <span style="font-size:12px;color:var(--text-muted)">
6160 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6161 </span>
53c9249Claude6162 )}
6163 {quotaExceeded && (
6164 <span class="search-quota-warn">
6165 Semantic search quota reached — showing keyword results
efb11c5Claude6166 </span>
53c9249Claude6167 )}
6168 </div>
6169
6170 {/* ─── Semantic results ─── */}
6171 {isSemantic && q && (
6172 <>
6173 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6174 <div class="search-quota-warn" style="margin-bottom:10px">
6175 Falling back to keyword search — Claude found no relevant files.
6176 </div>
6177 )}
6178 {semanticHits.length === 0 ? (
6179 <div class="search-empty">
6180 <p>
6181 No matches for <strong>"{q}"</strong>.{" "}
6182 Try a different phrasing or{" "}
6183 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6184 switch to keyword search
6185 </a>.
6186 </p>
6187 </div>
6188 ) : (
6189 <>
6190 <div class="search-results-head">
6191 <span class="search-results-count">
6192 <strong>{semanticHits.length}</strong> result
6193 {semanticHits.length !== 1 ? "s" : ""}
6194 </span>
6195 <span class="search-results-query">
6196 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6197 <span class="search-results-q">"{q}"</span>
6198 </span>
6199 </div>
6200 <div class="sem-results">
6201 {semanticHits.map((h) => {
6202 const confClass =
6203 h.confidence > 0.7
6204 ? "sem-conf-strong"
6205 : h.confidence > 0.4
6206 ? "sem-conf-possible"
6207 : "sem-conf-weak";
6208 const confLabel =
6209 h.confidence > 0.7
6210 ? "Strong match"
6211 : h.confidence > 0.4
6212 ? "Possible match"
6213 : "Weak match";
6214 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6215 const preview =
6216 h.snippet.length > 800
6217 ? h.snippet.slice(0, 800) + "\n…"
6218 : h.snippet;
6219 return (
6220 <div class="sem-result">
6221 <div class="sem-result-head">
6222 <a href={href} class="sem-result-path">
6223 {h.file}
6224 {h.lineNumber ? (
6225 <span style="color:var(--text-muted);font-weight:500">
6226 :{h.lineNumber}
6227 </span>
6228 ) : null}
6229 </a>
6230 <span class={`sem-result-conf ${confClass}`}>
6231 {confLabel}
6232 </span>
6233 </div>
6234 {h.reason && (
6235 <p class="sem-result-reason">{h.reason}</p>
6236 )}
6237 {preview && (
6238 <pre class="sem-result-snippet">{preview}</pre>
6239 )}
6240 </div>
6241 );
6242 })}
6243 </div>
6244 </>
6245 )}
6246 </>
79136bbClaude6247 )}
53c9249Claude6248
6249 {/* ─── Keyword results ─── */}
6250 {!isSemantic && q && (
6251 <>
6252 <div class="search-results-head">
6253 <span class="search-results-count">
6254 <strong>{keywordResults.length}</strong> result
6255 {keywordResults.length !== 1 ? "s" : ""}
6256 </span>
6257 <span class="search-results-query">
6258 for <span class="search-results-q">"{q}"</span> on{" "}
6259 <code>{defaultBranch}</code>
6260 </span>
6261 </div>
6262 {keywordResults.length === 0 ? (
6263 <div class="search-empty">
6264 <p>
6265 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6266 {aiAvailable && (
6267 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6268 try AI semantic search
79136bbClaude6269 </a>
53c9249Claude6270 )}.
6271 </p>
6272 </div>
6273 ) : (
6274 <div class="search-results">
6275 {(() => {
6276 const grouped: Record<
6277 string,
6278 Array<{ lineNum: number; line: string }>
6279 > = {};
6280 for (const r of keywordResults) {
6281 if (!grouped[r.file]) grouped[r.file] = [];
6282 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6283 }
6284 return Object.entries(grouped).map(([file, matches]) => (
6285 <div class="search-file diff-file">
6286 <div class="search-file-head diff-file-header">
6287 <a
6288 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6289 class="search-file-link"
6290 >
6291 {file}
6292 </a>
6293 <span class="search-file-count">
6294 {matches.length} match{matches.length === 1 ? "" : "es"}
6295 </span>
6296 </div>
6297 <div class="blob-code">
6298 <table>
6299 <tbody>
6300 {matches.map((m) => (
6301 <tr>
6302 <td class="line-num">{m.lineNum}</td>
6303 <td class="line-content">{m.line}</td>
6304 </tr>
6305 ))}
6306 </tbody>
6307 </table>
6308 </div>
6309 </div>
6310 ));
6311 })()}
6312 </div>
6313 )}
6314 </>
6315 )}
79136bbClaude6316 </Layout>
6317 );
6318});
6319
fc1817aClaude6320export default web;