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.tsxBlame6295 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 });
2226 }
06d5ffeClaude2227
2228 return c.redirect(`/${user.username}/${name}`);
2229});
2230
11c3ab6ccanty labs2231// Daily brief — GET /brief
2232web.get("/brief", (c) => {
2233 const user = c.get("user");
8ed88f2ccantynz-alt2234 return c.html(
2235 <DailyBrief
2236 user={user ? { name: user.displayName || user.username } : undefined}
2237 />
2238 );
11c3ab6ccanty labs2239});
2240
2241// Trust report — GET /trust (public)
2242web.get("/trust", (c) => {
2243 return c.html(<TrustReport />);
2244});
2245
2246// Production layers — GET /layers
2247web.get("/layers", (c) => {
2248 const user = c.get("user");
2249 return c.html(<ProductionLayers user={user} />);
2250});
2251
2252// Distribution — GET /distribute
2253web.get("/distribute", (c) => {
2254 const user = c.get("user");
2255 return c.html(<DistributionView user={user} />);
2256});
2257
06d5ffeClaude2258// User profile
fc1817aClaude2259web.get("/:owner", async (c) => {
06d5ffeClaude2260 const { owner: ownerName } = c.req.param();
2261 const user = c.get("user");
2262
2263 // Avoid clashing with fixed routes
2264 if (
2265 ["login", "register", "logout", "new", "settings", "api"].includes(
2266 ownerName
2267 )
2268 ) {
2269 return c.notFound();
2270 }
2271
2272 let ownerUser;
2273 try {
2274 const [found] = await db
2275 .select()
2276 .from(users)
2277 .where(eq(users.username, ownerName))
2278 .limit(1);
2279 ownerUser = found;
2280 } catch {
2281 // DB not available — check if repos exist on disk
2282 ownerUser = null;
2283 }
2284
2285 // Even without DB, show repos if they exist on disk
2286 let repos: any[] = [];
2287 if (ownerUser) {
2288 const allRepos = await db
2289 .select()
2290 .from(repositories)
2291 .where(eq(repositories.ownerId, ownerUser.id))
2292 .orderBy(desc(repositories.updatedAt));
2293
2294 // Show public repos to everyone, private only to owner
2295 repos =
2296 user?.id === ownerUser.id
2297 ? allRepos
2298 : allRepos.filter((r) => !r.isPrivate);
2299 }
2300
7aa8b99Claude2301 // Block J4 — follow counts + viewer's follow state
2302 let followState = {
2303 followers: 0,
2304 following: 0,
2305 viewerFollows: false,
2306 };
2307 if (ownerUser) {
2308 try {
2309 const { followCounts, isFollowing } = await import("../lib/follows");
2310 const counts = await followCounts(ownerUser.id);
2311 followState.followers = counts.followers;
2312 followState.following = counts.following;
2313 if (user && user.id !== ownerUser.id) {
2314 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2315 }
2316 } catch {
2317 // DB hiccup — fall back to zeros.
2318 }
2319 }
2320 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2321
d412586Claude2322 // Block J5 — profile README. Render owner/owner repo's README on the
2323 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2324 // back to "<user>/.github" for org-style profile repos.
2325 let profileReadmeHtml: string | null = null;
2326 try {
2327 const candidates = [ownerName, ".github"];
2328 for (const rname of candidates) {
2329 if (await repoExists(ownerName, rname)) {
2330 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2331 const md = await getReadme(ownerName, rname, ref);
2332 if (md) {
2333 profileReadmeHtml = renderMarkdown(md);
2334 break;
2335 }
2336 }
2337 }
2338 } catch {
2339 profileReadmeHtml = null;
2340 }
2341
efb11c5Claude2342 const displayName = ownerUser?.displayName || ownerName;
2343 const memberSince = ownerUser?.createdAt
2344 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2345 : null;
2346
fc1817aClaude2347 return c.html(
06d5ffeClaude2348 <Layout title={ownerName} user={user}>
efb11c5Claude2349 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2350 <div class="profile-hero">
2351 <div class="profile-hero-orb-wrap" aria-hidden="true">
2352 <div class="profile-hero-orb" />
06d5ffeClaude2353 </div>
efb11c5Claude2354 <div class="profile-hero-inner">
2355 <div class="profile-hero-avatar" aria-hidden="true">
2356 {displayName[0].toUpperCase()}
2357 </div>
2358 <div class="profile-hero-text">
2359 <div class="profile-eyebrow">
2360 <strong>Developer</strong>
2361 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2362 <>
2363 {" "}· Joined{" "}
2364 {memberSince.toLocaleDateString("en-US", {
2365 month: "short",
2366 year: "numeric",
2367 })}
2368 </>
2369 )}
2370 </div>
2371 <h1 class="profile-name">
2372 <span class="gradient-text">{displayName}</span>
2373 </h1>
2374 <div class="profile-handle">@{ownerName}</div>
2375 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2376 <div class="profile-meta">
2377 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2378 <strong>{followState.followers}</strong> follower
2379 {followState.followers === 1 ? "" : "s"}
2380 </a>
2381 <a href={`/${ownerName}/following`} class="profile-meta-link">
2382 <strong>{followState.following}</strong> following
2383 </a>
2384 <a href={`/${ownerName}`} class="profile-meta-link">
2385 <strong>{repos.length}</strong> repo
2386 {repos.length === 1 ? "" : "s"}
2387 </a>
2388 {canFollow && (
2389 <form
2390 method="post"
2391 action={`/${ownerName}/${
2392 followState.viewerFollows ? "unfollow" : "follow"
2393 }`}
2394 class="profile-follow-form"
7aa8b99Claude2395 >
efb11c5Claude2396 <button
2397 type="submit"
2398 class={`btn ${
2399 followState.viewerFollows ? "" : "btn-primary"
2400 } btn-sm`}
2401 >
2402 {followState.viewerFollows ? "Unfollow" : "Follow"}
2403 </button>
2404 </form>
2405 )}
2406 </div>
7aa8b99Claude2407 </div>
06d5ffeClaude2408 </div>
2409 </div>
d412586Claude2410 {profileReadmeHtml && (
efb11c5Claude2411 <div class="profile-readme">
2412 <div class="profile-readme-head">
2413 <span class="profile-readme-icon">{"☰"}</span>
2414 <span>{ownerName}/{ownerName} README.md</span>
2415 </div>
2416 <div
2417 class="markdown-body profile-readme-body"
2418 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2419 />
2420 </div>
d412586Claude2421 )}
efb11c5Claude2422 <div class="profile-section-head">
2423 <h2 class="profile-section-title">Repositories</h2>
2424 <span class="profile-section-count">{repos.length}</span>
2425 </div>
06d5ffeClaude2426 {repos.length === 0 ? (
efb11c5Claude2427 <div class="profile-empty">
2428 <p class="profile-empty-text">
2429 No repositories yet
2430 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2431 </p>
2432 {user?.id === ownerUser?.id && (
2433 <a href="/new" class="btn btn-primary btn-sm">
2434 + Create your first
2435 </a>
2436 )}
2437 </div>
06d5ffeClaude2438 ) : (
2439 <div class="card-grid">
2440 {repos.map((repo) => (
2441 <RepoCard repo={repo} ownerName={ownerName} />
2442 ))}
2443 </div>
2444 )}
fc1817aClaude2445 </Layout>
2446 );
2447});
2448
06d5ffeClaude2449// Star/unstar a repo
2450web.post("/:owner/:repo/star", requireAuth, async (c) => {
2451 const { owner: ownerName, repo: repoName } = c.req.param();
2452 const user = c.get("user")!;
2453
2454 try {
2455 const [ownerUser] = await db
2456 .select()
2457 .from(users)
2458 .where(eq(users.username, ownerName))
2459 .limit(1);
2460 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2461
2462 const [repo] = await db
2463 .select()
2464 .from(repositories)
2465 .where(
2466 and(
2467 eq(repositories.ownerId, ownerUser.id),
2468 eq(repositories.name, repoName)
2469 )
2470 )
2471 .limit(1);
2472 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2473
2474 // Toggle star
2475 const [existing] = await db
2476 .select()
2477 .from(stars)
2478 .where(
2479 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2480 )
2481 .limit(1);
2482
2483 if (existing) {
2484 await db.delete(stars).where(eq(stars.id, existing.id));
2485 await db
2486 .update(repositories)
2487 .set({ starCount: Math.max(0, repo.starCount - 1) })
2488 .where(eq(repositories.id, repo.id));
2489 } else {
2490 await db.insert(stars).values({
2491 userId: user.id,
2492 repositoryId: repo.id,
2493 });
2494 await db
2495 .update(repositories)
2496 .set({ starCount: repo.starCount + 1 })
2497 .where(eq(repositories.id, repo.id));
a74f4edccanty labs2498 void fireWebhooks(repo.id, "star", { action: "created" });
06d5ffeClaude2499 }
2500 } catch {
2501 // DB error — ignore
2502 }
2503
2504 return c.redirect(`/${ownerName}/${repoName}`);
2505});
2506
641aa42Claude2507// ---------------------------------------------------------------------------
2508// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2509// ---------------------------------------------------------------------------
2510const obCss = `
2511 .ob-card {
2512 position: relative;
2513 margin: 14px 0 18px;
6fd5915Claude2514 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2515 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2516 border-radius: 14px;
2517 overflow: hidden;
2518 }
2519 .ob-card::before {
2520 content: '';
2521 position: absolute;
2522 top: 0; left: 0; right: 0;
2523 height: 2px;
6fd5915Claude2524 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2525 pointer-events: none;
2526 }
2527 .ob-header {
2528 padding: 16px 20px 10px;
2529 border-bottom: 1px solid var(--border);
2530 }
2531 .ob-header h3 {
2532 font-size: 16px;
2533 font-weight: 700;
2534 margin: 0 0 4px;
2535 color: var(--text-strong);
2536 }
2537 .ob-header p {
2538 font-size: 13px;
2539 color: var(--text-muted);
2540 margin: 0;
2541 }
2542 .ob-sections {
2543 display: grid;
2544 grid-template-columns: repeat(3, 1fr);
2545 gap: 0;
2546 }
2547 @media (max-width: 760px) {
2548 .ob-sections { grid-template-columns: 1fr; }
2549 }
2550 .ob-section {
2551 padding: 14px 20px;
2552 border-right: 1px solid var(--border);
2553 }
2554 .ob-section:last-child { border-right: none; }
2555 .ob-section h4 {
2556 font-size: 12px;
2557 font-weight: 700;
2558 text-transform: uppercase;
2559 letter-spacing: 0.08em;
2560 color: var(--accent);
2561 margin: 0 0 8px;
2562 }
2563 .ob-preview {
2564 font-family: var(--font-mono);
2565 font-size: 11.5px;
2566 background: var(--bg);
2567 border: 1px solid var(--border);
2568 border-radius: 6px;
2569 padding: 8px 10px;
2570 color: var(--text-muted);
2571 line-height: 1.55;
2572 margin-bottom: 8px;
2573 white-space: pre-wrap;
2574 overflow: hidden;
2575 max-height: 80px;
2576 position: relative;
2577 }
2578 .ob-preview::after {
2579 content: '';
2580 position: absolute;
2581 bottom: 0; left: 0; right: 0;
2582 height: 24px;
2583 background: linear-gradient(transparent, var(--bg));
2584 pointer-events: none;
2585 }
2586 .ob-labels {
2587 display: flex;
2588 flex-wrap: wrap;
2589 gap: 5px;
2590 margin-bottom: 8px;
2591 }
2592 .ob-label-chip {
2593 display: inline-flex;
2594 align-items: center;
2595 padding: 2px 8px;
2596 border-radius: 9999px;
2597 font-size: 11.5px;
2598 font-weight: 600;
2599 line-height: 1.5;
2600 border: 1px solid transparent;
2601 }
2602 .ob-section ul {
2603 margin: 0;
2604 padding: 0 0 0 16px;
2605 font-size: 13px;
2606 color: var(--text-muted);
2607 line-height: 1.7;
2608 }
2609 .ob-section ul li { margin-bottom: 2px; }
2610 .ob-footer {
2611 display: flex;
2612 align-items: center;
2613 justify-content: flex-end;
2614 padding: 10px 20px;
2615 border-top: 1px solid var(--border);
2616 gap: 10px;
2617 }
2618 .ob-dismiss {
2619 appearance: none;
2620 background: transparent;
2621 border: 1px solid var(--border);
2622 color: var(--text-muted);
2623 border-radius: 6px;
2624 padding: 5px 12px;
2625 font-size: 12.5px;
2626 font-family: inherit;
2627 cursor: pointer;
2628 transition: background var(--t-fast), color var(--t-fast);
2629 }
2630 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2631 .btn-sm {
2632 appearance: none;
2633 background: var(--bg-elevated);
2634 border: 1px solid var(--border);
2635 color: var(--text-strong);
2636 border-radius: 6px;
2637 padding: 5px 12px;
2638 font-size: 12.5px;
2639 font-weight: 600;
2640 font-family: inherit;
2641 cursor: pointer;
2642 text-decoration: none;
2643 display: inline-flex;
2644 align-items: center;
2645 transition: background var(--t-fast);
2646 }
2647 .btn-sm:hover { background: var(--bg-hover); }
2648 .btn-sm.btn-primary {
2649 background: var(--accent);
2650 color: #fff;
2651 border-color: var(--accent);
2652 }
2653 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2654`;
2655
2656// Onboarding card component — shown to repo owner until dismissed
2657function RepoOnboardingCard({
2658 owner,
2659 repo,
2660 data,
2661}: {
2662 owner: string;
2663 repo: string;
2664 data: typeof repoOnboardingData.$inferSelect;
2665}) {
2666 const labels = (data.suggestedLabels ?? []) as Array<{
2667 name: string;
2668 color: string;
2669 description: string;
2670 }>;
2671 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2672 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2673
2674 return (
2675 <>
2676 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2677 <div class="ob-card" id="repo-onboarding">
2678 <div class="ob-header">
2679 <h3>Get started with {owner}/{repo}</h3>
2680 <p>
2681 Detected: {data.detectedLanguage ?? "Unknown"}
2682 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2683 Here&rsquo;s what we suggest to hit the ground running.
2684 </p>
2685 </div>
2686 <div class="ob-sections">
2687 <div class="ob-section">
2688 <h4>Suggested README</h4>
2689 <div class="ob-preview">{readmePreview}</div>
2690 <button
2691 class="btn-sm"
2692 type="button"
2693 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(_){};})()`}
2694 aria-label="Copy suggested README to clipboard"
2695 >
2696 Copy to clipboard
2697 </button>
2698 </div>
2699 <div class="ob-section">
2700 <h4>Suggested labels ({labels.length})</h4>
2701 <div class="ob-labels">
2702 {labels.slice(0, 6).map((l) => (
2703 <span
2704 class="ob-label-chip"
2705 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2706 title={l.description}
2707 >
2708 {l.name}
2709 </span>
2710 ))}
2711 </div>
2712 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2713 <button class="btn-sm btn-primary" type="submit">
2714 Create all labels
2715 </button>
2716 </form>
2717 </div>
2718 <div class="ob-section">
2719 <h4>First steps</h4>
2720 <ul>
2721 {suggestions.map((s) => (
2722 <li>{s}</li>
2723 ))}
2724 </ul>
2725 </div>
2726 </div>
2727 <div class="ob-footer">
2728 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2729 <button class="ob-dismiss" type="submit">
2730 Dismiss
2731 </button>
2732 </form>
2733 </div>
2734 <script
2735 dangerouslySetInnerHTML={{
2736 __html: `
2737 (function(){
2738 var card = document.getElementById('repo-onboarding');
2739 if (!card) return;
2740 document.addEventListener('keydown', function(e){
2741 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2742 card.style.display = 'none';
2743 }
2744 });
2745 })();
2746 `,
2747 }}
2748 />
2749 </div>
2750 </>
2751 );
2752}
2753
2754// ---------------------------------------------------------------------------
2755// Setup routes — create suggested labels + dismiss onboarding
2756// ---------------------------------------------------------------------------
2757
2758// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2759// requireAuth + write access. Idempotent (label UPSERT by name).
2760web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2761 const { owner, repo } = c.req.param();
2762 const user = c.get("user")!;
2763
2764 // Resolve repo + verify write access
2765 let repoRow: { id: string; ownerId: string } | null = null;
2766 try {
2767 const [ownerUser] = await db
2768 .select({ id: users.id })
2769 .from(users)
2770 .where(eq(users.username, owner))
2771 .limit(1);
2772 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2773 const [r] = await db
2774 .select({ id: repositories.id, ownerId: repositories.ownerId })
2775 .from(repositories)
2776 .where(
2777 and(
2778 eq(repositories.ownerId, ownerUser.id),
2779 eq(repositories.name, repo)
2780 )
2781 )
2782 .limit(1);
2783 repoRow = r ?? null;
2784 } catch {
2785 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2786 }
2787 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2788 if (repoRow.ownerId !== user.id) {
2789 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2790 }
2791
2792 // Fetch the onboarding data
2793 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2794 try {
2795 const [r] = await db
2796 .select()
2797 .from(repoOnboardingData)
2798 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2799 .limit(1);
2800 obRow = r ?? null;
2801 } catch {
2802 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2803 }
2804 if (!obRow) {
2805 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2806 }
2807
2808 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2809 name: string;
2810 color: string;
2811 description: string;
2812 }>;
2813
2814 // Create labels — import the labels table which was already imported
2815 let created = 0;
2816 for (const l of suggestedLabels) {
2817 try {
2818 await db
2819 .insert(labels)
2820 .values({
2821 repositoryId: repoRow.id,
2822 name: l.name,
2823 color: l.color,
2824 description: l.description ?? "",
2825 })
2826 .onConflictDoNothing();
2827 created++;
2828 } catch {
2829 /* skip duplicates */
2830 }
2831 }
2832
2833 // Mark onboarding dismissed
2834 try {
2835 await db
2836 .update(repositories)
2837 .set({ onboardingShown: true })
2838 .where(eq(repositories.id, repoRow.id));
2839 } catch {
2840 /* ignore */
2841 }
2842
2843 return c.redirect(
2844 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2845 );
2846});
2847
2848// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2849web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2850 const { owner, repo } = c.req.param();
2851 const user = c.get("user")!;
2852
2853 try {
2854 const [ownerUser] = await db
2855 .select({ id: users.id })
2856 .from(users)
2857 .where(eq(users.username, owner))
2858 .limit(1);
2859 if (ownerUser) {
2860 const [r] = await db
2861 .select({ id: repositories.id, ownerId: repositories.ownerId })
2862 .from(repositories)
2863 .where(
2864 and(
2865 eq(repositories.ownerId, ownerUser.id),
2866 eq(repositories.name, repo)
2867 )
2868 )
2869 .limit(1);
2870 if (r && r.ownerId === user.id) {
2871 await db
2872 .update(repositories)
2873 .set({ onboardingShown: true })
2874 .where(eq(repositories.id, r.id));
2875 }
2876 }
2877 } catch {
2878 /* swallow — dismiss should never fail visibly */
2879 }
2880
2881 return c.redirect(`/${owner}/${repo}`);
2882});
2883
11c3ab6ccanty labs2884// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2885web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2886 const { owner, repo } = c.req.param();
2887 const user = c.get("user");
5bb52faccanty labs2888 const gate = await assertRepoReadable(c, owner, repo);
2889 if (gate) return gate;
11c3ab6ccanty labs2890 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2891});
2892
2893// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2894web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2895 const { owner, repo } = c.req.param();
2896 const user = c.get("user");
5bb52faccanty labs2897 const gate = await assertRepoReadable(c, owner, repo);
2898 if (gate) return gate;
11c3ab6ccanty labs2899 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2900});
2901
fc1817aClaude2902// Repository overview — file tree at HEAD
2903web.get("/:owner/:repo", async (c) => {
2904 const { owner, repo } = c.req.param();
06d5ffeClaude2905 const user = c.get("user");
fc1817aClaude2906
5bb52faccanty labs2907 // SECURITY: gate private-repo content before any render (incl. skeleton).
2908 const gate = await assertRepoReadable(c, owner, repo);
2909 if (gate) return gate;
2910
f1dc7c7Claude2911 // ── Loading skeleton (flag-gated) ──
2912 // Renders an SSR'd shell with file-tree + README placeholders when
2913 // `?skeleton=1` is set. Lets the user see the page structure before
2914 // git ops finish. Behind a flag for now so we never flash before the
2915 // real content lands.
2916 if (c.req.query("skeleton") === "1") {
2917 return c.html(
2918 <Layout title={`${owner}/${repo}`} user={user}>
2919 <style
2920 dangerouslySetInnerHTML={{
2921 __html: `
2922 .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; }
2923 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2924 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2925 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2926 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2927 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2928 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2929 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2930 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2931 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2932 .repo-skel-readme { height: 320px; border-radius: 12px; }
2933 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2934 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2935 `,
2936 }}
2937 />
2938 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2939 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2940 <div class="repo-skel-grid" aria-hidden="true">
2941 <div>
2942 <div class="repo-skel repo-skel-branch" />
2943 <div class="repo-skel-tree">
2944 {Array.from({ length: 8 }).map(() => (
2945 <div class="repo-skel repo-skel-tree-row" />
2946 ))}
2947 </div>
2948 <div class="repo-skel repo-skel-readme" />
2949 </div>
2950 <aside class="repo-skel-side">
2951 <div class="repo-skel repo-skel-side-card" />
2952 <div class="repo-skel repo-skel-side-card" />
2953 </aside>
2954 </div>
2955 <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">
2956 Loading {owner}/{repo}…
2957 </span>
2958 </Layout>
2959 );
2960 }
2961
8f50ed0Claude2962 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2963 trackByName(owner, repo, "view", {
2964 userId: user?.id || null,
2965 path: `/${owner}/${repo}`,
2966 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2967 userAgent: c.req.header("user-agent") || null,
2968 referer: c.req.header("referer") || null,
a28cedeClaude2969 }).catch((err) => {
2970 console.warn(
2971 `[web] view tracking failed for ${owner}/${repo}:`,
2972 err instanceof Error ? err.message : err
2973 );
2974 });
8f50ed0Claude2975
fc1817aClaude2976 if (!(await repoExists(owner, repo))) {
2977 return c.html(
06d5ffeClaude2978 <Layout title="Not Found" user={user}>
fc1817aClaude2979 <div class="empty-state">
2980 <h2>Repository not found</h2>
2981 <p>
2982 {owner}/{repo} does not exist.
2983 </p>
2984 </div>
2985 </Layout>,
2986 404
2987 );
2988 }
2989
05b973eClaude2990 // Parallelize all independent operations
2991 const [defaultBranch, branches] = await Promise.all([
2992 getDefaultBranch(owner, repo).then((b) => b || "main"),
2993 listBranches(owner, repo),
2994 ]);
2995 const [tree, starInfo] = await Promise.all([
2996 getTree(owner, repo, defaultBranch),
2997 // Star info fetched in parallel with tree
2998 (async () => {
2999 try {
28b52beccantynz-alt3000 // assertRepoReadable already resolved (and access-checked) both rows
3001 // for this request — reuse them instead of repeating the two lookups.
3002 const resolved = c.get("resolvedRepoCtx") as
3003 | { repoRow: typeof repositories.$inferSelect }
3004 | undefined;
3005 const repoRow = resolved?.repoRow;
71cd5ecClaude3006 if (!repoRow)
3007 return {
3008 starCount: 0,
3009 starred: false,
3010 archived: false,
3011 isTemplate: false,
544d842Claude3012 forkCount: 0,
3013 description: null as string | null,
3014 pushedAt: null as Date | null,
3015 createdAt: null as Date | null,
cb5a796Claude3016 repoId: null as string | null,
3017 repoOwnerId: null as string | null,
71cd5ecClaude3018 };
05b973eClaude3019 let starred = false;
06d5ffeClaude3020 if (user) {
3021 const [star] = await db
3022 .select()
3023 .from(stars)
3024 .where(
3025 and(
3026 eq(stars.userId, user.id),
3027 eq(stars.repositoryId, repoRow.id)
3028 )
3029 )
3030 .limit(1);
3031 starred = !!star;
3032 }
71cd5ecClaude3033 return {
3034 starCount: repoRow.starCount,
3035 starred,
3036 archived: repoRow.isArchived,
3037 isTemplate: repoRow.isTemplate,
544d842Claude3038 forkCount: repoRow.forkCount,
3039 description: repoRow.description as string | null,
3040 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3041 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3042 repoId: repoRow.id as string,
3043 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3044 };
05b973eClaude3045 } catch {
71cd5ecClaude3046 return {
3047 starCount: 0,
3048 starred: false,
3049 archived: false,
3050 isTemplate: false,
544d842Claude3051 forkCount: 0,
3052 description: null as string | null,
3053 pushedAt: null as Date | null,
3054 createdAt: null as Date | null,
cb5a796Claude3055 repoId: null as string | null,
3056 repoOwnerId: null as string | null,
71cd5ecClaude3057 };
06d5ffeClaude3058 }
05b973eClaude3059 })(),
3060 ]);
544d842Claude3061 const {
3062 starCount,
3063 starred,
3064 archived,
3065 isTemplate,
3066 forkCount,
3067 description,
3068 pushedAt,
3069 createdAt,
cb5a796Claude3070 repoId,
3071 repoOwnerId,
544d842Claude3072 } = starInfo;
3073
91a0204Claude3074 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3075 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3076 // and the badge simply doesn't render.
3077 let healthScore: HealthScore | null = null;
3078 if (repoId) {
3079 try {
3080 healthScore = await computeHealthScore(repoId);
3081 } catch {
3082 // swallow — badge is optional
3083 }
3084 }
3085
cb5a796Claude3086 // Pending-comments banner data (lazy + best-effort). Only the repo
3087 // owner sees the banner, so non-owner views skip the DB hit entirely.
3088 let repoHomePendingCount = 0;
3089 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3090 try {
3091 const { countPendingForRepo } = await import(
3092 "../lib/comment-moderation"
3093 );
3094 repoHomePendingCount = await countPendingForRepo(repoId);
3095 } catch {
3096 /* swallow */
3097 }
3098 }
3099
8c790e0Claude3100 // Push Watch discoverability — fetch the most recent push within 24 h.
3101 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3102 const recentPush: RecentPush | null = repoId
3103 ? await getRecentPush(repoId)
3104 : null;
3105
ebaae0fClaude3106 // AI stats strip — best-effort, fail-open to zero values.
3107 let aiStats: RepoAiStats = {
3108 mergedCount: 0,
3109 reviewCount: 0,
3110 securityAlertCount: 0,
3111 hoursSaved: "0.0",
3112 };
3113 if (repoId) {
3114 try {
3115 aiStats = await getRepoAiStats(repoId);
3116 } catch {
3117 /* swallow — show zero values */
3118 }
3119 }
3120
641aa42Claude3121 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3122 // if the DB is down the card simply won't appear. Only the owner sees it.
3123 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3124 let showOnboarding = false;
8102dd4ccantynz-alt3125 // The card is an EMPTY-REPO nudge ("add a README, add a .gitignore"). It must
3126 // only appear while the repo actually is empty — otherwise a populated repo
3127 // (files + branches pushed) keeps rendering "Get started" above the fold
3128 // forever, because `onboarding_shown` only flips on an explicit dismiss.
3129 const repoLooksEmpty = tree.length === 0 && branches.length === 0;
3130 if (repoLooksEmpty && repoId && user && repoOwnerId && user.id === repoOwnerId) {
641aa42Claude3131 try {
28b52beccantynz-alt3132 // onboarding_shown comes from the row assertRepoReadable already
3133 // resolved — no need for a third lookup of the same repository.
3134 const resolvedForOnboarding = c.get("resolvedRepoCtx") as
3135 | { repoRow: typeof repositories.$inferSelect }
3136 | undefined;
3137 const repoRow2 = resolvedForOnboarding?.repoRow;
641aa42Claude3138 if (repoRow2 && !repoRow2.onboardingShown) {
3139 const [obRow] = await db
3140 .select()
3141 .from(repoOnboardingData)
3142 .where(eq(repoOnboardingData.repositoryId, repoId))
3143 .limit(1);
3144 onboardingRow = obRow ?? null;
3145 showOnboarding = !!onboardingRow;
3146 }
3147 } catch {
3148 /* swallow — onboarding is optional */
3149 }
3150 }
3151
544d842Claude3152 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3153 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3154 const repoHomeCss = `
3155 .repo-home-hero {
3156 position: relative;
3157 margin-bottom: var(--space-5);
3158 padding: var(--space-5) var(--space-6);
3159 background: var(--bg-elevated);
3160 border: 1px solid var(--border);
3161 border-radius: 16px;
3162 overflow: hidden;
3163 }
3164 .repo-home-hero::before {
3165 content: '';
3166 position: absolute;
3167 top: 0; left: 0; right: 0;
3168 height: 2px;
6fd5915Claude3169 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3170 opacity: 0.7;
3171 pointer-events: none;
3172 }
3173 .repo-home-hero-orb-wrap {
3174 position: absolute;
3175 inset: -25% -10% auto auto;
3176 width: 360px;
3177 height: 360px;
3178 pointer-events: none;
3179 z-index: 0;
3180 }
3181 .repo-home-hero-orb {
3182 position: absolute;
3183 inset: 0;
6fd5915Claude3184 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3185 filter: blur(80px);
3186 opacity: 0.7;
3187 animation: repoHomeOrb 14s ease-in-out infinite;
3188 }
3189 @keyframes repoHomeOrb {
3190 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3191 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3192 }
3193 @media (prefers-reduced-motion: reduce) {
3194 .repo-home-hero-orb { animation: none; }
3195 }
3196 .repo-home-hero-inner {
3197 position: relative;
3198 z-index: 1;
3199 }
3200 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3201 .repo-home-eyebrow {
3202 font-size: 12px;
3203 font-family: var(--font-mono);
3204 color: var(--text-muted);
3205 letter-spacing: 0.1em;
3206 text-transform: uppercase;
3207 margin-bottom: var(--space-2);
3208 }
3209 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3210 .repo-home-description {
3211 font-size: 15px;
3212 line-height: 1.55;
3213 color: var(--text);
3214 margin: 0;
3215 max-width: 720px;
3216 }
3217 .repo-home-description-empty {
3218 font-size: 14px;
3219 color: var(--text-muted);
3220 font-style: italic;
3221 margin: 0;
3222 }
3223 .repo-home-stat-row {
3224 display: flex;
3225 flex-wrap: wrap;
3226 gap: var(--space-4);
3227 margin-top: var(--space-3);
3228 font-size: 13px;
3229 color: var(--text-muted);
3230 }
3231 .repo-home-stat {
3232 display: inline-flex;
3233 align-items: center;
3234 gap: 6px;
3235 }
3236 .repo-home-stat strong {
3237 color: var(--text-strong);
3238 font-weight: 600;
3239 font-variant-numeric: tabular-nums;
3240 }
3241 .repo-home-stat .repo-home-stat-icon {
3242 color: var(--text-faint);
3243 font-size: 14px;
3244 line-height: 1;
3245 }
3246 .repo-home-stat a {
3247 color: var(--text-muted);
3248 transition: color var(--t-fast) var(--ease);
3249 }
3250 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3251
3252 /* Two-column layout: file tree + sidebar */
3253 .repo-home-grid {
3254 display: grid;
3255 grid-template-columns: minmax(0, 1fr) 280px;
3256 gap: var(--space-5);
3257 align-items: start;
3258 }
3259 @media (max-width: 960px) {
3260 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3261 }
3262 .repo-home-main { min-width: 0; }
3263
3264 /* Sidebar card */
3265 .repo-home-side {
3266 display: flex;
3267 flex-direction: column;
3268 gap: var(--space-4);
3269 }
3270 .repo-home-side-card {
3271 background: var(--bg-elevated);
3272 border: 1px solid var(--border);
3273 border-radius: 12px;
3274 padding: var(--space-4);
3275 }
3276 .repo-home-side-title {
3277 font-size: 11px;
3278 font-family: var(--font-mono);
3279 letter-spacing: 0.12em;
3280 text-transform: uppercase;
3281 color: var(--text-muted);
3282 margin: 0 0 var(--space-3);
3283 font-weight: 600;
3284 }
3285 .repo-home-side-row {
3286 display: flex;
3287 justify-content: space-between;
3288 align-items: center;
3289 gap: var(--space-2);
3290 font-size: 13px;
3291 padding: 6px 0;
3292 border-top: 1px solid var(--border);
3293 }
3294 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3295 .repo-home-side-key {
3296 color: var(--text-muted);
3297 display: inline-flex;
3298 align-items: center;
3299 gap: 6px;
3300 }
3301 .repo-home-side-val {
3302 color: var(--text-strong);
3303 font-weight: 500;
3304 font-variant-numeric: tabular-nums;
3305 max-width: 60%;
3306 text-align: right;
3307 overflow: hidden;
3308 text-overflow: ellipsis;
3309 white-space: nowrap;
3310 }
3311 .repo-home-side-val a { color: var(--text-strong); }
3312 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3313
3314 /* Clone / Code tabs */
3315 .repo-home-clone {
3316 background: var(--bg-elevated);
3317 border: 1px solid var(--border);
3318 border-radius: 12px;
3319 overflow: hidden;
3320 }
3321 .repo-home-clone-tabs {
3322 display: flex;
3323 gap: 0;
3324 background: var(--bg-secondary);
3325 border-bottom: 1px solid var(--border);
3326 padding: 0 var(--space-2);
3327 }
3328 .repo-home-clone-tab {
3329 appearance: none;
3330 background: transparent;
3331 border: 0;
3332 border-bottom: 2px solid transparent;
3333 padding: 9px 12px;
3334 font-size: 12px;
3335 font-weight: 500;
3336 color: var(--text-muted);
3337 cursor: pointer;
3338 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3339 font-family: inherit;
3340 margin-bottom: -1px;
3341 }
3342 .repo-home-clone-tab:hover { color: var(--text-strong); }
3343 .repo-home-clone-tab[aria-selected="true"] {
3344 color: var(--text-strong);
3345 border-bottom-color: var(--accent);
3346 }
3347 .repo-home-clone-body {
3348 padding: var(--space-3);
3349 display: flex;
3350 align-items: center;
3351 gap: var(--space-2);
3352 }
3353 .repo-home-clone-input {
3354 flex: 1;
3355 min-width: 0;
3356 font-family: var(--font-mono);
3357 font-size: 12px;
3358 background: var(--bg);
3359 border: 1px solid var(--border);
3360 border-radius: 8px;
3361 padding: 8px 10px;
3362 color: var(--text-strong);
3363 overflow: hidden;
3364 text-overflow: ellipsis;
3365 white-space: nowrap;
3366 }
3367 .repo-home-clone-copy {
3368 appearance: none;
3369 background: var(--bg-secondary);
3370 border: 1px solid var(--border);
3371 color: var(--text-strong);
3372 border-radius: 8px;
3373 padding: 8px 12px;
3374 font-size: 12px;
3375 font-weight: 600;
3376 cursor: pointer;
3377 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3378 font-family: inherit;
3379 }
3380 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3381 .repo-home-clone-pane { display: none; }
3382 .repo-home-clone-pane[data-active="true"] { display: flex; }
3383
3384 /* README card */
3385 .repo-home-readme {
3386 margin-top: var(--space-5);
3387 background: var(--bg-elevated);
3388 border: 1px solid var(--border);
3389 border-radius: 12px;
3390 overflow: hidden;
3391 }
3392 .repo-home-readme-head {
3393 display: flex;
3394 align-items: center;
3395 gap: 8px;
3396 padding: 10px 16px;
3397 background: var(--bg-secondary);
3398 border-bottom: 1px solid var(--border);
3399 font-size: 13px;
3400 color: var(--text-muted);
3401 }
3402 .repo-home-readme-head .repo-home-readme-icon {
3403 color: var(--accent);
3404 font-size: 14px;
3405 }
3406 .repo-home-readme-body {
3407 padding: var(--space-5) var(--space-6);
3408 }
3409
3410 /* Empty-state CTA */
3411 .repo-home-empty {
3412 position: relative;
3413 margin-top: var(--space-4);
3414 background: var(--bg-elevated);
3415 border: 1px solid var(--border);
3416 border-radius: 16px;
3417 padding: var(--space-6);
3418 overflow: hidden;
3419 }
3420 .repo-home-empty::before {
3421 content: '';
3422 position: absolute;
3423 top: 0; left: 0; right: 0;
3424 height: 2px;
6fd5915Claude3425 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3426 opacity: 0.7;
3427 pointer-events: none;
3428 }
3429 .repo-home-empty-eyebrow {
3430 font-size: 12px;
3431 font-family: var(--font-mono);
3432 color: var(--accent);
3433 letter-spacing: 0.12em;
3434 text-transform: uppercase;
3435 margin-bottom: var(--space-2);
3436 font-weight: 600;
3437 }
3438 .repo-home-empty-title {
3439 font-family: var(--font-display);
3440 font-weight: 800;
3441 letter-spacing: -0.025em;
3442 font-size: clamp(22px, 3vw, 30px);
3443 line-height: 1.1;
3444 margin: 0 0 var(--space-2);
3445 color: var(--text-strong);
3446 }
3447 .repo-home-empty-sub {
3448 color: var(--text-muted);
3449 font-size: 14px;
3450 line-height: 1.55;
3451 max-width: 640px;
3452 margin: 0 0 var(--space-4);
3453 }
3454 .repo-home-empty-snippet {
3455 background: var(--bg);
3456 border: 1px solid var(--border);
3457 border-radius: 10px;
3458 padding: var(--space-3) var(--space-4);
3459 font-family: var(--font-mono);
3460 font-size: 12.5px;
3461 line-height: 1.7;
3462 color: var(--text-strong);
3463 overflow-x: auto;
3464 white-space: pre;
3465 margin: 0;
3466 }
3467 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3468 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3469
91a0204Claude3470 /* Health score badge */
3471 .repo-health-badge {
3472 display: inline-flex;
3473 align-items: center;
3474 gap: 5px;
3475 padding: 3px 10px;
3476 border-radius: 999px;
3477 font-size: 11.5px;
3478 font-weight: 700;
3479 font-family: var(--font-mono);
3480 text-decoration: none;
3481 border: 1px solid transparent;
3482 transition: filter 120ms ease, opacity 120ms ease;
3483 vertical-align: middle;
3484 }
3485 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3486 .repo-health-badge-elite {
3487 background: rgba(52,211,153,0.15);
e589f77ccantynz-alt3488 color: var(--green);
91a0204Claude3489 border-color: rgba(52,211,153,0.30);
3490 }
3491 .repo-health-badge-strong {
3492 background: rgba(96,165,250,0.15);
3493 color: #93c5fd;
3494 border-color: rgba(96,165,250,0.30);
3495 }
3496 .repo-health-badge-improving {
3497 background: rgba(251,191,36,0.15);
3498 color: #fde68a;
3499 border-color: rgba(251,191,36,0.30);
3500 }
3501 .repo-health-badge-needs-attention {
3502 background: rgba(248,113,113,0.15);
e589f77ccantynz-alt3503 color: var(--red);
91a0204Claude3504 border-color: rgba(248,113,113,0.30);
3505 }
3506
3507 /* Three-option empty-state panel */
3508 .repo-empty-options {
3509 display: grid;
3510 grid-template-columns: repeat(3, 1fr);
3511 gap: var(--space-3);
3512 margin-top: var(--space-5);
3513 }
3514 @media (max-width: 760px) {
3515 .repo-empty-options { grid-template-columns: 1fr; }
3516 }
3517 .repo-empty-option {
3518 position: relative;
3519 background: var(--bg-elevated);
3520 border: 1px solid var(--border);
3521 border-radius: 14px;
3522 padding: var(--space-4) var(--space-4);
3523 display: flex;
3524 flex-direction: column;
3525 gap: var(--space-2);
3526 overflow: hidden;
3527 transition: border-color 140ms ease, background 140ms ease;
3528 }
3529 .repo-empty-option:hover {
6fd5915Claude3530 border-color: rgba(91,110,232,0.45);
3531 background: rgba(91,110,232,0.04);
91a0204Claude3532 }
3533 .repo-empty-option::before {
3534 content: '';
3535 position: absolute;
3536 top: 0; left: 0; right: 0;
3537 height: 2px;
6fd5915Claude3538 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3539 opacity: 0.55;
3540 pointer-events: none;
3541 }
3542 .repo-empty-option-label {
3543 font-size: 10px;
3544 font-family: var(--font-mono);
3545 font-weight: 700;
3546 letter-spacing: 0.12em;
3547 text-transform: uppercase;
3548 color: var(--accent);
3549 margin-bottom: 2px;
3550 }
3551 .repo-empty-option-title {
3552 font-family: var(--font-display);
3553 font-weight: 700;
3554 font-size: 16px;
3555 letter-spacing: -0.01em;
3556 color: var(--text-strong);
3557 margin: 0;
3558 }
3559 .repo-empty-option-sub {
3560 font-size: 13px;
3561 color: var(--text-muted);
3562 line-height: 1.5;
3563 margin: 0;
3564 flex: 1;
3565 }
3566 .repo-empty-option-snippet {
3567 background: var(--bg);
3568 border: 1px solid var(--border);
3569 border-radius: 8px;
3570 padding: var(--space-2) var(--space-3);
3571 font-family: var(--font-mono);
3572 font-size: 11.5px;
3573 line-height: 1.75;
3574 color: var(--text-strong);
3575 overflow-x: auto;
3576 white-space: pre;
3577 margin: var(--space-1) 0 0;
3578 }
3579 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3580 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3581 .repo-empty-option-cta {
3582 display: inline-flex;
3583 align-items: center;
3584 gap: 6px;
3585 margin-top: auto;
3586 padding-top: var(--space-2);
3587 font-size: 13px;
3588 font-weight: 600;
3589 color: var(--accent);
3590 text-decoration: none;
3591 transition: color 120ms ease;
3592 }
3593 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3594
544d842Claude3595 @media (max-width: 720px) {
3596 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3597 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3598 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3599 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3600 .repo-home-side-val { max-width: 55%; }
3601 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3602 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3603 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3604 }
ebaae0fClaude3605
3606 /* AI stats strip */
3607 .repo-ai-stats-strip {
3608 display: flex;
3609 align-items: center;
3610 gap: 6px;
3611 margin-top: 12px;
3612 margin-bottom: 4px;
3613 font-size: 12px;
3614 color: var(--text-muted);
3615 flex-wrap: wrap;
3616 }
3617 .repo-ai-stats-strip a {
3618 color: var(--text-muted);
3619 text-decoration: underline;
3620 text-decoration-color: transparent;
3621 transition: color 120ms ease, text-decoration-color 120ms ease;
3622 }
3623 .repo-ai-stats-strip a:hover {
3624 color: var(--accent);
3625 text-decoration-color: currentColor;
3626 }
3627 .repo-ai-stats-sep {
3628 opacity: 0.4;
3629 user-select: none;
3630 }
544d842Claude3631 `;
3632 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3633 // SSH URL — port-aware:
3634 // Standard port 22 → git@host:owner/repo.git
3635 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3636 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3637 try {
3638 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3639 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3640 ? host
3641 : "localhost";
3642 const sshPort = config.sshPort;
3643 if (sshPort === 22) {
3644 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3645 } else {
3646 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3647 }
3648 } catch {
3649 // Fall through to default.
3650 }
3651 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3652 const formatRelative = (date: Date | null): string => {
3653 if (!date) return "never";
3654 const ms = Date.now() - date.getTime();
3655 const s = Math.max(0, Math.round(ms / 1000));
3656 if (s < 60) return "just now";
3657 const m = Math.round(s / 60);
3658 if (m < 60) return `${m} min ago`;
3659 const h = Math.round(m / 60);
3660 if (h < 24) return `${h}h ago`;
3661 const d = Math.round(h / 24);
3662 if (d < 30) return `${d}d ago`;
3663 const mo = Math.round(d / 30);
3664 if (mo < 12) return `${mo}mo ago`;
3665 const y = Math.round(d / 365);
3666 return `${y}y ago`;
3667 };
06d5ffeClaude3668
fc1817aClaude3669 if (tree.length === 0) {
5acce80Claude3670 const repoOgDesc = description
3671 ? `${owner}/${repo} on Gluecron — ${description}`
3672 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3673 return c.html(
5acce80Claude3674 <Layout
3675 title={`${owner}/${repo}`}
3676 user={user}
3677 description={repoOgDesc}
3678 ogTitle={`${owner}/${repo} — Gluecron`}
3679 ogDescription={repoOgDesc}
3680 twitterCard="summary"
3681 >
544d842Claude3682 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3683 <style dangerouslySetInnerHTML={{ __html: `
3684 .empty-options-grid {
3685 display: grid;
3686 grid-template-columns: repeat(3, 1fr);
3687 gap: var(--space-4);
3688 margin-top: var(--space-4);
3689 }
3690 @media (max-width: 800px) {
3691 .empty-options-grid { grid-template-columns: 1fr; }
3692 }
3693 .empty-option-card {
3694 position: relative;
3695 background: var(--bg-elevated);
3696 border: 1px solid var(--border);
3697 border-radius: 14px;
3698 padding: var(--space-5);
3699 display: flex;
3700 flex-direction: column;
3701 gap: var(--space-3);
3702 overflow: hidden;
3703 transition: border-color 140ms ease, box-shadow 140ms ease;
3704 }
3705 .empty-option-card:hover {
6fd5915Claude3706 border-color: rgba(91,110,232,0.45);
3707 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3708 }
3709 .empty-option-card::before {
3710 content: '';
3711 position: absolute;
3712 top: 0; left: 0; right: 0;
3713 height: 2px;
6fd5915Claude3714 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3715 opacity: 0.55;
3716 pointer-events: none;
3717 }
3718 .empty-option-label {
3719 font-size: 10.5px;
3720 font-family: var(--font-mono);
3721 text-transform: uppercase;
3722 letter-spacing: 0.14em;
3723 color: var(--accent);
3724 font-weight: 700;
3725 }
3726 .empty-option-title {
3727 font-family: var(--font-display);
3728 font-weight: 700;
3729 font-size: 17px;
3730 letter-spacing: -0.01em;
3731 color: var(--text-strong);
3732 margin: 0;
3733 line-height: 1.25;
3734 }
3735 .empty-option-body {
3736 font-size: 13px;
3737 color: var(--text-muted);
3738 line-height: 1.55;
3739 margin: 0;
3740 flex: 1;
3741 }
3742 .empty-option-snippet {
3743 background: var(--bg);
3744 border: 1px solid var(--border);
3745 border-radius: 8px;
3746 padding: var(--space-3) var(--space-4);
3747 font-family: var(--font-mono);
3748 font-size: 11.5px;
3749 line-height: 1.75;
3750 color: var(--text-strong);
3751 overflow-x: auto;
3752 white-space: pre;
3753 margin: 0;
3754 }
3755 .empty-option-snippet .ec { color: var(--text-faint); }
3756 .empty-option-snippet .em { color: var(--accent); }
3757 ` }} />
f6730d0ccantynz-alt3758 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
544d842Claude3759 <div class="repo-home-hero">
3760 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3761 <div class="repo-home-hero-orb" />
3762 </div>
3763 <div class="repo-home-hero-inner">
3764 <div class="repo-home-eyebrow">
3765 <strong>Repository</strong> · {owner}
3766 </div>
91a0204Claude3767 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3768 <RepoHeader
3769 owner={owner}
3770 repo={repo}
3771 starCount={starCount}
3772 starred={starred}
3773 forkCount={forkCount}
3774 currentUser={user?.username}
3775 archived={archived}
3776 isTemplate={isTemplate}
8c790e0Claude3777 recentPush={recentPush}
91a0204Claude3778 />
3779 {healthScore && (() => {
3780 const gradeLabel: Record<string, string> = {
3781 elite: "Elite", strong: "Strong",
3782 improving: "Improving", "needs-attention": "Needs Attention",
3783 };
3784 return (
3785 <a
3786 href={`/${owner}/${repo}/insights/health`}
3787 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3788 title={`Health score: ${healthScore!.total}/100`}
3789 >
3790 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3791 </a>
3792 );
3793 })()}
3794 </div>
544d842Claude3795 {description ? (
3796 <p class="repo-home-description">{description}</p>
3797 ) : (
3798 <p class="repo-home-description-empty">
3799 No description yet — push a README to tell the world what this
3800 ships.
3801 </p>
3802 )}
3803 </div>
3804 </div>
fc1817aClaude3805 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3806 <div style="margin-top:var(--space-4)">
3807 <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)">
3808 Getting started
3809 </div>
544d842Claude3810 <h2 class="repo-home-empty-title">
91a0204Claude3811 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3812 </h2>
3813 <p class="repo-home-empty-sub">
91a0204Claude3814 Choose how you want to kick things off. Every push wires up gate
3815 checks and AI review automatically.
544d842Claude3816 </p>
91a0204Claude3817 <div class="empty-options-grid">
3818 <div class="empty-option-card">
3819 <div class="empty-option-label">Option A</div>
3820 <h3 class="empty-option-title">Push your first commit</h3>
3821 <p class="empty-option-body">
3822 Wire up an existing project in seconds. Run these commands from
3823 your project directory.
3824 </p>
3825 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3826<span class="em">git remote add</span> origin {cloneHttpsUrl}
3827<span class="em">git branch</span> -M main
3828<span class="em">git push</span> -u origin main</pre>
3829 </div>
3830 <div class="empty-option-card">
3831 <div class="empty-option-label">Option B</div>
3832 <h3 class="empty-option-title">Import from GitHub</h3>
3833 <p class="empty-option-body">
3834 Mirror an existing GitHub repository here in one click. Gluecron
3835 syncs the full history and branches.
3836 </p>
f6730d0ccantynz-alt3837 <Button href="/import" variant="secondary" style="align-self:flex-start">
91a0204Claude3838 Import repository
f6730d0ccantynz-alt3839 </Button>
91a0204Claude3840 </div>
3841 <div class="empty-option-card">
3842 <div class="empty-option-label">Option C</div>
3843 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3844 <p class="empty-option-body">
3845 Let AI write your first feature. Describe what you want to build
3846 and Gluecron opens a pull request with the code.
3847 </p>
f6730d0ccantynz-alt3848 <Button href={`/${owner}/${repo}/specs`} variant="primary" style="align-self:flex-start">
91a0204Claude3849 Let AI write your first feature
f6730d0ccantynz-alt3850 </Button>
91a0204Claude3851 </div>
3852 </div>
fc1817aClaude3853 </div>
3854 </Layout>
3855 );
3856 }
3857
3858 const readme = await getReadme(owner, repo, defaultBranch);
3859
544d842Claude3860 // Sidebar facts — derived from data we already have.
3861 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3862 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3863
5acce80Claude3864 const repoOgDesc = description
3865 ? `${owner}/${repo} on Gluecron — ${description}`
3866 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3867
fc1817aClaude3868 return c.html(
5acce80Claude3869 <Layout
3870 title={`${owner}/${repo}`}
3871 user={user}
3872 description={repoOgDesc}
3873 ogTitle={`${owner}/${repo} — Gluecron`}
3874 ogDescription={repoOgDesc}
3875 twitterCard="summary"
3876 >
544d842Claude3877 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3878 {/* Repo-context commands for the command palette (Feature 2) */}
3879 <script
3880 id="cmdk-repo-context"
3881 dangerouslySetInnerHTML={{
3882 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3883 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3884 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3885 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3886 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3887 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3888 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3889 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3890 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3891 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3892 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3893 ])};`,
3894 }}
3895 />
544d842Claude3896 <div class="repo-home-hero">
3897 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3898 <div class="repo-home-hero-orb" />
3899 </div>
3900 <div class="repo-home-hero-inner">
3901 <div class="repo-home-eyebrow">
3902 <strong>Repository</strong> · {owner}
3903 </div>
91a0204Claude3904 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3905 <RepoHeader
3906 owner={owner}
3907 repo={repo}
3908 starCount={starCount}
3909 starred={starred}
3910 forkCount={forkCount}
3911 currentUser={user?.username}
3912 archived={archived}
3913 isTemplate={isTemplate}
8c790e0Claude3914 recentPush={recentPush}
91a0204Claude3915 />
3916 {healthScore && (() => {
3917 const gradeLabel: Record<string, string> = {
3918 elite: "Elite", strong: "Strong",
3919 improving: "Improving", "needs-attention": "Needs Attention",
3920 };
3921 return (
3922 <a
3923 href={`/${owner}/${repo}/insights/health`}
3924 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3925 title={`Health score: ${healthScore!.total}/100`}
3926 >
3927 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3928 </a>
3929 );
3930 })()}
3931 </div>
544d842Claude3932 {description ? (
3933 <p class="repo-home-description">{description}</p>
3934 ) : (
3935 <p class="repo-home-description-empty">
3936 No description yet.
3937 </p>
3938 )}
3939 <div class="repo-home-stat-row" aria-label="Repository stats">
3940 <span class="repo-home-stat" title="Default branch">
3941 <span class="repo-home-stat-icon">{"⎇"}</span>
3942 <strong>{defaultBranch}</strong>
3943 </span>
3944 <a
3945 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3946 class="repo-home-stat"
3947 title="Browse all branches"
3948 >
3949 <span class="repo-home-stat-icon">{"⊢"}</span>
3950 <strong>{branches.length}</strong>{" "}
3951 branch{branches.length === 1 ? "" : "es"}
3952 </a>
3953 <span class="repo-home-stat" title="Top-level entries">
3954 <span class="repo-home-stat-icon">{"■"}</span>
3955 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3956 {dirCount > 0 && (
3957 <>
3958 {" · "}
3959 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3960 </>
3961 )}
3962 </span>
3963 {pushedAt && (
3964 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3965 <span class="repo-home-stat-icon">{"↻"}</span>
3966 Updated <strong>{formatRelative(pushedAt)}</strong>
3967 </span>
3968 )}
3969 </div>
3970 </div>
3971 </div>
71cd5ecClaude3972 {isTemplate && user && user.username !== owner && (
3973 <div
3974 class="panel"
dc26881CC LABS App3975 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3976 >
3977 <div style="font-size:13px">
3978 <strong>Template repository.</strong> Create a new repository from
3979 this template's files.
3980 </div>
3981 <form
001af43Claude3982 method="post"
71cd5ecClaude3983 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3984 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3985 >
3986 <input
3987 type="text"
3988 name="name"
3989 placeholder="new-repo-name"
3990 required
2c3ba6ecopilot-swe-agent[bot]3991 aria-label="New repository name"
71cd5ecClaude3992 style="width:200px"
3993 />
3994 <button type="submit" class="btn btn-primary">
3995 Use this template
3996 </button>
3997 </form>
3998 </div>
3999 )}
fc1817aClaude4000 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4001 <RepoHomePendingBanner
4002 owner={owner}
4003 repo={repo}
4004 count={repoHomePendingCount}
4005 />
641aa42Claude4006 {showOnboarding && onboardingRow && (
4007 <RepoOnboardingCard
4008 owner={owner}
4009 repo={repo}
4010 data={onboardingRow}
4011 />
4012 )}
c6018a5Claude4013 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4014 row sits just below the nav as a slim CTA strip. Scoped under
4015 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4016 <style
4017 dangerouslySetInnerHTML={{
4018 __html: `
4019 .repo-ai-cta-row {
4020 display: flex;
4021 flex-wrap: wrap;
4022 gap: 8px;
4023 margin: 12px 0 18px;
4024 padding: 10px 14px;
6fd5915Claude4025 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4026 border: 1px solid var(--border);
4027 border-radius: 10px;
4028 position: relative;
4029 overflow: hidden;
4030 }
4031 .repo-ai-cta-row::before {
4032 content: '';
4033 position: absolute;
4034 top: 0; left: 0; right: 0;
4035 height: 1px;
6fd5915Claude4036 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4037 opacity: 0.7;
4038 pointer-events: none;
4039 }
4040 .repo-ai-cta-label {
4041 font-size: 11px;
4042 font-weight: 600;
4043 letter-spacing: 0.06em;
4044 text-transform: uppercase;
4045 color: var(--accent);
4046 align-self: center;
4047 padding-right: 8px;
4048 border-right: 1px solid var(--border);
4049 margin-right: 4px;
4050 }
4051 .repo-ai-cta {
4052 display: inline-flex;
4053 align-items: center;
4054 gap: 6px;
4055 padding: 5px 10px;
4056 font-size: 12.5px;
4057 font-weight: 500;
4058 color: var(--text);
4059 background: var(--bg);
4060 border: 1px solid var(--border);
4061 border-radius: 7px;
4062 text-decoration: none;
4063 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4064 }
4065 .repo-ai-cta:hover {
6fd5915Claude4066 border-color: rgba(91,110,232,0.45);
c6018a5Claude4067 color: var(--text-strong);
6fd5915Claude4068 background: rgba(91,110,232,0.06);
c6018a5Claude4069 text-decoration: none;
4070 }
4071 .repo-ai-cta-icon {
4072 opacity: 0.75;
4073 font-size: 12px;
4074 }
4075 @media (max-width: 640px) {
4076 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4077 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4078 }
4079 `,
4080 }}
4081 />
4082 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4083 <span class="repo-ai-cta-label">AI surfaces</span>
4084 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4085 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4086 Chat
4087 </a>
4088 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4089 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4090 Previews
4091 </a>
4092 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4093 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4094 Migrations
4095 </a>
53c9249Claude4096 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4097 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4098 AI Search
c6018a5Claude4099 </a>
4100 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4101 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4102 AI release notes
4103 </a>
3646bfeClaude4104 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4105 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4106 Dev environment
4107 </a>
c6018a5Claude4108 </nav>
544d842Claude4109 <div class="repo-home-grid">
4110 <div class="repo-home-main">
4111 <BranchSwitcher
4112 owner={owner}
4113 repo={repo}
4114 currentRef={defaultBranch}
4115 branches={branches}
4116 pathType="tree"
4117 />
4118 <FileTable
4119 entries={tree}
4120 owner={owner}
4121 repo={repo}
4122 ref={defaultBranch}
4123 path=""
4124 />
ebaae0fClaude4125 {/* AI stats strip — one-line summary of AI value for this repo */}
4126 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4127 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4128 <span>{"⚡"}</span>
4129 {aiStats.mergedCount > 0 ? (
4130 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4131 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4132 </a>
4133 ) : (
4134 <span>0 AI merges this week</span>
4135 )}
4136 <span class="repo-ai-stats-sep">{"·"}</span>
4137 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4138 <span class="repo-ai-stats-sep">{"·"}</span>
4139 {aiStats.securityAlertCount > 0 ? (
4140 <a href={`/${owner}/${repo}/issues?label=security`}>
4141 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4142 </a>
4143 ) : (
4144 <span>0 open security alerts</span>
4145 )}
4146 </div>
4147 ) : (
4148 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4149 <span>{"⚡"}</span>
4150 <span>AI is watching — no activity this week</span>
4151 </div>
4152 )}
544d842Claude4153 {readme && (() => {
4154 const readmeHtml = renderMarkdown(readme);
4155 return (
4156 <div class="repo-home-readme">
4157 <div class="repo-home-readme-head">
4158 <span class="repo-home-readme-icon">{"☰"}</span>
4159 <span>README.md</span>
4160 </div>
4161 <style>{markdownCss}</style>
4162 <div class="markdown-body repo-home-readme-body">
4163 {html([readmeHtml] as unknown as TemplateStringsArray)}
4164 </div>
4165 </div>
4166 );
4167 })()}
4168 </div>
4169 <aside class="repo-home-side" aria-label="Repository details">
4170 <div class="repo-home-clone">
4171 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4172 <button
4173 type="button"
4174 class="repo-home-clone-tab"
4175 role="tab"
4176 aria-selected="true"
4177 data-pane="https"
4178 data-repo-home-clone-tab
4179 >
4180 HTTPS
4181 </button>
4182 <button
4183 type="button"
4184 class="repo-home-clone-tab"
4185 role="tab"
4186 aria-selected="false"
4187 data-pane="ssh"
4188 data-repo-home-clone-tab
4189 >
4190 SSH
4191 </button>
4192 <button
4193 type="button"
4194 class="repo-home-clone-tab"
4195 role="tab"
4196 aria-selected="false"
4197 data-pane="cli"
4198 data-repo-home-clone-tab
4199 >
4200 CLI
4201 </button>
4202 </div>
4203 <div
4204 class="repo-home-clone-pane"
4205 data-pane="https"
4206 data-active="true"
4207 role="tabpanel"
4208 >
4209 <div class="repo-home-clone-body">
4210 <input
4211 class="repo-home-clone-input"
4212 type="text"
4213 value={cloneHttpsUrl}
4214 readonly
4215 aria-label="HTTPS clone URL"
4216 data-repo-home-clone-input
4217 />
4218 <button
4219 type="button"
4220 class="repo-home-clone-copy"
4221 data-repo-home-copy={cloneHttpsUrl}
4222 >
4223 Copy
4224 </button>
4225 </div>
4226 </div>
4227 <div
4228 class="repo-home-clone-pane"
4229 data-pane="ssh"
4230 data-active="false"
4231 role="tabpanel"
4232 >
4233 <div class="repo-home-clone-body">
4234 <input
4235 class="repo-home-clone-input"
4236 type="text"
4237 value={cloneSshUrl}
4238 readonly
4239 aria-label="SSH clone URL"
4240 data-repo-home-clone-input
4241 />
4242 <button
4243 type="button"
4244 class="repo-home-clone-copy"
4245 data-repo-home-copy={cloneSshUrl}
4246 >
4247 Copy
4248 </button>
4249 </div>
4250 </div>
4251 <div
4252 class="repo-home-clone-pane"
4253 data-pane="cli"
4254 data-active="false"
4255 role="tabpanel"
4256 >
4257 <div class="repo-home-clone-body">
4258 <input
4259 class="repo-home-clone-input"
4260 type="text"
4261 value={cloneCliCmd}
4262 readonly
4263 aria-label="Gluecron CLI clone command"
4264 data-repo-home-clone-input
4265 />
4266 <button
4267 type="button"
4268 class="repo-home-clone-copy"
4269 data-repo-home-copy={cloneCliCmd}
4270 >
4271 Copy
4272 </button>
4273 </div>
79136bbClaude4274 </div>
fc1817aClaude4275 </div>
544d842Claude4276 <div class="repo-home-side-card">
4277 <h3 class="repo-home-side-title">About</h3>
4278 <div class="repo-home-side-row">
4279 <span class="repo-home-side-key">Default branch</span>
4280 <span class="repo-home-side-val">{defaultBranch}</span>
4281 </div>
4282 <div class="repo-home-side-row">
4283 <span class="repo-home-side-key">Branches</span>
4284 <span class="repo-home-side-val">
4285 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4286 {branches.length}
4287 </a>
4288 </span>
4289 </div>
4290 <div class="repo-home-side-row">
4291 <span class="repo-home-side-key">Stars</span>
4292 <span class="repo-home-side-val">{starCount}</span>
4293 </div>
4294 <div class="repo-home-side-row">
4295 <span class="repo-home-side-key">Forks</span>
4296 <span class="repo-home-side-val">{forkCount}</span>
4297 </div>
4298 {pushedAt && (
4299 <div class="repo-home-side-row">
4300 <span class="repo-home-side-key">Last push</span>
4301 <span class="repo-home-side-val">
4302 {formatRelative(pushedAt)}
4303 </span>
4304 </div>
4305 )}
4306 {createdAt && (
4307 <div class="repo-home-side-row">
4308 <span class="repo-home-side-key">Created</span>
4309 <span class="repo-home-side-val">
4310 {formatRelative(createdAt)}
4311 </span>
4312 </div>
4313 )}
4314 {(archived || isTemplate) && (
4315 <div class="repo-home-side-row">
4316 <span class="repo-home-side-key">State</span>
4317 <span class="repo-home-side-val">
4318 {archived ? "Archived" : "Template"}
4319 </span>
4320 </div>
4321 )}
4322 </div>
f1dc38bClaude4323 {/* Claude AI — quick-access card for authenticated users */}
4324 {user && (
4325 <div class="repo-home-side-card" style="margin-top:12px">
4326 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4327 ✨ Claude AI
4328 </h3>
4329 <a
4330 href={`/${owner}/${repo}/claude`}
e589f77ccantynz-alt4331 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"
f1dc38bClaude4332 >
4333 Claude Code sessions
4334 </a>
4335 <a
4336 href={`/${owner}/${repo}/ask`}
4337 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4338 >
4339 Ask AI about this repo
4340 </a>
4341 </div>
4342 )}
544d842Claude4343 </aside>
4344 </div>
4345 <script
4346 dangerouslySetInnerHTML={{
4347 __html: `
4348 (function(){
4349 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4350 tabs.forEach(function(tab){
4351 tab.addEventListener('click', function(){
4352 var target = tab.getAttribute('data-pane');
4353 tabs.forEach(function(t){
4354 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4355 });
4356 var panes = document.querySelectorAll('.repo-home-clone-pane');
4357 panes.forEach(function(p){
4358 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4359 });
4360 });
4361 });
4362 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4363 copyBtns.forEach(function(btn){
4364 btn.addEventListener('click', function(){
4365 var text = btn.getAttribute('data-repo-home-copy') || '';
4366 var done = function(){
4367 var prev = btn.textContent;
4368 btn.textContent = 'Copied';
4369 setTimeout(function(){ btn.textContent = prev; }, 1200);
4370 };
4371 if (navigator.clipboard && navigator.clipboard.writeText) {
4372 navigator.clipboard.writeText(text).then(done, done);
4373 } else {
4374 var ta = document.createElement('textarea');
4375 ta.value = text;
4376 document.body.appendChild(ta);
4377 ta.select();
4378 try { document.execCommand('copy'); } catch (e) {}
4379 document.body.removeChild(ta);
4380 done();
4381 }
4382 });
4383 });
4384 })();
4385 `,
4386 }}
4387 />
fc1817aClaude4388 </Layout>
4389 );
4390});
4391
4392// Browse tree at ref/path
4393web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4394 const { owner, repo } = c.req.param();
06d5ffeClaude4395 const user = c.get("user");
5bb52faccanty labs4396 const gate = await assertRepoReadable(c, owner, repo);
4397 if (gate) return gate;
fc1817aClaude4398 const refAndPath = c.req.param("ref");
4399
4400 const branches = await listBranches(owner, repo);
4401 let ref = "";
4402 let treePath = "";
4403
4404 for (const branch of branches) {
4405 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4406 ref = branch;
4407 treePath = refAndPath.slice(branch.length + 1);
4408 break;
4409 }
4410 }
4411
4412 if (!ref) {
4413 const slashIdx = refAndPath.indexOf("/");
4414 if (slashIdx === -1) {
4415 ref = refAndPath;
4416 } else {
4417 ref = refAndPath.slice(0, slashIdx);
4418 treePath = refAndPath.slice(slashIdx + 1);
4419 }
4420 }
4421
4422 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4423 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4424 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4425
4426 return c.html(
06d5ffeClaude4427 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4428 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4429 <RepoHeader owner={owner} repo={repo} />
4430 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4431 <div class="tree-header">
4432 <div class="tree-header-row">
4433 <BranchSwitcher
4434 owner={owner}
4435 repo={repo}
4436 currentRef={ref}
4437 branches={branches}
4438 pathType="tree"
4439 subPath={treePath}
4440 />
4441 <div class="tree-header-stats">
4442 <span class="tree-stat" title="Entries in this directory">
4443 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4444 {dirCount > 0 && (
4445 <>
4446 {" · "}
4447 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4448 </>
4449 )}
4450 </span>
4451 <a
4452 href={`/${owner}/${repo}/search`}
4453 class="tree-stat tree-stat-link"
4454 title="Search code in this repository"
4455 >
4456 {"⌕"} Search
4457 </a>
4458 </div>
4459 </div>
4460 <div class="tree-breadcrumb-row">
4461 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4462 </div>
4463 </div>
fc1817aClaude4464 <FileTable
4465 entries={tree}
4466 owner={owner}
4467 repo={repo}
4468 ref={ref}
4469 path={treePath}
4470 />
4471 </Layout>
4472 );
4473});
4474
06d5ffeClaude4475// View file blob with syntax highlighting
fc1817aClaude4476web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4477 const { owner, repo } = c.req.param();
06d5ffeClaude4478 const user = c.get("user");
5bb52faccanty labs4479 const gate = await assertRepoReadable(c, owner, repo);
4480 if (gate) return gate;
fc1817aClaude4481 const refAndPath = c.req.param("ref");
4482
4483 const branches = await listBranches(owner, repo);
4484 let ref = "";
4485 let filePath = "";
4486
4487 for (const branch of branches) {
4488 if (refAndPath.startsWith(branch + "/")) {
4489 ref = branch;
4490 filePath = refAndPath.slice(branch.length + 1);
4491 break;
4492 }
4493 }
4494
4495 if (!ref) {
4496 const slashIdx = refAndPath.indexOf("/");
4497 if (slashIdx === -1) return c.text("Not found", 404);
4498 ref = refAndPath.slice(0, slashIdx);
4499 filePath = refAndPath.slice(slashIdx + 1);
4500 }
4501
4502 const blob = await getBlob(owner, repo, ref, filePath);
4503 if (!blob) {
4504 return c.html(
06d5ffeClaude4505 <Layout title="Not Found" user={user}>
fc1817aClaude4506 <div class="empty-state">
4507 <h2>File not found</h2>
4508 </div>
4509 </Layout>,
4510 404
4511 );
4512 }
4513
06d5ffeClaude4514 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4515 const lineCount = blob.isBinary
4516 ? 0
4517 : (blob.content.endsWith("\n")
4518 ? blob.content.split("\n").length - 1
4519 : blob.content.split("\n").length);
4520 const formatBytes = (n: number): string => {
4521 if (n < 1024) return `${n} B`;
4522 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4523 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4524 };
fc1817aClaude4525
4526 return c.html(
06d5ffeClaude4527 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4528 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4529 <RepoHeader owner={owner} repo={repo} />
4530 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4531 <div class="blob-toolbar">
4532 <BranchSwitcher
4533 owner={owner}
4534 repo={repo}
4535 currentRef={ref}
4536 branches={branches}
4537 pathType="blob"
4538 subPath={filePath}
4539 />
4540 <div class="blob-breadcrumb">
4541 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4542 </div>
4543 </div>
4544 <div class="blob-view blob-card">
4545 <div class="blob-header blob-header-polished">
4546 <div class="blob-header-meta">
4547 <span class="blob-header-icon" aria-hidden="true">
4548 {"📄"}
4549 </span>
4550 <span class="blob-header-name">{fileName}</span>
4551 <span class="blob-header-size">
4552 {formatBytes(blob.size)}
4553 {!blob.isBinary && (
4554 <>
4555 {" · "}
4556 {lineCount} line{lineCount === 1 ? "" : "s"}
4557 </>
4558 )}
4559 </span>
4560 </div>
4561 <div class="blob-header-actions">
4562 <a
4563 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4564 class="blob-pill"
4565 >
79136bbClaude4566 Raw
4567 </a>
efb11c5Claude4568 <a
4569 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4570 class="blob-pill"
4571 >
79136bbClaude4572 Blame
4573 </a>
efb11c5Claude4574 <a
4575 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4576 class="blob-pill"
4577 >
16b325cClaude4578 History
4579 </a>
0074234Claude4580 {user && (
efb11c5Claude4581 <a
4582 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4583 class="blob-pill blob-pill-accent"
4584 >
0074234Claude4585 Edit
4586 </a>
4587 )}
efb11c5Claude4588 </div>
fc1817aClaude4589 </div>
4590 {blob.isBinary ? (
efb11c5Claude4591 <div class="blob-binary">
fc1817aClaude4592 Binary file not shown.
4593 </div>
06d5ffeClaude4594 ) : (() => {
4595 const { html: highlighted, language } = highlightCode(
4596 blob.content,
4597 fileName
4598 );
4599 if (language) {
4600 return (
4601 <HighlightedCode
4602 highlightedHtml={highlighted}
efb11c5Claude4603 lineCount={lineCount}
06d5ffeClaude4604 />
4605 );
4606 }
4607 const lines = blob.content.split("\n");
4608 if (lines[lines.length - 1] === "") lines.pop();
4609 return <PlainCode lines={lines} />;
4610 })()}
fc1817aClaude4611 </div>
4612 </Layout>
4613 );
4614});
4615
398a10cClaude4616// ─── Branches list ────────────────────────────────────────────────────────
4617// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4618// author + relative time + ahead/behind vs the default branch. No DB. All
4619// data comes from git plumbing; failures degrade gracefully (counts omitted).
4620web.get("/:owner/:repo/branches", async (c) => {
4621 const { owner, repo } = c.req.param();
4622 const user = c.get("user");
5bb52faccanty labs4623 const gate = await assertRepoReadable(c, owner, repo);
4624 if (gate) return gate;
398a10cClaude4625
4626 if (!(await repoExists(owner, repo))) return c.notFound();
4627
4628 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4629 const branches = await listBranches(owner, repo);
4630 const repoDir = getRepoPath(owner, repo);
4631
4632 type BranchRow = {
4633 name: string;
4634 isDefault: boolean;
4635 sha: string;
4636 subject: string;
4637 author: string;
4638 date: string;
4639 ahead: number;
4640 behind: number;
4641 };
4642
4643 const runGit = async (args: string[]): Promise<string> => {
4644 try {
4645 const proc = Bun.spawn(["git", ...args], {
4646 cwd: repoDir,
4647 stdout: "pipe",
4648 stderr: "pipe",
4649 });
4650 const out = await new Response(proc.stdout).text();
4651 await proc.exited;
4652 return out;
4653 } catch {
4654 return "";
4655 }
4656 };
4657
4658 const meta = await runGit([
4659 "for-each-ref",
4660 "--sort=-committerdate",
4661 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4662 "refs/heads/",
4663 ]);
4664 const metaByName: Record<
4665 string,
4666 { sha: string; subject: string; author: string; date: string }
4667 > = {};
4668 for (const line of meta.split("\n").filter(Boolean)) {
4669 const [name, sha, subject, author, date] = line.split("\0");
4670 metaByName[name] = { sha, subject, author, date };
4671 }
4672
4673 const branchOrder = [...branches].sort((a, b) => {
4674 if (a === defaultBranch) return -1;
4675 if (b === defaultBranch) return 1;
4676 const aDate = metaByName[a]?.date || "";
4677 const bDate = metaByName[b]?.date || "";
4678 return bDate.localeCompare(aDate);
4679 });
4680
4681 const rows: BranchRow[] = [];
4682 for (const name of branchOrder) {
4683 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4684 let ahead = 0;
4685 let behind = 0;
4686 if (name !== defaultBranch && metaByName[defaultBranch]) {
4687 const out = await runGit([
4688 "rev-list",
4689 "--left-right",
4690 "--count",
4691 `${defaultBranch}...${name}`,
4692 ]);
4693 const parts = out.trim().split(/\s+/);
4694 if (parts.length === 2) {
4695 behind = parseInt(parts[0], 10) || 0;
4696 ahead = parseInt(parts[1], 10) || 0;
4697 }
4698 }
4699 rows.push({
4700 name,
4701 isDefault: name === defaultBranch,
4702 sha: m.sha,
4703 subject: m.subject,
4704 author: m.author,
4705 date: m.date,
4706 ahead,
4707 behind,
4708 });
4709 }
4710
4711 const relative = (iso: string): string => {
4712 if (!iso) return "—";
4713 const d = new Date(iso);
4714 if (Number.isNaN(d.getTime())) return "—";
4715 const diff = Date.now() - d.getTime();
4716 const m = Math.floor(diff / 60000);
4717 if (m < 1) return "just now";
4718 if (m < 60) return `${m} min ago`;
4719 const h = Math.floor(m / 60);
4720 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4721 const dd = Math.floor(h / 24);
4722 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4723 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4724 };
4725
4726 const success = c.req.query("success");
4727 const error = c.req.query("error");
4728
4729 return c.html(
4730 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4731 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4732 <RepoHeader owner={owner} repo={repo} />
4733 <RepoNav owner={owner} repo={repo} active="code" />
4734 <div
4735 class="branches-wrap"
eed4684Claude4736 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4737 >
4738 <header class="branches-head" style="margin-bottom:var(--space-5)">
4739 <div
4740 class="branches-eyebrow"
4741 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"
4742 >
4743 <span
4744 class="branches-eyebrow-dot"
4745 aria-hidden="true"
6fd5915Claude4746 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)"
398a10cClaude4747 />
4748 Repository · Branches
4749 </div>
4750 <h1
4751 class="branches-title"
4752 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)"
4753 >
4754 <span class="gradient-text">{rows.length}</span>{" "}
4755 branch{rows.length === 1 ? "" : "es"}
4756 </h1>
4757 <p
4758 class="branches-sub"
4759 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4760 >
4761 All work-in-progress lines for{" "}
4762 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4763 counts are relative to{" "}
4764 <code style="font-size:12.5px">{defaultBranch}</code>.
4765 </p>
4766 </header>
4767
4768 {success && (
4769 <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">
4770 {decodeURIComponent(success)}
4771 </div>
4772 )}
4773 {error && (
4774 <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">
4775 {decodeURIComponent(error)}
4776 </div>
4777 )}
4778
4779 {rows.length === 0 ? (
4780 <div class="commits-empty">
4781 <div class="commits-empty-orb" aria-hidden="true" />
4782 <div class="commits-empty-inner">
4783 <div class="commits-empty-icon" aria-hidden="true">
4784 <svg
4785 width="22"
4786 height="22"
4787 viewBox="0 0 24 24"
4788 fill="none"
4789 stroke="currentColor"
4790 stroke-width="2"
4791 stroke-linecap="round"
4792 stroke-linejoin="round"
4793 >
4794 <line x1="6" y1="3" x2="6" y2="15" />
4795 <circle cx="18" cy="6" r="3" />
4796 <circle cx="6" cy="18" r="3" />
4797 <path d="M18 9a9 9 0 0 1-9 9" />
4798 </svg>
4799 </div>
4800 <h3 class="commits-empty-title">No branches yet</h3>
4801 <p class="commits-empty-sub">
4802 Push your first commit to create the default branch.
4803 </p>
4804 </div>
4805 </div>
4806 ) : (
4807 <div class="branches-list">
4808 {rows.map((r) => (
4809 <div class="branches-row">
4810 <div class="branches-row-icon" aria-hidden="true">
4811 <svg
4812 width="14"
4813 height="14"
4814 viewBox="0 0 24 24"
4815 fill="none"
4816 stroke="currentColor"
4817 stroke-width="2"
4818 stroke-linecap="round"
4819 stroke-linejoin="round"
4820 >
4821 <line x1="6" y1="3" x2="6" y2="15" />
4822 <circle cx="18" cy="6" r="3" />
4823 <circle cx="6" cy="18" r="3" />
4824 <path d="M18 9a9 9 0 0 1-9 9" />
4825 </svg>
4826 </div>
4827 <div class="branches-row-main">
4828 <div class="branches-row-name">
4829 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4830 {r.isDefault && (
4831 <span
4832 class="branches-row-default"
4833 title="Default branch"
4834 >
4835 Default
4836 </span>
4837 )}
4838 </div>
4839 <div class="branches-row-meta">
4840 {r.subject ? (
4841 <>
4842 <strong>{r.author || "—"}</strong>
4843 <span class="sep">·</span>
4844 <span
4845 title={r.date ? new Date(r.date).toISOString() : ""}
4846 >
4847 updated {relative(r.date)}
4848 </span>
4849 {r.sha && (
4850 <>
4851 <span class="sep">·</span>
4852 <a
4853 href={`/${owner}/${repo}/commit/${r.sha}`}
4854 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4855 >
4856 {r.sha.slice(0, 7)}
4857 </a>
4858 </>
4859 )}
4860 </>
4861 ) : (
4862 <span>No commit metadata</span>
4863 )}
4864 </div>
4865 </div>
4866 <div class="branches-row-side">
4867 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4868 <span
4869 class="branches-row-divergence"
4870 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4871 >
4872 <span class="ahead">{r.ahead} ahead</span>
4873 <span style="opacity:0.4">|</span>
4874 <span class="behind">{r.behind} behind</span>
4875 </span>
4876 )}
4877 <div class="branches-row-actions">
4878 <a
4879 href={`/${owner}/${repo}/commits/${r.name}`}
4880 class="branches-btn"
4881 title="View commits on this branch"
4882 >
4883 Commits
4884 </a>
4885 {!r.isDefault &&
4886 user &&
4887 user.username === owner && (
4888 <form
4889 method="post"
4890 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4891 style="margin:0"
4892 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4893 >
4894 <button
4895 type="submit"
4896 class="branches-btn branches-btn-danger"
4897 >
4898 Delete
4899 </button>
4900 </form>
4901 )}
4902 </div>
4903 </div>
4904 </div>
4905 ))}
4906 </div>
4907 )}
4908 </div>
4909 </Layout>
4910 );
4911});
4912
4913// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4914// that are not merged into the default branch — matches the explicit
4915// confirmation on the row's delete button.
4916web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4917 const { owner, repo } = c.req.param();
4918 const branchName = decodeURIComponent(c.req.param("name"));
4919 const user = c.get("user")!;
4920
4921 // Owner-only check (mirrors collaborators.tsx pattern).
4922 const [ownerRow] = await db
4923 .select()
4924 .from(users)
4925 .where(eq(users.username, owner))
4926 .limit(1);
4927 if (!ownerRow || ownerRow.id !== user.id) {
4928 return c.redirect(
4929 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4930 );
4931 }
4932
4933 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4934 if (branchName === defaultBranch) {
4935 return c.redirect(
4936 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4937 );
4938 }
4939
4940 const branches = await listBranches(owner, repo);
4941 if (!branches.includes(branchName)) {
4942 return c.redirect(
4943 `/${owner}/${repo}/branches?error=Branch+not+found`
4944 );
4945 }
4946
4947 try {
4948 const repoDir = getRepoPath(owner, repo);
4949 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4950 cwd: repoDir,
4951 stdout: "pipe",
4952 stderr: "pipe",
4953 });
4954 await proc.exited;
4955 if (proc.exitCode !== 0) {
4956 return c.redirect(
4957 `/${owner}/${repo}/branches?error=Delete+failed`
4958 );
4959 }
4960 } catch {
4961 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4962 }
4963
4964 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4965});
4966
4967// ─── Tags list ────────────────────────────────────────────────────────────
4968// Pulls from `listTags` (sorted newest first). For each tag we look up an
4969// associated release row so the "View release" CTA can deep-link directly
4970// without making the user hunt for it.
4971web.get("/:owner/:repo/tags", async (c) => {
4972 const { owner, repo } = c.req.param();
4973 const user = c.get("user");
5bb52faccanty labs4974 const gate = await assertRepoReadable(c, owner, repo);
4975 if (gate) return gate;
398a10cClaude4976
4977 if (!(await repoExists(owner, repo))) return c.notFound();
4978
4979 const tags = await listTags(owner, repo);
4980
4981 // Map tags -> releases. Best-effort; releases table may not exist in
4982 // every test setup, so any error falls through with an empty set.
4983 const tagsWithReleases = new Set<string>();
4984 try {
4985 const [ownerRow] = await db
4986 .select()
4987 .from(users)
4988 .where(eq(users.username, owner))
4989 .limit(1);
4990 if (ownerRow) {
4991 const [repoRow] = await db
4992 .select()
4993 .from(repositories)
4994 .where(
4995 and(
4996 eq(repositories.ownerId, ownerRow.id),
4997 eq(repositories.name, repo)
4998 )
4999 )
5000 .limit(1);
5001 if (repoRow) {
5002 // Raw SQL so we don't need to import the releases schema here.
5003 const result = await db.execute(
5004 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5005 );
5006 const rows: any[] = (result as any).rows || (result as any) || [];
5007 for (const row of rows) {
5008 const tag = row?.tag;
5009 if (typeof tag === "string") tagsWithReleases.add(tag);
5010 }
5011 }
5012 }
5013 } catch {
5014 // No releases table or DB error — leave set empty.
5015 }
5016
5017 const relative = (iso: string): string => {
5018 if (!iso) return "—";
5019 const d = new Date(iso);
5020 if (Number.isNaN(d.getTime())) return "—";
5021 const diff = Date.now() - d.getTime();
5022 const m = Math.floor(diff / 60000);
5023 if (m < 1) return "just now";
5024 if (m < 60) return `${m} min ago`;
5025 const h = Math.floor(m / 60);
5026 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5027 const dd = Math.floor(h / 24);
5028 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5029 return d.toLocaleDateString("en-US", {
5030 month: "short",
5031 day: "numeric",
5032 year: "numeric",
5033 });
5034 };
5035
5036 return c.html(
5037 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5038 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5039 <RepoHeader owner={owner} repo={repo} />
5040 <RepoNav owner={owner} repo={repo} active="code" />
5041 <div
5042 class="tags-wrap"
eed4684Claude5043 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5044 >
5045 <header class="tags-head" style="margin-bottom:var(--space-5)">
5046 <div
5047 class="tags-eyebrow"
5048 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"
5049 >
5050 <span
5051 class="tags-eyebrow-dot"
5052 aria-hidden="true"
6fd5915Claude5053 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)"
398a10cClaude5054 />
5055 Repository · Tags
5056 </div>
5057 <h1
5058 class="tags-title"
5059 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)"
5060 >
5061 <span class="gradient-text">{tags.length}</span>{" "}
5062 tag{tags.length === 1 ? "" : "s"}
5063 </h1>
5064 <p
5065 class="tags-sub"
5066 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5067 >
5068 Named points in the history — typically releases, milestones,
5069 or shipped versions. Click a tag to browse the tree at that
5070 revision.
5071 </p>
5072 </header>
5073
5074 {tags.length === 0 ? (
5075 <div class="commits-empty">
5076 <div class="commits-empty-orb" aria-hidden="true" />
5077 <div class="commits-empty-inner">
5078 <div class="commits-empty-icon" aria-hidden="true">
5079 <svg
5080 width="22"
5081 height="22"
5082 viewBox="0 0 24 24"
5083 fill="none"
5084 stroke="currentColor"
5085 stroke-width="2"
5086 stroke-linecap="round"
5087 stroke-linejoin="round"
5088 >
5089 <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" />
5090 <line x1="7" y1="7" x2="7.01" y2="7" />
5091 </svg>
5092 </div>
5093 <h3 class="commits-empty-title">No tags yet</h3>
5094 <p class="commits-empty-sub">
5095 Tag a commit to mark a release or milestone. From the CLI:{" "}
5096 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5097 </p>
5098 </div>
5099 </div>
5100 ) : (
5101 <div class="tags-list">
5102 {tags.map((t) => {
5103 const hasRelease = tagsWithReleases.has(t.name);
5104 return (
5105 <div class="tags-row">
5106 <div class="tags-row-icon" aria-hidden="true">
5107 <svg
5108 width="14"
5109 height="14"
5110 viewBox="0 0 24 24"
5111 fill="none"
5112 stroke="currentColor"
5113 stroke-width="2"
5114 stroke-linecap="round"
5115 stroke-linejoin="round"
5116 >
5117 <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" />
5118 <line x1="7" y1="7" x2="7.01" y2="7" />
5119 </svg>
5120 </div>
5121 <div class="tags-row-main">
5122 <div class="tags-row-name">
5123 <a
5124 href={`/${owner}/${repo}/tree/${t.name}`}
5125 style="text-decoration:none"
5126 >
5127 <span class="tags-row-version">{t.name}</span>
5128 </a>
5129 </div>
5130 <div class="tags-row-meta">
5131 <span
5132 title={t.date ? new Date(t.date).toISOString() : ""}
5133 >
5134 Tagged {relative(t.date)}
5135 </span>
5136 <span class="sep">·</span>
5137 <a
5138 href={`/${owner}/${repo}/commit/${t.sha}`}
5139 class="tags-row-sha"
5140 title={t.sha}
5141 >
5142 {t.sha.slice(0, 7)}
5143 </a>
5144 </div>
5145 </div>
5146 <div class="tags-row-side">
5147 <a
5148 href={`/${owner}/${repo}/tree/${t.name}`}
5149 class="tags-row-link"
5150 >
5151 Browse files
5152 </a>
5153 {hasRelease && (
5154 <a
5155 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5156 class="tags-row-link"
6fd5915Claude5157 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5158 >
5159 View release
5160 </a>
5161 )}
5162 </div>
5163 </div>
5164 );
5165 })}
5166 </div>
5167 )}
5168 </div>
5169 </Layout>
5170 );
5171});
5172
fc1817aClaude5173// Commit log
5174web.get("/:owner/:repo/commits/:ref?", async (c) => {
5175 const { owner, repo } = c.req.param();
06d5ffeClaude5176 const user = c.get("user");
5bb52faccanty labs5177 const gate = await assertRepoReadable(c, owner, repo);
5178 if (gate) return gate;
fc1817aClaude5179 const ref =
5180 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5181 const branches = await listBranches(owner, repo);
fc1817aClaude5182
5183 const commits = await listCommits(owner, repo, ref, 50);
5184
3951454Claude5185 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5186 // Also resolve repoId here so we can query the recent push for the
5187 // Push Watch live/watch indicator in the header.
3951454Claude5188 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5189 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5190 try {
5191 const [ownerRow] = await db
5192 .select()
5193 .from(users)
5194 .where(eq(users.username, owner))
5195 .limit(1);
5196 if (ownerRow) {
5197 const [repoRow] = await db
5198 .select()
5199 .from(repositories)
5200 .where(
5201 and(
5202 eq(repositories.ownerId, ownerRow.id),
5203 eq(repositories.name, repo)
5204 )
5205 )
5206 .limit(1);
8c790e0Claude5207 if (repoRow) {
5208 // Fetch verifications and recent push in parallel.
5209 const [verRows, rp] = await Promise.all([
5210 commits.length > 0
5211 ? db
5212 .select()
5213 .from(commitVerifications)
5214 .where(
5215 and(
5216 eq(commitVerifications.repositoryId, repoRow.id),
5217 inArray(
5218 commitVerifications.commitSha,
5219 commits.map((c) => c.sha)
5220 )
5221 )
5222 )
5223 : Promise.resolve([]),
5224 getRecentPush(repoRow.id),
5225 ]);
5226 for (const r of verRows) {
3951454Claude5227 verifications[r.commitSha] = {
5228 verified: r.verified,
5229 reason: r.reason,
5230 };
5231 }
8c790e0Claude5232 commitsPageRecentPush = rp;
3951454Claude5233 }
5234 }
5235 } catch {
5236 // DB unavailable — skip the badges gracefully.
5237 }
5238
fc1817aClaude5239 return c.html(
06d5ffeClaude5240 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5241 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5242 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5243 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5244 <div class="commits-hero">
5245 <div class="commits-hero-orb-wrap" aria-hidden="true">
5246 <div class="commits-hero-orb" />
fc1817aClaude5247 </div>
efb11c5Claude5248 <div class="commits-hero-inner">
5249 <div class="commits-eyebrow">
5250 <strong>History</strong> · {owner}/{repo}
5251 </div>
5252 <h1 class="commits-title">
5253 <span class="gradient-text">{commits.length}</span> recent commit
5254 {commits.length === 1 ? "" : "s"} on{" "}
5255 <span class="commits-branch">{ref}</span>
5256 </h1>
5257 <p class="commits-sub">
5258 Browse the project's history. Click any commit to see the full
5259 diff, AI review notes, and signature status.
5260 </p>
5261 </div>
5262 </div>
5263 <div class="commits-toolbar">
5264 <BranchSwitcher
3951454Claude5265 owner={owner}
5266 repo={repo}
efb11c5Claude5267 currentRef={ref}
5268 branches={branches}
5269 pathType="commits"
3951454Claude5270 />
398a10cClaude5271 <div class="commits-toolbar-actions">
5272 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5273 {"⊢"} Branches
5274 </a>
5275 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5276 {"#"} Tags
5277 </a>
5278 </div>
efb11c5Claude5279 </div>
5280 {commits.length === 0 ? (
5281 <div class="commits-empty">
398a10cClaude5282 <div class="commits-empty-orb" aria-hidden="true" />
5283 <div class="commits-empty-inner">
5284 <div class="commits-empty-icon" aria-hidden="true">
5285 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5286 <circle cx="12" cy="12" r="4" />
5287 <line x1="1.05" y1="12" x2="7" y2="12" />
5288 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5289 </svg>
5290 </div>
5291 <h3 class="commits-empty-title">No commits yet</h3>
5292 <p class="commits-empty-sub">
5293 This branch is empty. Push your first commit, or use the
5294 web editor to create a file.
5295 </p>
5296 </div>
efb11c5Claude5297 </div>
5298 ) : (
5299 <div class="commits-list-wrap">
398a10cClaude5300 {(() => {
5301 // Group commits by day for the section headers.
5302 const dayLabel = (iso: string): string => {
5303 const d = new Date(iso);
5304 if (Number.isNaN(d.getTime())) return "Unknown";
5305 const today = new Date();
5306 const yesterday = new Date(today);
5307 yesterday.setDate(today.getDate() - 1);
5308 const sameDay = (a: Date, b: Date) =>
5309 a.getFullYear() === b.getFullYear() &&
5310 a.getMonth() === b.getMonth() &&
5311 a.getDate() === b.getDate();
5312 if (sameDay(d, today)) return "Today";
5313 if (sameDay(d, yesterday)) return "Yesterday";
5314 return d.toLocaleDateString("en-US", {
5315 month: "long",
5316 day: "numeric",
5317 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5318 });
5319 };
5320 const relative = (iso: string): string => {
5321 const d = new Date(iso);
5322 if (Number.isNaN(d.getTime())) return "";
5323 const diff = Date.now() - d.getTime();
5324 const m = Math.floor(diff / 60000);
5325 if (m < 1) return "just now";
5326 if (m < 60) return `${m} min ago`;
5327 const h = Math.floor(m / 60);
5328 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5329 const dd = Math.floor(h / 24);
5330 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5331 return d.toLocaleDateString("en-US", {
5332 month: "short",
5333 day: "numeric",
5334 });
5335 };
5336 const initial = (name: string): string =>
5337 (name || "?").trim().charAt(0).toUpperCase() || "?";
5338 // Build groups preserving order.
5339 const groups: Array<{ label: string; items: typeof commits }> = [];
5340 let lastLabel = "";
5341 for (const cm of commits) {
5342 const label = dayLabel(cm.date);
5343 if (label !== lastLabel) {
5344 groups.push({ label, items: [] });
5345 lastLabel = label;
5346 }
5347 groups[groups.length - 1].items.push(cm);
5348 }
5349 return groups.map((g) => (
5350 <>
5351 <div class="commits-day-head">
5352 <span class="commits-day-head-dot" aria-hidden="true" />
5353 Commits on {g.label}
5354 </div>
5355 {g.items.map((cm) => {
5356 const v = verifications[cm.sha];
5357 return (
5358 <div class="commits-row">
5359 <div class="commits-avatar" aria-hidden="true">
5360 {initial(cm.author)}
5361 </div>
5362 <div class="commits-row-body">
5363 <div class="commits-row-msg">
5364 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5365 {cm.message}
5366 </a>
5367 {v?.verified && (
5368 <span
5369 class="commits-row-verified"
5370 title="Signed with a registered key"
5371 >
5372 Verified
5373 </span>
5374 )}
5375 </div>
5376 <div class="commits-row-meta">
5377 <strong>{cm.author}</strong>
5378 <span class="sep">·</span>
5379 <span
5380 class="commits-row-time"
5381 title={new Date(cm.date).toISOString()}
5382 >
5383 committed {relative(cm.date)}
5384 </span>
5385 {cm.parentShas.length > 1 && (
5386 <>
5387 <span class="sep">·</span>
5388 <span>merge of {cm.parentShas.length} parents</span>
5389 </>
5390 )}
5391 </div>
5392 </div>
5393 <div class="commits-row-side">
5394 <a
5395 href={`/${owner}/${repo}/commit/${cm.sha}`}
5396 class="commits-row-sha"
5397 title={cm.sha}
5398 >
5399 {cm.sha.slice(0, 7)}
5400 </a>
8c790e0Claude5401 <a
5402 href={`/${owner}/${repo}/push/${cm.sha}`}
5403 class="commits-row-watch"
5404 title="Watch gate + deploy results for this push"
5405 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5406 >
5407 <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">
5408 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5409 <circle cx="12" cy="12" r="3" />
5410 </svg>
5411 </a>
398a10cClaude5412 <button
5413 type="button"
5414 class="commits-row-copy"
5415 data-copy-sha={cm.sha}
5416 title="Copy full SHA"
5417 aria-label={`Copy SHA ${cm.sha}`}
5418 >
5419 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5420 <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" />
5421 <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" />
5422 </svg>
5423 </button>
5424 </div>
5425 </div>
5426 );
5427 })}
5428 </>
5429 ));
5430 })()}
efb11c5Claude5431 </div>
fc1817aClaude5432 )}
398a10cClaude5433 <script
5434 dangerouslySetInnerHTML={{
5435 __html: `
5436 (function(){
5437 document.addEventListener('click', function(e){
5438 var t = e.target; if (!t) return;
5439 var btn = t.closest && t.closest('[data-copy-sha]');
5440 if (!btn) return;
5441 e.preventDefault();
5442 var sha = btn.getAttribute('data-copy-sha') || '';
5443 if (!navigator.clipboard) return;
5444 navigator.clipboard.writeText(sha).then(function(){
5445 btn.classList.add('is-copied');
5446 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5447 }).catch(function(){});
5448 });
5449 })();
5450 `,
5451 }}
5452 />
fc1817aClaude5453 </Layout>
5454 );
5455});
5456
5457// Single commit with diff
5458web.get("/:owner/:repo/commit/:sha", async (c) => {
5459 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5460 const user = c.get("user");
5bb52faccanty labs5461 const gate = await assertRepoReadable(c, owner, repo);
5462 if (gate) return gate;
fc1817aClaude5463
05b973eClaude5464 // Fetch commit, full message, and diff in parallel
5465 const [commit, fullMessage, diffResult] = await Promise.all([
5466 getCommit(owner, repo, sha),
5467 getCommitFullMessage(owner, repo, sha),
5468 getDiff(owner, repo, sha),
5469 ]);
fc1817aClaude5470 if (!commit) {
5471 return c.html(
06d5ffeClaude5472 <Layout title="Not Found" user={user}>
fc1817aClaude5473 <div class="empty-state">
5474 <h2>Commit not found</h2>
5475 </div>
5476 </Layout>,
5477 404
5478 );
5479 }
5480
3951454Claude5481 // Block J3 — try to verify this commit's signature.
5482 let verification:
5483 | { verified: boolean; reason: string; signatureType: string | null }
5484 | null = null;
0cdfd89Claude5485 // Block J8 — external CI commit statuses rollup.
5486 let statusCombined:
5487 | {
5488 state: "pending" | "success" | "failure";
5489 total: number;
5490 contexts: Array<{
5491 context: string;
5492 state: string;
5493 description: string | null;
5494 targetUrl: string | null;
5495 }>;
5496 }
5497 | null = null;
3951454Claude5498 try {
5499 const [ownerRow] = await db
5500 .select()
5501 .from(users)
5502 .where(eq(users.username, owner))
5503 .limit(1);
5504 if (ownerRow) {
5505 const [repoRow] = await db
5506 .select()
5507 .from(repositories)
5508 .where(
5509 and(
5510 eq(repositories.ownerId, ownerRow.id),
5511 eq(repositories.name, repo)
5512 )
5513 )
5514 .limit(1);
5515 if (repoRow) {
5516 const { verifyCommit } = await import("../lib/signatures");
5517 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5518 verification = {
5519 verified: v.verified,
5520 reason: v.reason,
5521 signatureType: v.signatureType,
5522 };
0cdfd89Claude5523 try {
5524 const { combinedStatus } = await import("../lib/commit-statuses");
5525 const combined = await combinedStatus(repoRow.id, commit.sha);
5526 if (combined.total > 0) {
5527 statusCombined = {
5528 state: combined.state as any,
5529 total: combined.total,
5530 contexts: combined.contexts.map((c) => ({
5531 context: c.context,
5532 state: c.state,
5533 description: c.description,
5534 targetUrl: c.targetUrl,
5535 })),
5536 };
5537 }
5538 } catch {
5539 statusCombined = null;
5540 }
3951454Claude5541 }
5542 }
5543 } catch {
5544 verification = null;
5545 }
5546
05b973eClaude5547 const { files, raw } = diffResult;
fc1817aClaude5548
efb11c5Claude5549 // Diff stats: count additions / deletions across all files for the
5550 // header summary bar. Computed here from the parsed diff so we don't
5551 // touch the DiffView component.
5552 let additions = 0;
5553 let deletions = 0;
5554 for (const f of files) {
5555 const hunks = (f as any).hunks as Array<any> | undefined;
5556 if (Array.isArray(hunks)) {
5557 for (const h of hunks) {
5558 const lines = (h?.lines || []) as Array<any>;
5559 for (const ln of lines) {
5560 const t = ln?.type || ln?.kind;
5561 if (t === "add" || t === "added" || t === "+") additions += 1;
5562 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5563 deletions += 1;
5564 }
5565 }
5566 }
5567 }
5568 // Fall back: scan raw if file-level counting yielded zero (it's just a
5569 // header polish — never let a parsing miss break the page).
5570 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5571 for (const line of raw.split("\n")) {
5572 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5573 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5574 }
5575 }
5576 const fileCount = files.length;
5577
fc1817aClaude5578 return c.html(
06d5ffeClaude5579 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5580 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5581 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5582 <div class="commit-detail-card">
5583 <div class="commit-detail-eyebrow">
5584 <strong>Commit</strong>
5585 <span class="commit-detail-sha-pill" title={commit.sha}>
5586 {commit.sha.slice(0, 7)}
5587 </span>
3951454Claude5588 {verification && verification.reason !== "unsigned" && (
5589 <span
efb11c5Claude5590 class={`commit-detail-verify ${
3951454Claude5591 verification.verified
efb11c5Claude5592 ? "commit-detail-verify-ok"
5593 : "commit-detail-verify-warn"
3951454Claude5594 }`}
5595 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5596 >
5597 {verification.verified ? "Verified" : verification.reason}
5598 </span>
5599 )}
fc1817aClaude5600 </div>
efb11c5Claude5601 <h1 class="commit-detail-title">{commit.message}</h1>
5602 {fullMessage !== commit.message && (
5603 <pre class="commit-detail-body">{fullMessage}</pre>
5604 )}
5605 <div class="commit-detail-meta">
5606 <span class="commit-detail-author">
5607 <strong>{commit.author}</strong> committed on{" "}
5608 {new Date(commit.date).toLocaleDateString("en-US", {
5609 month: "long",
5610 day: "numeric",
5611 year: "numeric",
5612 })}
5613 </span>
fc1817aClaude5614 {commit.parentShas.length > 0 && (
efb11c5Claude5615 <span class="commit-detail-parents">
5616 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5617 {commit.parentShas.map((p, idx) => (
5618 <>
5619 {idx > 0 && " "}
5620 <a
5621 href={`/${owner}/${repo}/commit/${p}`}
5622 class="commit-detail-sha-link"
5623 >
5624 {p.slice(0, 7)}
5625 </a>
5626 </>
fc1817aClaude5627 ))}
5628 </span>
5629 )}
5630 </div>
efb11c5Claude5631 <div class="commit-detail-stats">
5632 <span class="commit-detail-stat">
5633 <strong>{fileCount}</strong>{" "}
5634 file{fileCount === 1 ? "" : "s"} changed
5635 </span>
5636 <span class="commit-detail-stat commit-detail-stat-add">
5637 <span class="commit-detail-stat-mark">+</span>
5638 <strong>{additions}</strong>
5639 </span>
5640 <span class="commit-detail-stat commit-detail-stat-del">
5641 <span class="commit-detail-stat-mark">−</span>
5642 <strong>{deletions}</strong>
5643 </span>
5644 <span class="commit-detail-sha-full" title="Full SHA">
5645 {commit.sha}
5646 </span>
5647 </div>
0cdfd89Claude5648 {statusCombined && (
efb11c5Claude5649 <div class="commit-detail-checks">
5650 <div class="commit-detail-checks-head">
5651 <strong>Checks</strong>
5652 <span class="commit-detail-checks-summary">
5653 {statusCombined.total} total ·{" "}
5654 <span
5655 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5656 >
5657 {statusCombined.state}
5658 </span>
0cdfd89Claude5659 </span>
efb11c5Claude5660 </div>
5661 <div class="commit-detail-check-row">
0cdfd89Claude5662 {statusCombined.contexts.map((cx) => (
5663 <span
efb11c5Claude5664 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5665 title={cx.description || cx.context}
5666 >
5667 {cx.targetUrl ? (
efb11c5Claude5668 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5669 {cx.context}: {cx.state}
5670 </a>
5671 ) : (
5672 <>
5673 {cx.context}: {cx.state}
5674 </>
5675 )}
5676 </span>
5677 ))}
5678 </div>
5679 </div>
5680 )}
fc1817aClaude5681 </div>
ea9ed4cClaude5682 <DiffView
5683 raw={raw}
5684 files={files}
5685 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5686 />
fc1817aClaude5687 </Layout>
5688 );
5689});
5690
79136bbClaude5691// Raw file download
5692web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5693 const { owner, repo } = c.req.param();
5bb52faccanty labs5694 const gate = await assertRepoReadable(c, owner, repo);
5695 if (gate) return gate;
79136bbClaude5696 const refAndPath = c.req.param("ref");
5697
5698 const branches = await listBranches(owner, repo);
5699 let ref = "";
5700 let filePath = "";
5701
5702 for (const branch of branches) {
5703 if (refAndPath.startsWith(branch + "/")) {
5704 ref = branch;
5705 filePath = refAndPath.slice(branch.length + 1);
5706 break;
5707 }
5708 }
5709
5710 if (!ref) {
5711 const slashIdx = refAndPath.indexOf("/");
5712 if (slashIdx === -1) return c.text("Not found", 404);
5713 ref = refAndPath.slice(0, slashIdx);
5714 filePath = refAndPath.slice(slashIdx + 1);
5715 }
5716
5717 const data = await getRawBlob(owner, repo, ref, filePath);
5718 if (!data) return c.text("Not found", 404);
5719
5720 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5721 return new Response(data as BodyInit, {
79136bbClaude5722 headers: {
5723 "Content-Type": "application/octet-stream",
5724 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5725 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5726 },
5727 });
5728});
5729
5730// Blame view
5731web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5732 const { owner, repo } = c.req.param();
5733 const user = c.get("user");
5bb52faccanty labs5734 const gate = await assertRepoReadable(c, owner, repo);
5735 if (gate) return gate;
79136bbClaude5736 const refAndPath = c.req.param("ref");
5737
5738 const branches = await listBranches(owner, repo);
5739 let ref = "";
5740 let filePath = "";
5741
5742 for (const branch of branches) {
5743 if (refAndPath.startsWith(branch + "/")) {
5744 ref = branch;
5745 filePath = refAndPath.slice(branch.length + 1);
5746 break;
5747 }
5748 }
5749
5750 if (!ref) {
5751 const slashIdx = refAndPath.indexOf("/");
5752 if (slashIdx === -1) return c.text("Not found", 404);
5753 ref = refAndPath.slice(0, slashIdx);
5754 filePath = refAndPath.slice(slashIdx + 1);
5755 }
5756
5757 const blameLines = await getBlame(owner, repo, ref, filePath);
5758 if (blameLines.length === 0) {
5759 return c.html(
5760 <Layout title="Not Found" user={user}>
5761 <div class="empty-state">
5762 <h2>File not found</h2>
5763 </div>
5764 </Layout>,
5765 404
5766 );
5767 }
5768
5769 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5770 // Unique contributors (by author) tracked once for the header chip.
5771 const blameAuthors = new Set<string>();
5772 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5773
5774 return c.html(
5775 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5776 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5777 <RepoHeader owner={owner} repo={repo} />
5778 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5779 <header class="blame-head">
5780 <div class="blame-eyebrow">
5781 <span class="blame-eyebrow-dot" aria-hidden="true" />
5782 Blame · Line-by-line history
5783 </div>
5784 <h1 class="blame-title">
5785 <code>{fileName}</code>
5786 </h1>
5787 <p class="blame-sub">
5788 Each line is annotated with the commit that last touched it.
5789 Click any SHA to jump to that commit and see the surrounding
5790 change.
5791 </p>
5792 </header>
efb11c5Claude5793 <div class="blame-toolbar">
5794 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5795 </div>
398a10cClaude5796 <div class="blame-card">
5797 <div class="blame-header">
efb11c5Claude5798 <div class="blame-header-meta">
5799 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5800 <span class="blame-header-name">{fileName}</span>
5801 <span class="blame-header-tag">Blame</span>
5802 <span class="blame-header-stats">
5803 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5804 {blameAuthors.size} contributor
5805 {blameAuthors.size === 1 ? "" : "s"}
5806 </span>
5807 </div>
5808 <div class="blame-header-actions">
5809 <a
5810 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5811 class="blob-pill"
5812 >
5813 Normal view
5814 </a>
5815 <a
5816 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5817 class="blob-pill"
5818 >
5819 Raw
5820 </a>
5821 </div>
79136bbClaude5822 </div>
398a10cClaude5823 <div style="overflow-x:auto">
5824 <table class="blame-table">
79136bbClaude5825 <tbody>
5826 {blameLines.map((line, i) => {
5827 const showInfo =
5828 i === 0 || blameLines[i - 1].sha !== line.sha;
5829 return (
398a10cClaude5830 <tr class={showInfo ? "blame-row-first" : ""}>
5831 <td class="blame-gutter">
79136bbClaude5832 {showInfo && (
398a10cClaude5833 <span class="blame-gutter-inner">
79136bbClaude5834 <a
5835 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5836 class="blame-gutter-sha"
5837 title={`Commit ${line.sha}`}
79136bbClaude5838 >
5839 {line.sha.slice(0, 7)}
398a10cClaude5840 </a>
5841 <span class="blame-gutter-author" title={line.author}>
5842 {line.author}
5843 </span>
5844 </span>
79136bbClaude5845 )}
5846 </td>
398a10cClaude5847 <td class="blame-line-num">{line.lineNum}</td>
5848 <td class="blame-line-content">{line.content}</td>
79136bbClaude5849 </tr>
5850 );
5851 })}
5852 </tbody>
5853 </table>
5854 </div>
5855 </div>
5856 </Layout>
5857 );
5858});
5859
53c9249Claude5860// Search — keyword + optional Claude semantic mode
79136bbClaude5861web.get("/:owner/:repo/search", async (c) => {
5862 const { owner, repo } = c.req.param();
5863 const user = c.get("user");
5bb52faccanty labs5864 const gate = await assertRepoReadable(c, owner, repo);
5865 if (gate) return gate;
79136bbClaude5866 const q = c.req.query("q") || "";
53c9249Claude5867 const aiAvailable = isAiAvailable();
5868 // Default to semantic when Claude is available and no explicit mode set.
5869 const modeParam = c.req.query("mode");
5870 const mode: "semantic" | "keyword" =
5871 modeParam === "keyword"
5872 ? "keyword"
5873 : modeParam === "semantic"
5874 ? "semantic"
5875 : aiAvailable
5876 ? "semantic"
5877 : "keyword";
5878 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5879
5880 if (!(await repoExists(owner, repo))) return c.notFound();
5881
5882 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5883
5884 // Keyword results (always available as fallback / when in keyword mode)
5885 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5886 // Semantic results (Claude-powered)
5887 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5888 let semanticHits: SemanticHit[] = [];
5889 let semanticMode: "semantic" | "keyword" = "semantic";
5890 let quotaExceeded = false;
79136bbClaude5891
5892 if (q.trim()) {
53c9249Claude5893 if (isSemantic) {
5894 // Resolve repo DB id for caching
5895 const [ownerRow] = await db
5896 .select({ id: users.id })
5897 .from(users)
5898 .where(eq(users.username, owner))
5899 .limit(1);
5900 const [repoRow] = ownerRow
5901 ? await db
5902 .select({ id: repositories.id })
5903 .from(repositories)
5904 .where(
5905 and(
5906 eq(repositories.ownerId, ownerRow.id),
5907 eq(repositories.name, repo)
5908 )
5909 )
5910 .limit(1)
5911 : [];
5912
5913 const rateLimitKey = user
5914 ? `user:${user.id}`
5915 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5916
5917 const searchResult = await claudeSemanticSearch(
5918 owner,
5919 repo,
5920 repoRow?.id ?? `${owner}/${repo}`,
5921 q.trim(),
5922 { branch: defaultBranch, rateLimitKey }
5923 );
5924 semanticHits = searchResult.results;
5925 semanticMode = searchResult.mode;
5926 quotaExceeded = searchResult.quotaExceeded;
5927 } else {
5928 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5929 }
79136bbClaude5930 }
5931
53c9249Claude5932 const aiSearchCss = `
5933 /* ─── Semantic search mode toggle ─── */
5934 .search-mode-bar {
5935 display: flex;
5936 align-items: center;
5937 gap: 8px;
5938 margin-bottom: 14px;
5939 flex-wrap: wrap;
5940 }
5941 .search-mode-label {
5942 font-size: 12.5px;
5943 color: var(--text-muted);
5944 font-weight: 500;
5945 }
5946 .search-mode-toggle {
5947 display: inline-flex;
5948 background: var(--bg-elevated);
5949 border: 1px solid var(--border);
5950 border-radius: 9999px;
5951 padding: 3px;
5952 gap: 2px;
5953 }
5954 .search-mode-btn {
5955 display: inline-flex;
5956 align-items: center;
5957 gap: 5px;
5958 padding: 5px 12px;
5959 border-radius: 9999px;
5960 font-size: 12.5px;
5961 font-weight: 500;
5962 color: var(--text-muted);
5963 text-decoration: none;
5964 transition: color 120ms ease, background 120ms ease;
5965 cursor: pointer;
5966 border: none;
5967 background: none;
5968 font: inherit;
5969 }
5970 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5971 .search-mode-btn.active {
6fd5915Claude5972 background: rgba(91,110,232,0.15);
53c9249Claude5973 color: var(--text-strong);
5974 }
5975 .search-mode-ai-badge {
5976 display: inline-flex;
5977 align-items: center;
5978 gap: 4px;
5979 padding: 2px 8px;
5980 border-radius: 9999px;
5981 font-size: 11px;
5982 font-weight: 600;
6fd5915Claude5983 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5984 color: #c4b5fd;
6fd5915Claude5985 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5986 margin-left: 2px;
5987 }
5988 .search-quota-warn {
5989 font-size: 12.5px;
5990 color: var(--text-muted);
5991 padding: 6px 12px;
5992 background: rgba(255,200,0,0.06);
5993 border: 1px solid rgba(255,200,0,0.20);
5994 border-radius: 8px;
5995 }
5996
5997 /* ─── Semantic result cards ─── */
5998 .sem-results { display: flex; flex-direction: column; gap: 10px; }
5999 .sem-result {
6000 padding: 14px 16px;
6001 background: var(--bg-elevated);
6002 border: 1px solid var(--border);
6003 border-radius: 12px;
6004 transition: border-color 120ms ease;
6005 }
6006 .sem-result:hover { border-color: var(--border-strong); }
6007 .sem-result-head {
6008 display: flex;
6009 align-items: flex-start;
6010 justify-content: space-between;
6011 gap: 10px;
6012 flex-wrap: wrap;
6013 margin-bottom: 6px;
6014 }
6015 .sem-result-path {
6016 font-family: var(--font-mono);
6017 font-size: 13px;
6018 font-weight: 600;
6019 color: var(--text-strong);
6020 text-decoration: none;
6021 word-break: break-all;
6022 }
6023 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6024 .sem-result-conf {
6025 display: inline-flex;
6026 align-items: center;
6027 gap: 4px;
6028 padding: 2px 9px;
6029 border-radius: 9999px;
6030 font-size: 11.5px;
6031 font-weight: 600;
6032 white-space: nowrap;
6033 flex-shrink: 0;
6034 }
e589f77ccantynz-alt6035 .sem-conf-strong { background: rgba(52,211,153,0.12); color: var(--green); border: 1px solid rgba(52,211,153,0.30); }
6036 .sem-conf-possible { background: rgba(91,110,232,0.12); color: var(--accent); border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6037 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6038 .sem-result-reason {
6039 font-size: 12.5px;
6040 color: var(--text-muted);
6041 margin-bottom: 8px;
6042 line-height: 1.5;
6043 }
6044 .sem-result-snippet {
6045 margin: 0;
6046 padding: 10px 12px;
6047 background: rgba(0,0,0,0.22);
6048 border: 1px solid var(--border);
6049 border-radius: 8px;
6050 font-family: var(--font-mono);
6051 font-size: 12px;
6052 line-height: 1.55;
6053 color: var(--text);
6054 overflow-x: auto;
6055 white-space: pre-wrap;
6056 word-break: break-word;
6057 max-height: 240px;
6058 overflow-y: auto;
6059 }
6060 `;
6061
6062 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6063 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6064
79136bbClaude6065 return c.html(
6066 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6067 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6068 <RepoHeader owner={owner} repo={repo} />
6069 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6070 <div class="search-hero">
6071 <div class="search-eyebrow">
6072 <strong>Search</strong> · {owner}/{repo}
6073 </div>
6074 <h1 class="search-title">
53c9249Claude6075 {isSemantic ? (
6076 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6077 ) : (
6078 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6079 )}
efb11c5Claude6080 </h1>
6081 <form
6082 method="get"
6083 action={`/${owner}/${repo}/search`}
6084 class="search-form"
6085 role="search"
6086 >
53c9249Claude6087 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6088 <div class="search-input-wrap">
6089 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6090 <input
6091 type="text"
6092 name="q"
6093 value={q}
53c9249Claude6094 placeholder={
6095 isSemantic
6096 ? "Ask a question or describe what you're looking for…"
6097 : "Search code on the default branch…"
6098 }
efb11c5Claude6099 aria-label="Search code"
6100 class="search-input"
6101 autocomplete="off"
6102 autofocus
6103 />
6104 </div>
6105 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6106 Search
6107 </button>
efb11c5Claude6108 </form>
6109 </div>
53c9249Claude6110
6111 {/* Mode toggle */}
6112 <div class="search-mode-bar">
6113 <span class="search-mode-label">Mode:</span>
6114 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6115 {aiAvailable ? (
6116 <a
6117 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6118 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6119 aria-pressed={isSemantic ? "true" : "false"}
6120 >
6121 {"✨"} Semantic AI
6122 <span class="search-mode-ai-badge">AI-powered</span>
6123 </a>
6124 ) : null}
6125 <a
6126 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6127 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6128 aria-pressed={!isSemantic ? "true" : "false"}
6129 >
6130 {"⌕"} Keyword
6131 </a>
6132 </div>
6133 {isSemantic && aiAvailable && (
6134 <span style="font-size:12px;color:var(--text-muted)">
6135 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6136 </span>
53c9249Claude6137 )}
6138 {quotaExceeded && (
6139 <span class="search-quota-warn">
6140 Semantic search quota reached — showing keyword results
efb11c5Claude6141 </span>
53c9249Claude6142 )}
6143 </div>
6144
6145 {/* ─── Semantic results ─── */}
6146 {isSemantic && q && (
6147 <>
6148 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6149 <div class="search-quota-warn" style="margin-bottom:10px">
6150 Falling back to keyword search — Claude found no relevant files.
6151 </div>
6152 )}
6153 {semanticHits.length === 0 ? (
6154 <div class="search-empty">
6155 <p>
6156 No matches for <strong>"{q}"</strong>.{" "}
6157 Try a different phrasing or{" "}
6158 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6159 switch to keyword search
6160 </a>.
6161 </p>
6162 </div>
6163 ) : (
6164 <>
6165 <div class="search-results-head">
6166 <span class="search-results-count">
6167 <strong>{semanticHits.length}</strong> result
6168 {semanticHits.length !== 1 ? "s" : ""}
6169 </span>
6170 <span class="search-results-query">
6171 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6172 <span class="search-results-q">"{q}"</span>
6173 </span>
6174 </div>
6175 <div class="sem-results">
6176 {semanticHits.map((h) => {
6177 const confClass =
6178 h.confidence > 0.7
6179 ? "sem-conf-strong"
6180 : h.confidence > 0.4
6181 ? "sem-conf-possible"
6182 : "sem-conf-weak";
6183 const confLabel =
6184 h.confidence > 0.7
6185 ? "Strong match"
6186 : h.confidence > 0.4
6187 ? "Possible match"
6188 : "Weak match";
6189 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6190 const preview =
6191 h.snippet.length > 800
6192 ? h.snippet.slice(0, 800) + "\n…"
6193 : h.snippet;
6194 return (
6195 <div class="sem-result">
6196 <div class="sem-result-head">
6197 <a href={href} class="sem-result-path">
6198 {h.file}
6199 {h.lineNumber ? (
6200 <span style="color:var(--text-muted);font-weight:500">
6201 :{h.lineNumber}
6202 </span>
6203 ) : null}
6204 </a>
6205 <span class={`sem-result-conf ${confClass}`}>
6206 {confLabel}
6207 </span>
6208 </div>
6209 {h.reason && (
6210 <p class="sem-result-reason">{h.reason}</p>
6211 )}
6212 {preview && (
6213 <pre class="sem-result-snippet">{preview}</pre>
6214 )}
6215 </div>
6216 );
6217 })}
6218 </div>
6219 </>
6220 )}
6221 </>
79136bbClaude6222 )}
53c9249Claude6223
6224 {/* ─── Keyword results ─── */}
6225 {!isSemantic && q && (
6226 <>
6227 <div class="search-results-head">
6228 <span class="search-results-count">
6229 <strong>{keywordResults.length}</strong> result
6230 {keywordResults.length !== 1 ? "s" : ""}
6231 </span>
6232 <span class="search-results-query">
6233 for <span class="search-results-q">"{q}"</span> on{" "}
6234 <code>{defaultBranch}</code>
6235 </span>
6236 </div>
6237 {keywordResults.length === 0 ? (
6238 <div class="search-empty">
6239 <p>
6240 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6241 {aiAvailable && (
6242 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6243 try AI semantic search
79136bbClaude6244 </a>
53c9249Claude6245 )}.
6246 </p>
6247 </div>
6248 ) : (
6249 <div class="search-results">
6250 {(() => {
6251 const grouped: Record<
6252 string,
6253 Array<{ lineNum: number; line: string }>
6254 > = {};
6255 for (const r of keywordResults) {
6256 if (!grouped[r.file]) grouped[r.file] = [];
6257 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6258 }
6259 return Object.entries(grouped).map(([file, matches]) => (
6260 <div class="search-file diff-file">
6261 <div class="search-file-head diff-file-header">
6262 <a
6263 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6264 class="search-file-link"
6265 >
6266 {file}
6267 </a>
6268 <span class="search-file-count">
6269 {matches.length} match{matches.length === 1 ? "" : "es"}
6270 </span>
6271 </div>
6272 <div class="blob-code">
6273 <table>
6274 <tbody>
6275 {matches.map((m) => (
6276 <tr>
6277 <td class="line-num">{m.lineNum}</td>
6278 <td class="line-content">{m.line}</td>
6279 </tr>
6280 ))}
6281 </tbody>
6282 </table>
6283 </div>
6284 </div>
6285 ));
6286 })()}
6287 </div>
6288 )}
6289 </>
6290 )}
79136bbClaude6291 </Layout>
6292 );
6293});
6294
fc1817aClaude6295export default web;