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.tsxBlame6314 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.
5bb52faccanty labs139 const [ownerRow] = await db
527e1e3ccantynz-alt140 .select({ id: users.id, username: users.username })
5bb52faccanty labs141 .from(users)
527e1e3ccantynz-alt142 .where(sql`lower(${users.username}) = lower(${owner})`)
5bb52faccanty labs143 .limit(1);
144 if (!ownerRow) return notFound();
145
146 const [repoRow] = await db
527e1e3ccantynz-alt147 .select({
148 id: repositories.id,
149 name: repositories.name,
150 isPrivate: repositories.isPrivate,
151 })
5bb52faccanty labs152 .from(repositories)
153 .where(
527e1e3ccantynz-alt154 and(
155 eq(repositories.ownerId, ownerRow.id),
156 sql`lower(${repositories.name}) = lower(${repo})`
157 )
5bb52faccanty labs158 )
159 .limit(1);
160 if (!repoRow) return notFound();
161
162 const access = await resolveRepoAccess({
163 repoId: repoRow.id,
164 userId: user?.id ?? null,
165 isPublic: !repoRow.isPrivate,
166 });
167 // Positive denial: the repo exists and the viewer lacks read access.
168 // Return 404 (not 403) so we never confirm a private repo's existence.
169 if (!satisfiesAccess(access, "read")) return notFound();
170
527e1e3ccantynz-alt171 // Canonical-casing redirect. If the URL used a different casing than the
172 // stored slug (e.g. /ccantynz/gluecron.com for Gluecron.com), 302 to the
173 // canonical path so every downstream git-on-disk op receives the real
174 // casing and never 404s on the case-sensitive filesystem (the "R3" trap).
175 // Done AFTER the access check so a private repo's exact casing is never
176 // revealed to a caller who can't read it.
177 if (ownerRow.username !== owner || repoRow.name !== repo) {
178 const segs = c.req.path.split("/");
179 // segs = ["", "<owner>", "<repo>", ...rest]
180 segs[1] = encodeURIComponent(ownerRow.username);
181 segs[2] = encodeURIComponent(repoRow.name);
182 const search = new URL(c.req.url).search;
183 return c.redirect(segs.join("/") + search, 302);
184 }
185
5bb52faccanty labs186 return null;
187 } catch (err) {
6b75ac1ccanty labs188 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
189 // lookup errored, so we cannot positively establish that this repo is
190 // public. Denying is the only safe choice for an access-control gate —
191 // failing open here would re-expose private repos during a DB hiccup,
192 // which is the exact vulnerability this function exists to close.
5bb52faccanty labs193 console.warn(
6b75ac1ccanty labs194 `[web] assertRepoReadable: access check failed for ${owner}/${repo} — denying:`,
5bb52faccanty labs195 err instanceof Error ? err.message : err
196 );
6b75ac1ccanty labs197 return notFound();
5bb52faccanty labs198 }
199}
200
ebaae0fClaude201// ---------------------------------------------------------------------------
202// Repo AI stats — computed once per repo home page load, best-effort.
203// Fast because every WHERE clause is bounded to a 7-day window.
204// ---------------------------------------------------------------------------
205interface RepoAiStats {
206 mergedCount: number;
207 reviewCount: number;
208 securityAlertCount: number;
209 hoursSaved: string;
210}
211
212async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
213 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
214
215 // 1. AI-merged PRs this week: activity_feed entries with action =
216 // 'auto_merge.merged' in the last 7 days for this repo.
217 // This is cheaper than a JOIN on pull_requests and covers both the
218 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
219 const [mergedRow] = await db
220 .select({ n: count() })
221 .from(activityFeed)
222 .where(
223 and(
224 eq(activityFeed.repositoryId, repoId),
225 eq(activityFeed.action, "auto_merge.merged"),
226 gte(activityFeed.createdAt, since)
227 )
228 );
229 const mergedCount = Number(mergedRow?.n ?? 0);
230
231 // 2. AI reviews this week: pr_comments where is_ai_review = true in
232 // the last 7 days, joined through pull_requests to scope to this repo.
233 const [reviewRow] = await db
234 .select({ n: count() })
235 .from(prComments)
236 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
237 .where(
238 and(
239 eq(pullRequests.repositoryId, repoId),
240 eq(prComments.isAiReview, true),
241 gte(prComments.createdAt, since)
242 )
243 );
244 const reviewCount = Number(reviewRow?.n ?? 0);
245
246 // 3. Open security alerts: open issues that carry a label whose name
247 // contains 'security' (case-insensitive) for this repo.
248 const [secRow] = await db
249 .select({ n: count() })
250 .from(issues)
251 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
252 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
253 .where(
254 and(
255 eq(issues.repositoryId, repoId),
256 eq(issues.state, "open"),
257 sql`lower(${labels.name}) like '%security%'`
258 )
259 );
260 const securityAlertCount = Number(secRow?.n ?? 0);
261
262 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
263 const hours = mergedCount * 1.5 + reviewCount * 0.5;
264 const hoursSaved = hours.toFixed(1);
265
266 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
267}
268
8c790e0Claude269/**
270 * Query the most recent push to a repo from the activity_feed table.
271 * Returns a RecentPush if there's been a push in the last 24 hours,
272 * or null otherwise. Used by the RepoHeader live/watch indicator.
273 *
274 * Queries activity_feed where action='push' and targetId holds the commit SHA.
275 */
276async function getRecentPush(repoId: string): Promise<RecentPush | null> {
277 try {
278 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
279 const [row] = await db
280 .select({
281 targetId: activityFeed.targetId,
282 createdAt: activityFeed.createdAt,
283 })
284 .from(activityFeed)
285 .where(
286 and(
287 eq(activityFeed.repositoryId, repoId),
288 eq(activityFeed.action, "push"),
289 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
290 )
291 )
292 .orderBy(desc(activityFeed.createdAt))
293 .limit(1);
294 if (!row || !row.targetId) return null;
295 return {
296 sha: row.targetId,
297 ageMs: Date.now() - new Date(row.createdAt).getTime(),
298 };
299 } catch {
300 // DB unavailable or schema mismatch — degrade gracefully.
301 return null;
302 }
303}
304
efb11c5Claude305/**
306 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
307 *
308 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
309 * scope is route-local and `layout.tsx` is locked. Each polished handler
310 * injects this via a `<style>` tag; the rules are namespaced by surface
311 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
312 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
313 * `.repo-home-*` styling Agent A already shipped.
314 */
315const codeBrowseCss = `
316 /* ───────── shared primitives ───────── */
317 .cb-hairline::before,
318 .new-repo-hero::before,
319 .profile-hero::before,
320 .commits-hero::before,
321 .commit-detail-card::before,
322 .search-hero::before {
323 content: '';
324 position: absolute;
325 top: 0; left: 0; right: 0;
326 height: 2px;
6fd5915Claude327 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude328 opacity: 0.7;
329 pointer-events: none;
330 }
331 @keyframes cbHeroOrb {
332 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
333 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
334 }
335 @media (prefers-reduced-motion: reduce) {
336 .new-repo-hero-orb,
337 .profile-hero-orb,
338 .commits-hero-orb { animation: none; }
339 }
340
341 /* ───────── new-repo ───────── */
342 .new-repo-hero {
343 position: relative;
344 margin-bottom: var(--space-5);
345 padding: var(--space-5) var(--space-6);
346 background: var(--bg-elevated);
347 border: 1px solid var(--border);
348 border-radius: 16px;
349 overflow: hidden;
350 }
351 .new-repo-hero-orb-wrap {
352 position: absolute;
353 inset: -25% -10% auto auto;
354 width: 360px;
355 height: 360px;
356 pointer-events: none;
357 z-index: 0;
358 }
359 .new-repo-hero-orb {
360 position: absolute;
361 inset: 0;
6fd5915Claude362 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude363 filter: blur(80px);
364 opacity: 0.7;
365 animation: cbHeroOrb 14s ease-in-out infinite;
366 }
367 .new-repo-hero-inner { position: relative; z-index: 1; }
368 .new-repo-eyebrow {
369 font-size: 12px;
370 font-family: var(--font-mono);
371 color: var(--text-muted);
372 letter-spacing: 0.1em;
373 text-transform: uppercase;
374 margin-bottom: var(--space-2);
375 }
376 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
377 .new-repo-title {
378 font-family: var(--font-display);
379 font-weight: 800;
380 letter-spacing: -0.028em;
381 font-size: clamp(28px, 4vw, 40px);
382 line-height: 1.05;
383 margin: 0 0 var(--space-2);
384 color: var(--text-strong);
385 }
386 .new-repo-sub {
387 font-size: 15px;
388 color: var(--text-muted);
389 margin: 0;
390 line-height: 1.55;
391 max-width: 620px;
392 }
393 .new-repo-form {
394 max-width: 680px;
395 }
396 .new-repo-error {
397 background: rgba(218, 54, 51, 0.12);
398 border: 1px solid rgba(218, 54, 51, 0.35);
399 color: #ffb3b3;
400 padding: 10px 14px;
401 border-radius: 10px;
402 margin-bottom: var(--space-4);
403 font-size: 14px;
404 }
405 .new-repo-form-grid {
406 display: flex;
407 flex-direction: column;
408 gap: var(--space-4);
409 }
410 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
411 .new-repo-label {
412 font-size: 13px;
413 color: var(--text-strong);
414 font-weight: 600;
415 }
416 .new-repo-label-optional {
417 color: var(--text-muted);
418 font-weight: 400;
419 font-size: 12px;
420 }
421 .new-repo-input {
422 appearance: none;
423 width: 100%;
424 background: var(--bg);
425 border: 1px solid var(--border);
426 color: var(--text-strong);
427 border-radius: 10px;
428 padding: 10px 12px;
429 font-size: 14px;
430 font-family: inherit;
431 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
432 }
433 .new-repo-input:focus {
434 outline: none;
435 border-color: var(--accent);
6fd5915Claude436 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude437 }
438 .new-repo-input-disabled {
439 color: var(--text-muted);
440 background: var(--bg-secondary);
441 cursor: not-allowed;
442 }
443 .new-repo-hint {
444 font-size: 12px;
445 color: var(--text-muted);
446 margin: 4px 0 0;
447 }
448 .new-repo-hint code {
449 font-family: var(--font-mono);
450 font-size: 11.5px;
451 background: var(--bg-secondary);
452 border: 1px solid var(--border);
453 border-radius: 5px;
454 padding: 1px 5px;
455 color: var(--text-strong);
456 }
457 .new-repo-visibility {
458 display: grid;
459 grid-template-columns: 1fr 1fr;
460 gap: var(--space-2);
461 }
462 @media (max-width: 600px) {
463 .new-repo-visibility { grid-template-columns: 1fr; }
464 }
465 .new-repo-vis-card {
466 display: flex;
467 gap: 10px;
468 padding: 14px;
469 background: var(--bg);
470 border: 1px solid var(--border);
471 border-radius: 12px;
472 cursor: pointer;
473 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
474 }
475 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
476 .new-repo-vis-card:has(input:checked) {
6fd5915Claude477 border-color: rgba(91,110,232,0.55);
478 background: rgba(91,110,232,0.06);
479 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude480 }
481 .new-repo-vis-radio {
482 margin-top: 3px;
483 accent-color: var(--accent);
484 }
485 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
486 .new-repo-vis-label {
487 font-size: 14px;
488 font-weight: 600;
489 color: var(--text-strong);
490 }
491 .new-repo-vis-desc {
492 font-size: 12.5px;
493 color: var(--text-muted);
494 line-height: 1.45;
495 }
496 .new-repo-callout {
497 margin-top: var(--space-2);
498 padding: var(--space-3) var(--space-4);
6fd5915Claude499 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
500 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude501 border-radius: 12px;
502 }
503 .new-repo-callout-eyebrow {
504 font-size: 11px;
505 font-family: var(--font-mono);
506 text-transform: uppercase;
507 letter-spacing: 0.12em;
508 color: var(--accent);
509 font-weight: 700;
510 margin-bottom: 4px;
511 }
512 .new-repo-callout-body {
513 font-size: 13px;
514 color: var(--text);
515 line-height: 1.5;
516 margin: 0;
517 }
518 .new-repo-callout-body code {
519 font-family: var(--font-mono);
520 font-size: 12px;
521 color: var(--accent);
6fd5915Claude522 background: rgba(91,110,232,0.1);
efb11c5Claude523 border-radius: 4px;
524 padding: 1px 5px;
525 }
398a10cClaude526 .new-repo-templates {
527 display: flex;
528 flex-wrap: wrap;
529 gap: 8px;
530 margin-top: 4px;
531 }
532 .new-repo-template-chip {
533 position: relative;
534 display: inline-flex;
535 align-items: center;
536 gap: 6px;
537 padding: 8px 14px;
538 background: var(--bg);
539 border: 1px solid var(--border);
540 border-radius: 999px;
541 font-size: 13px;
542 color: var(--text);
543 cursor: pointer;
544 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
545 }
546 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
547 .new-repo-template-chip:hover {
548 border-color: var(--border-strong, var(--border));
549 color: var(--text-strong);
550 }
551 .new-repo-template-chip:has(input:checked) {
6fd5915Claude552 border-color: rgba(91,110,232,0.55);
553 background: rgba(91,110,232,0.10);
398a10cClaude554 color: var(--text-strong);
6fd5915Claude555 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude556 }
557 .new-repo-template-chip-dot {
558 width: 8px; height: 8px;
559 border-radius: 999px;
560 background: var(--text-faint, var(--text-muted));
561 transition: background 140ms ease, box-shadow 140ms ease;
562 }
563 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude564 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
565 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude566 }
efb11c5Claude567 .new-repo-actions {
568 display: flex;
569 gap: var(--space-2);
570 margin-top: var(--space-2);
398a10cClaude571 align-items: center;
572 }
573 .new-repo-submit {
574 min-width: 180px;
6fd5915Claude575 border: 1px solid rgba(91,110,232,0.45);
576 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude577 color: #fff;
578 font-weight: 700;
6fd5915Claude579 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude580 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
581 }
582 .new-repo-submit:hover {
583 transform: translateY(-1px);
6fd5915Claude584 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude585 filter: brightness(1.06);
586 }
587 .new-repo-submit:focus-visible {
6fd5915Claude588 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude589 outline-offset: 2px;
efb11c5Claude590 }
591
592 /* ───────── profile ───────── */
593 .profile-hero {
594 position: relative;
595 margin-bottom: var(--space-5);
596 padding: var(--space-5) var(--space-6);
597 background: var(--bg-elevated);
598 border: 1px solid var(--border);
599 border-radius: 16px;
600 overflow: hidden;
601 }
602 .profile-hero-orb-wrap {
603 position: absolute;
604 inset: -25% -10% auto auto;
605 width: 360px;
606 height: 360px;
607 pointer-events: none;
608 z-index: 0;
609 }
610 .profile-hero-orb {
611 position: absolute;
612 inset: 0;
6fd5915Claude613 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude614 filter: blur(80px);
615 opacity: 0.7;
616 animation: cbHeroOrb 14s ease-in-out infinite;
617 }
618 .profile-hero-inner {
619 position: relative;
620 z-index: 1;
621 display: flex;
622 align-items: flex-start;
623 gap: var(--space-5);
624 }
625 .profile-hero-avatar {
626 flex: 0 0 auto;
627 width: 88px;
628 height: 88px;
629 border-radius: 50%;
6fd5915Claude630 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude631 color: #fff;
632 display: flex;
633 align-items: center;
634 justify-content: center;
635 font-size: 38px;
636 font-weight: 700;
637 font-family: var(--font-display);
6fd5915Claude638 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude639 }
640 .profile-hero-text { flex: 1; min-width: 0; }
641 .profile-eyebrow {
642 font-size: 12px;
643 font-family: var(--font-mono);
644 color: var(--text-muted);
645 letter-spacing: 0.1em;
646 text-transform: uppercase;
647 margin-bottom: var(--space-2);
648 }
649 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
650 .profile-name {
651 font-family: var(--font-display);
652 font-weight: 800;
653 letter-spacing: -0.028em;
654 font-size: clamp(28px, 3.6vw, 36px);
655 line-height: 1.05;
656 margin: 0 0 4px;
657 color: var(--text-strong);
658 }
659 .profile-handle {
660 font-family: var(--font-mono);
661 font-size: 13px;
662 color: var(--text-muted);
663 margin-bottom: var(--space-2);
664 }
665 .profile-bio {
666 font-size: 14.5px;
667 color: var(--text);
668 line-height: 1.55;
669 margin: 0 0 var(--space-3);
670 max-width: 640px;
671 }
672 .profile-meta {
673 display: flex;
674 align-items: center;
675 gap: var(--space-4);
676 flex-wrap: wrap;
677 font-size: 13px;
678 }
679 .profile-meta-link {
680 color: var(--text-muted);
681 transition: color var(--t-fast, 0.15s) ease;
682 }
683 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
684 .profile-meta-link strong {
685 color: var(--text-strong);
686 font-weight: 600;
687 font-variant-numeric: tabular-nums;
688 }
689 .profile-follow-form { margin: 0; }
690 @media (max-width: 600px) {
691 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
692 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
693 }
694 .profile-readme {
695 margin-bottom: var(--space-6);
696 background: var(--bg-elevated);
697 border: 1px solid var(--border);
698 border-radius: 12px;
699 overflow: hidden;
700 }
701 .profile-readme-head {
702 display: flex;
703 align-items: center;
704 gap: 8px;
705 padding: 10px 16px;
706 background: var(--bg-secondary);
707 border-bottom: 1px solid var(--border);
708 font-size: 13px;
709 color: var(--text-muted);
710 }
711 .profile-readme-icon { color: var(--accent); font-size: 14px; }
712 .profile-readme-body { padding: var(--space-5) var(--space-6); }
713 .profile-section-head {
714 display: flex;
715 align-items: baseline;
716 gap: var(--space-2);
717 margin-bottom: var(--space-3);
718 }
719 .profile-section-title {
720 font-family: var(--font-display);
721 font-weight: 700;
722 font-size: 20px;
723 letter-spacing: -0.015em;
724 margin: 0;
725 color: var(--text-strong);
726 }
727 .profile-section-count {
728 font-family: var(--font-mono);
729 font-size: 12px;
730 color: var(--text-muted);
731 background: var(--bg-secondary);
732 border: 1px solid var(--border);
733 border-radius: 999px;
734 padding: 2px 10px;
735 font-variant-numeric: tabular-nums;
736 }
737 .profile-empty {
738 display: flex;
739 align-items: center;
740 justify-content: space-between;
741 gap: var(--space-3);
742 padding: var(--space-4) var(--space-5);
743 background: var(--bg-elevated);
744 border: 1px dashed var(--border);
745 border-radius: 12px;
746 }
747 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
748
749 /* ───────── tree (file browser) ───────── */
750 .tree-header {
751 margin-bottom: var(--space-3);
752 display: flex;
753 flex-direction: column;
754 gap: var(--space-2);
755 }
756 .tree-header-row {
757 display: flex;
758 align-items: center;
759 justify-content: space-between;
760 gap: var(--space-3);
761 flex-wrap: wrap;
762 }
763 .tree-header-stats {
764 display: flex;
765 align-items: center;
766 gap: var(--space-3);
767 font-size: 12px;
768 color: var(--text-muted);
769 }
770 .tree-stat strong {
771 color: var(--text-strong);
772 font-weight: 600;
773 font-variant-numeric: tabular-nums;
774 }
775 .tree-stat-link {
776 color: var(--text-muted);
777 padding: 4px 10px;
778 border-radius: 999px;
779 border: 1px solid var(--border);
780 background: var(--bg-elevated);
781 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
782 }
783 .tree-stat-link:hover {
784 color: var(--accent);
6fd5915Claude785 border-color: rgba(91,110,232,0.45);
efb11c5Claude786 text-decoration: none;
787 }
788 .tree-breadcrumb-row {
789 font-size: 13px;
790 }
791
792 /* ───────── blob (file viewer) ───────── */
793 .blob-toolbar {
794 margin-bottom: var(--space-3);
795 display: flex;
796 flex-direction: column;
797 gap: var(--space-2);
798 }
799 .blob-breadcrumb { font-size: 13px; }
800 .blob-card {
801 background: var(--bg-elevated);
802 border: 1px solid var(--border);
803 border-radius: 12px;
804 overflow: hidden;
805 }
806 .blob-header-polished {
807 display: flex;
808 align-items: center;
809 justify-content: space-between;
810 gap: var(--space-3);
811 padding: 10px 14px;
812 background: var(--bg-secondary);
813 border-bottom: 1px solid var(--border);
814 }
815 .blob-header-meta {
816 display: flex;
817 align-items: center;
818 gap: var(--space-2);
819 min-width: 0;
820 flex: 1;
821 }
822 .blob-header-icon { font-size: 14px; opacity: 0.85; }
823 .blob-header-name {
824 font-family: var(--font-mono);
825 font-size: 13px;
826 color: var(--text-strong);
827 font-weight: 600;
828 overflow: hidden;
829 text-overflow: ellipsis;
830 white-space: nowrap;
831 }
832 .blob-header-size {
833 font-family: var(--font-mono);
834 font-size: 11.5px;
835 color: var(--text-muted);
836 font-variant-numeric: tabular-nums;
837 border-left: 1px solid var(--border);
838 padding-left: var(--space-2);
839 margin-left: 2px;
840 }
841 .blob-header-actions {
842 display: flex;
843 gap: 6px;
844 flex-shrink: 0;
845 }
846 .blob-pill {
847 display: inline-flex;
848 align-items: center;
849 padding: 4px 12px;
850 font-size: 12px;
851 font-weight: 500;
852 color: var(--text);
853 background: var(--bg-elevated);
854 border: 1px solid var(--border);
855 border-radius: 999px;
856 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
857 }
858 .blob-pill:hover {
859 color: var(--accent);
6fd5915Claude860 border-color: rgba(91,110,232,0.45);
efb11c5Claude861 text-decoration: none;
6fd5915Claude862 background: rgba(91,110,232,0.06);
efb11c5Claude863 }
864 .blob-pill-accent {
865 color: var(--accent);
6fd5915Claude866 border-color: rgba(91,110,232,0.35);
867 background: rgba(91,110,232,0.08);
efb11c5Claude868 }
869 .blob-pill-accent:hover {
6fd5915Claude870 background: rgba(91,110,232,0.14);
efb11c5Claude871 }
872 .blob-binary {
873 padding: var(--space-5);
874 color: var(--text-muted);
875 text-align: center;
876 font-size: 13px;
877 background: var(--bg);
878 }
879
880 /* ───────── commits list ───────── */
881 .commits-hero {
882 position: relative;
883 margin-bottom: var(--space-4);
884 padding: var(--space-5) var(--space-6);
885 background: var(--bg-elevated);
886 border: 1px solid var(--border);
887 border-radius: 16px;
888 overflow: hidden;
889 }
890 .commits-hero-orb-wrap {
891 position: absolute;
892 inset: -25% -10% auto auto;
893 width: 320px;
894 height: 320px;
895 pointer-events: none;
896 z-index: 0;
897 }
898 .commits-hero-orb {
899 position: absolute;
900 inset: 0;
6fd5915Claude901 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude902 filter: blur(80px);
903 opacity: 0.7;
904 animation: cbHeroOrb 14s ease-in-out infinite;
905 }
906 .commits-hero-inner { position: relative; z-index: 1; }
907 .commits-eyebrow {
908 font-size: 12px;
909 font-family: var(--font-mono);
910 color: var(--text-muted);
911 letter-spacing: 0.1em;
912 text-transform: uppercase;
913 margin-bottom: var(--space-2);
914 }
915 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
916 .commits-title {
917 font-family: var(--font-display);
918 font-weight: 800;
919 letter-spacing: -0.025em;
920 font-size: clamp(22px, 3vw, 30px);
921 line-height: 1.15;
922 margin: 0 0 var(--space-2);
923 color: var(--text-strong);
924 }
925 .commits-branch {
926 font-family: var(--font-mono);
927 font-size: 0.7em;
928 color: var(--text);
929 background: var(--bg-secondary);
930 border: 1px solid var(--border);
931 border-radius: 6px;
932 padding: 2px 8px;
933 font-weight: 500;
934 vertical-align: middle;
935 }
936 .commits-sub {
937 font-size: 14px;
938 color: var(--text-muted);
939 margin: 0;
940 line-height: 1.55;
941 max-width: 640px;
942 }
398a10cClaude943 .commits-toolbar {
944 display: flex;
945 justify-content: space-between;
946 align-items: center;
947 flex-wrap: wrap;
948 gap: var(--space-2);
949 margin-bottom: var(--space-3);
950 }
951 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
952 .commits-toolbar-link {
953 display: inline-flex;
954 align-items: center;
955 gap: 6px;
956 padding: 6px 12px;
957 font-size: 12.5px;
958 font-weight: 500;
959 color: var(--text-muted);
960 background: rgba(255,255,255,0.025);
961 border: 1px solid var(--border);
962 border-radius: 8px;
963 text-decoration: none;
964 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
965 }
966 .commits-toolbar-link:hover {
967 border-color: var(--border-strong);
968 color: var(--text-strong);
969 background: rgba(255,255,255,0.04);
970 text-decoration: none;
971 }
efb11c5Claude972 .commits-list-wrap {
973 background: var(--bg-elevated);
974 border: 1px solid var(--border);
398a10cClaude975 border-radius: 14px;
efb11c5Claude976 overflow: hidden;
398a10cClaude977 position: relative;
978 }
979 .commits-list-wrap::before {
980 content: '';
981 position: absolute;
982 top: 0; left: 0; right: 0;
983 height: 2px;
6fd5915Claude984 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude985 opacity: 0.55;
986 pointer-events: none;
987 }
988 .commits-day-head {
989 display: flex;
990 align-items: center;
991 gap: 10px;
992 padding: 10px 18px;
993 font-size: 11.5px;
994 font-family: var(--font-mono);
995 text-transform: uppercase;
996 letter-spacing: 0.08em;
997 color: var(--text-muted);
998 background: var(--bg-secondary);
999 border-bottom: 1px solid var(--border);
1000 }
1001 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
1002 .commits-day-head-dot {
1003 width: 6px; height: 6px;
1004 border-radius: 9999px;
6fd5915Claude1005 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude1006 }
1007 .commits-row {
1008 display: flex;
1009 align-items: flex-start;
1010 gap: 14px;
1011 padding: 14px 18px;
1012 border-bottom: 1px solid var(--border-subtle);
1013 transition: background 120ms ease;
1014 }
1015 .commits-row:last-child { border-bottom: none; }
1016 .commits-row:hover { background: rgba(255,255,255,0.022); }
1017 .commits-avatar {
1018 width: 34px; height: 34px;
1019 border-radius: 9999px;
6fd5915Claude1020 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude1021 color: #fff;
1022 display: inline-flex;
1023 align-items: center;
1024 justify-content: center;
1025 font-family: var(--font-display);
1026 font-weight: 700;
1027 font-size: 13.5px;
1028 flex-shrink: 0;
1029 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
1030 }
1031 .commits-row-body { flex: 1; min-width: 0; }
1032 .commits-row-msg {
1033 display: flex;
1034 align-items: center;
1035 gap: 8px;
1036 flex-wrap: wrap;
1037 }
1038 .commits-row-msg a {
1039 font-family: var(--font-display);
1040 font-weight: 600;
1041 font-size: 14px;
1042 color: var(--text-strong);
1043 text-decoration: none;
1044 letter-spacing: -0.005em;
1045 }
1046 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1047 .commits-row-verified {
1048 font-size: 9.5px;
1049 padding: 1px 7px;
1050 border-radius: 9999px;
1051 background: rgba(52,211,153,0.16);
e589f77ccantynz-alt1052 color: var(--green);
398a10cClaude1053 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1054 text-transform: uppercase;
1055 letter-spacing: 0.06em;
1056 font-weight: 700;
1057 }
1058 .commits-row-meta {
1059 margin-top: 4px;
1060 display: flex;
1061 align-items: center;
1062 gap: 8px;
1063 flex-wrap: wrap;
1064 font-size: 12.5px;
1065 color: var(--text-muted);
1066 }
1067 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1068 .commits-row-meta .sep { opacity: 0.4; }
1069 .commits-row-time {
1070 font-variant-numeric: tabular-nums;
1071 }
1072 .commits-row-side {
1073 display: flex;
1074 align-items: center;
1075 gap: 8px;
1076 flex-shrink: 0;
1077 }
1078 .commits-row-sha {
1079 display: inline-flex;
1080 align-items: center;
1081 padding: 4px 10px;
1082 border-radius: 9999px;
1083 font-family: var(--font-mono);
1084 font-size: 11.5px;
1085 font-weight: 600;
1086 color: var(--text-strong);
1087 background: rgba(255,255,255,0.04);
1088 border: 1px solid var(--border);
1089 text-decoration: none;
1090 letter-spacing: 0.04em;
1091 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1092 }
1093 .commits-row-sha:hover {
6fd5915Claude1094 border-color: rgba(91,110,232,0.55);
398a10cClaude1095 color: var(--accent);
6fd5915Claude1096 background: rgba(91,110,232,0.08);
398a10cClaude1097 text-decoration: none;
1098 }
1099 .commits-row-copy {
1100 display: inline-flex;
1101 align-items: center;
1102 justify-content: center;
1103 width: 28px; height: 28px;
1104 padding: 0;
1105 border-radius: 8px;
1106 background: transparent;
1107 border: 1px solid var(--border);
1108 color: var(--text-muted);
1109 cursor: pointer;
1110 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1111 }
398a10cClaude1112 .commits-row-copy:hover {
1113 border-color: var(--border-strong);
1114 color: var(--text);
1115 background: rgba(255,255,255,0.04);
1116 }
e589f77ccantynz-alt1117 .commits-row-copy.is-copied { color: var(--green); border-color: rgba(52,211,153,0.35); }
8c790e0Claude1118 /* Push Watch link — eye icon next to the SHA chip */
1119 .commits-row-watch {
1120 display: inline-flex;
1121 align-items: center;
1122 justify-content: center;
1123 width: 28px;
1124 height: 28px;
1125 border-radius: 6px;
1126 border: 1px solid var(--border);
1127 color: var(--text-muted);
1128 background: transparent;
1129 cursor: pointer;
1130 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1131 text-decoration: none !important;
1132 }
1133 .commits-row-watch:hover {
6fd5915Claude1134 border-color: rgba(91,110,232,0.45);
8c790e0Claude1135 color: var(--accent);
6fd5915Claude1136 background: rgba(91,110,232,0.08);
8c790e0Claude1137 text-decoration: none !important;
1138 }
efb11c5Claude1139 .commits-empty {
398a10cClaude1140 position: relative;
1141 overflow: hidden;
1142 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1143 text-align: center;
398a10cClaude1144 background: var(--bg-elevated);
1145 border: 1px dashed var(--border-strong);
1146 border-radius: 16px;
1147 }
1148 .commits-empty-orb {
1149 position: absolute;
1150 inset: -40% 30% auto 30%;
1151 height: 280px;
6fd5915Claude1152 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1153 filter: blur(70px);
1154 opacity: 0.7;
1155 pointer-events: none;
1156 z-index: 0;
1157 }
1158 .commits-empty-inner { position: relative; z-index: 1; }
1159 .commits-empty-icon {
1160 width: 56px; height: 56px;
1161 border-radius: 9999px;
6fd5915Claude1162 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1163 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1164 display: inline-flex;
1165 align-items: center;
1166 justify-content: center;
1167 color: #c4b5fd;
1168 margin: 0 auto 14px;
1169 }
1170 .commits-empty-title {
1171 font-family: var(--font-display);
1172 font-size: 18px;
1173 font-weight: 700;
1174 margin: 0 0 6px;
1175 color: var(--text-strong);
1176 }
1177 .commits-empty-sub {
1178 margin: 0 auto 0;
1179 font-size: 13.5px;
efb11c5Claude1180 color: var(--text-muted);
398a10cClaude1181 max-width: 420px;
1182 line-height: 1.5;
1183 }
1184
1185 /* ───────── branches list ───────── */
1186 .branches-list {
efb11c5Claude1187 background: var(--bg-elevated);
398a10cClaude1188 border: 1px solid var(--border);
1189 border-radius: 14px;
1190 overflow: hidden;
1191 position: relative;
1192 }
1193 .branches-list::before {
1194 content: '';
1195 position: absolute;
1196 top: 0; left: 0; right: 0;
1197 height: 2px;
6fd5915Claude1198 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1199 opacity: 0.55;
1200 pointer-events: none;
1201 }
1202 .branches-row {
1203 display: flex;
1204 align-items: center;
1205 gap: var(--space-3);
1206 padding: 14px 18px;
1207 border-bottom: 1px solid var(--border-subtle);
1208 transition: background 120ms ease;
1209 flex-wrap: wrap;
1210 }
1211 .branches-row:last-child { border-bottom: none; }
1212 .branches-row:hover { background: rgba(255,255,255,0.022); }
1213 .branches-row-icon {
1214 width: 32px; height: 32px;
1215 border-radius: 8px;
6fd5915Claude1216 background: rgba(91,110,232,0.10);
398a10cClaude1217 color: #c4b5fd;
1218 display: inline-flex;
1219 align-items: center;
1220 justify-content: center;
1221 flex-shrink: 0;
6fd5915Claude1222 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1223 }
1224 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1225 .branches-row-name {
1226 display: inline-flex;
1227 align-items: center;
1228 gap: 8px;
1229 flex-wrap: wrap;
1230 }
1231 .branches-row-name a {
1232 font-family: var(--font-mono);
1233 font-size: 13.5px;
1234 color: var(--text-strong);
1235 font-weight: 600;
1236 text-decoration: none;
1237 }
1238 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1239 .branches-row-default {
1240 font-size: 10px;
1241 padding: 2px 8px;
1242 border-radius: 9999px;
6fd5915Claude1243 background: rgba(91,110,232,0.14);
398a10cClaude1244 color: #c4b5fd;
6fd5915Claude1245 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1246 text-transform: uppercase;
1247 letter-spacing: 0.08em;
1248 font-weight: 700;
1249 font-family: var(--font-mono);
1250 }
1251 .branches-row-meta {
1252 display: flex;
1253 align-items: center;
1254 gap: 8px;
1255 flex-wrap: wrap;
1256 font-size: 12.5px;
1257 color: var(--text-muted);
1258 font-variant-numeric: tabular-nums;
1259 }
1260 .branches-row-meta .sep { opacity: 0.4; }
1261 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1262 .branches-row-side {
1263 display: flex;
1264 align-items: center;
1265 gap: 10px;
1266 flex-shrink: 0;
1267 flex-wrap: wrap;
1268 }
1269 .branches-row-divergence {
1270 display: inline-flex;
1271 align-items: center;
1272 gap: 8px;
1273 font-family: var(--font-mono);
1274 font-size: 11.5px;
1275 color: var(--text-muted);
1276 padding: 4px 10px;
1277 border-radius: 9999px;
1278 background: rgba(255,255,255,0.035);
1279 border: 1px solid var(--border);
1280 font-variant-numeric: tabular-nums;
1281 }
e589f77ccantynz-alt1282 .branches-row-divergence .ahead { color: var(--green); }
1283 .branches-row-divergence .behind { color: var(--red); }
398a10cClaude1284 .branches-row-actions {
1285 display: flex;
1286 align-items: center;
1287 gap: 6px;
1288 }
1289 .branches-btn {
1290 display: inline-flex;
1291 align-items: center;
1292 gap: 5px;
1293 padding: 6px 12px;
1294 border-radius: 9999px;
1295 font-size: 12px;
1296 font-weight: 600;
1297 text-decoration: none;
1298 border: 1px solid var(--border);
1299 background: transparent;
1300 color: var(--text-muted);
1301 cursor: pointer;
1302 font: inherit;
1303 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1304 }
1305 .branches-btn:hover {
1306 border-color: var(--border-strong);
1307 color: var(--text);
1308 background: rgba(255,255,255,0.04);
1309 text-decoration: none;
1310 }
1311 .branches-btn-danger {
e589f77ccantynz-alt1312 color: var(--red);
398a10cClaude1313 border-color: rgba(248,113,113,0.30);
1314 }
1315 .branches-btn-danger:hover {
1316 border-style: dashed;
1317 border-color: rgba(248,113,113,0.65);
1318 background: rgba(248,113,113,0.06);
1319 color: #fecaca;
1320 }
1321
1322 /* ───────── tags list ───────── */
1323 .tags-list {
1324 background: var(--bg-elevated);
1325 border: 1px solid var(--border);
1326 border-radius: 14px;
1327 overflow: hidden;
1328 position: relative;
1329 }
1330 .tags-list::before {
1331 content: '';
1332 position: absolute;
1333 top: 0; left: 0; right: 0;
1334 height: 2px;
6fd5915Claude1335 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1336 opacity: 0.55;
1337 pointer-events: none;
1338 }
1339 .tags-row {
1340 display: flex;
1341 align-items: center;
1342 gap: var(--space-3);
1343 padding: 14px 18px;
1344 border-bottom: 1px solid var(--border-subtle);
1345 transition: background 120ms ease;
1346 flex-wrap: wrap;
1347 }
1348 .tags-row:last-child { border-bottom: none; }
1349 .tags-row:hover { background: rgba(255,255,255,0.022); }
1350 .tags-row-icon {
1351 width: 32px; height: 32px;
1352 border-radius: 8px;
6fd5915Claude1353 background: rgba(95,143,160,0.10);
398a10cClaude1354 color: #67e8f9;
1355 display: inline-flex;
1356 align-items: center;
1357 justify-content: center;
1358 flex-shrink: 0;
6fd5915Claude1359 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1360 }
1361 .tags-row-main { flex: 1; min-width: 240px; }
1362 .tags-row-name {
1363 display: inline-flex;
1364 align-items: center;
1365 gap: 8px;
1366 flex-wrap: wrap;
1367 }
1368 .tags-row-version {
1369 display: inline-flex;
1370 align-items: center;
1371 padding: 3px 11px;
1372 border-radius: 9999px;
1373 font-family: var(--font-mono);
1374 font-size: 13px;
1375 font-weight: 700;
1376 color: #67e8f9;
6fd5915Claude1377 background: rgba(95,143,160,0.12);
1378 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1379 letter-spacing: 0.01em;
1380 }
1381 .tags-row-meta {
1382 margin-top: 4px;
1383 display: flex;
1384 align-items: center;
1385 gap: 8px;
1386 flex-wrap: wrap;
1387 font-size: 12.5px;
1388 color: var(--text-muted);
1389 font-variant-numeric: tabular-nums;
1390 }
1391 .tags-row-meta .sep { opacity: 0.4; }
1392 .tags-row-sha {
1393 font-family: var(--font-mono);
1394 font-size: 11.5px;
1395 color: var(--text-strong);
1396 padding: 2px 8px;
1397 border-radius: 6px;
1398 background: rgba(255,255,255,0.04);
1399 border: 1px solid var(--border);
1400 text-decoration: none;
1401 letter-spacing: 0.04em;
1402 }
1403 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1404 .tags-row-side {
1405 display: flex;
1406 align-items: center;
1407 gap: 6px;
1408 flex-shrink: 0;
1409 flex-wrap: wrap;
1410 }
1411 .tags-row-link {
1412 display: inline-flex;
1413 align-items: center;
1414 gap: 5px;
1415 padding: 6px 12px;
1416 border-radius: 9999px;
1417 font-size: 12px;
1418 font-weight: 600;
1419 text-decoration: none;
1420 color: var(--text-muted);
1421 background: rgba(255,255,255,0.025);
1422 border: 1px solid var(--border);
1423 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1424 }
1425 .tags-row-link:hover {
6fd5915Claude1426 border-color: rgba(91,110,232,0.45);
398a10cClaude1427 color: var(--text-strong);
6fd5915Claude1428 background: rgba(91,110,232,0.06);
398a10cClaude1429 text-decoration: none;
efb11c5Claude1430 }
1431
1432 /* ───────── commit detail ───────── */
1433 .commit-detail-card {
1434 position: relative;
1435 margin-bottom: var(--space-5);
1436 padding: var(--space-5) var(--space-5);
1437 background: var(--bg-elevated);
1438 border: 1px solid var(--border);
1439 border-radius: 14px;
1440 overflow: hidden;
1441 }
1442 .commit-detail-eyebrow {
1443 display: flex;
1444 align-items: center;
1445 gap: var(--space-2);
1446 font-size: 12px;
1447 font-family: var(--font-mono);
1448 text-transform: uppercase;
1449 letter-spacing: 0.1em;
1450 color: var(--text-muted);
1451 margin-bottom: var(--space-2);
1452 }
1453 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1454 .commit-detail-sha-pill {
1455 font-family: var(--font-mono);
1456 font-size: 11.5px;
1457 color: var(--text-strong);
1458 background: var(--bg-secondary);
1459 border: 1px solid var(--border);
1460 border-radius: 999px;
1461 padding: 2px 10px;
1462 letter-spacing: 0.04em;
1463 }
1464 .commit-detail-verify {
1465 font-size: 10px;
1466 padding: 2px 8px;
1467 border-radius: 999px;
1468 text-transform: uppercase;
1469 letter-spacing: 0.06em;
1470 font-weight: 700;
1471 color: #fff;
1472 }
1473 .commit-detail-verify-ok {
1474 background: linear-gradient(135deg, #2ea043, #34d399);
1475 }
1476 .commit-detail-verify-warn {
1477 background: linear-gradient(135deg, #d29922, #f59e0b);
1478 }
1479 .commit-detail-title {
1480 font-family: var(--font-display);
1481 font-weight: 700;
1482 letter-spacing: -0.018em;
1483 font-size: clamp(20px, 2.5vw, 26px);
1484 line-height: 1.25;
1485 margin: 0 0 var(--space-2);
1486 color: var(--text-strong);
1487 }
1488 .commit-detail-body {
1489 white-space: pre-wrap;
1490 color: var(--text-muted);
1491 font-size: 14px;
1492 line-height: 1.55;
1493 margin: 0 0 var(--space-3);
1494 font-family: var(--font-mono);
1495 background: var(--bg);
1496 border: 1px solid var(--border);
1497 border-radius: 8px;
1498 padding: var(--space-3) var(--space-4);
1499 max-height: 280px;
1500 overflow: auto;
1501 }
1502 .commit-detail-meta {
1503 display: flex;
1504 flex-wrap: wrap;
1505 gap: var(--space-3);
1506 font-size: 13px;
1507 color: var(--text-muted);
1508 margin-bottom: var(--space-3);
1509 }
1510 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1511 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1512 .commit-detail-sha-link {
1513 font-family: var(--font-mono);
1514 font-size: 12.5px;
1515 color: var(--accent);
6fd5915Claude1516 background: rgba(91,110,232,0.08);
efb11c5Claude1517 border-radius: 6px;
1518 padding: 1px 6px;
1519 margin-left: 2px;
1520 }
6fd5915Claude1521 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1522 .commit-detail-stats {
1523 display: flex;
1524 flex-wrap: wrap;
1525 align-items: center;
1526 gap: var(--space-3);
1527 padding-top: var(--space-3);
1528 border-top: 1px solid var(--border);
1529 font-size: 13px;
1530 color: var(--text-muted);
1531 }
1532 .commit-detail-stat {
1533 display: inline-flex;
1534 align-items: center;
1535 gap: 4px;
1536 font-variant-numeric: tabular-nums;
1537 }
1538 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1539 .commit-detail-stat-add strong { color: var(--green); }
1540 .commit-detail-stat-del strong { color: var(--red); }
efb11c5Claude1541 .commit-detail-stat-mark {
1542 font-family: var(--font-mono);
1543 font-weight: 700;
1544 font-size: 14px;
1545 }
e589f77ccantynz-alt1546 .commit-detail-stat-add .commit-detail-stat-mark { color: var(--green); }
1547 .commit-detail-stat-del .commit-detail-stat-mark { color: var(--red); }
efb11c5Claude1548 .commit-detail-sha-full {
1549 margin-left: auto;
1550 font-family: var(--font-mono);
1551 font-size: 11.5px;
1552 color: var(--text-faint);
1553 letter-spacing: 0.02em;
1554 overflow: hidden;
1555 text-overflow: ellipsis;
1556 white-space: nowrap;
1557 max-width: 100%;
1558 }
1559 .commit-detail-checks {
1560 margin-top: var(--space-3);
1561 padding-top: var(--space-3);
1562 border-top: 1px solid var(--border);
1563 }
1564 .commit-detail-checks-head {
1565 display: flex;
1566 align-items: center;
1567 gap: var(--space-2);
1568 font-size: 13px;
1569 color: var(--text-muted);
1570 margin-bottom: 8px;
1571 }
1572 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
e589f77ccantynz-alt1573 .commit-detail-check-state-success { color: var(--green); font-weight: 600; }
1574 .commit-detail-check-state-failure { color: var(--red); font-weight: 600; }
efb11c5Claude1575 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1576 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1577 .commit-detail-check {
1578 font-size: 11px;
1579 padding: 2px 8px;
1580 border-radius: 999px;
1581 color: #fff;
1582 font-weight: 500;
1583 }
398a10cClaude1584 .commit-detail-check a { color: inherit; text-decoration: none; }
1585 .commit-detail-check-success { background: #2ea043; }
1586 .commit-detail-check-pending { background: #d29922; }
1587 .commit-detail-check-failure { background: #da3633; }
1588
1589 /* ───────── blame ───────── */
1590 .blame-head { margin-bottom: var(--space-5); }
1591 .blame-eyebrow {
1592 display: inline-flex;
1593 align-items: center;
1594 gap: 8px;
1595 text-transform: uppercase;
1596 font-family: var(--font-mono);
1597 font-size: 11px;
1598 letter-spacing: 0.16em;
1599 color: var(--text-muted);
1600 font-weight: 600;
1601 margin-bottom: 10px;
1602 }
1603 .blame-eyebrow-dot {
1604 width: 8px; height: 8px;
1605 border-radius: 9999px;
6fd5915Claude1606 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1607 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1608 }
1609 .blame-title {
1610 font-family: var(--font-display);
1611 font-size: clamp(22px, 3vw, 30px);
1612 font-weight: 800;
1613 letter-spacing: -0.025em;
1614 line-height: 1.15;
1615 margin: 0 0 6px;
1616 color: var(--text-strong);
1617 }
1618 .blame-title code {
1619 font-family: var(--font-mono);
1620 font-size: 0.78em;
1621 color: var(--text-strong);
1622 font-weight: 700;
1623 }
1624 .blame-sub {
1625 margin: 0;
1626 font-size: 14px;
1627 color: var(--text-muted);
1628 line-height: 1.5;
1629 max-width: 700px;
1630 }
efb11c5Claude1631 .blame-toolbar {
398a10cClaude1632 display: flex;
1633 justify-content: space-between;
1634 align-items: center;
1635 gap: var(--space-3);
efb11c5Claude1636 margin-bottom: var(--space-3);
398a10cClaude1637 flex-wrap: wrap;
efb11c5Claude1638 font-size: 13px;
1639 }
398a10cClaude1640 .blame-toolbar-actions { display: flex; gap: 6px; }
1641 .blame-card {
1642 background: var(--bg-elevated);
1643 border: 1px solid var(--border);
1644 border-radius: 14px;
1645 overflow: hidden;
1646 position: relative;
1647 }
1648 .blame-card::before {
1649 content: '';
1650 position: absolute;
1651 top: 0; left: 0; right: 0;
1652 height: 2px;
6fd5915Claude1653 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1654 opacity: 0.55;
1655 pointer-events: none;
1656 }
efb11c5Claude1657 .blame-header {
1658 display: flex;
1659 align-items: center;
1660 justify-content: space-between;
1661 gap: var(--space-3);
1662 padding: 10px 14px;
1663 background: var(--bg-secondary);
1664 border-bottom: 1px solid var(--border);
1665 }
1666 .blame-header-meta {
1667 display: flex;
1668 align-items: center;
1669 gap: var(--space-2);
1670 min-width: 0;
1671 flex-wrap: wrap;
1672 }
1673 .blame-header-icon { color: var(--accent); font-size: 14px; }
1674 .blame-header-name {
1675 font-family: var(--font-mono);
1676 font-size: 13px;
1677 color: var(--text-strong);
1678 font-weight: 600;
1679 }
1680 .blame-header-tag {
1681 font-size: 10.5px;
1682 text-transform: uppercase;
1683 letter-spacing: 0.08em;
1684 font-family: var(--font-mono);
6fd5915Claude1685 background: rgba(91,110,232,0.12);
efb11c5Claude1686 color: var(--accent);
1687 border-radius: 999px;
1688 padding: 2px 8px;
1689 font-weight: 600;
1690 }
1691 .blame-header-stats {
1692 font-family: var(--font-mono);
1693 font-size: 11.5px;
1694 color: var(--text-muted);
1695 font-variant-numeric: tabular-nums;
1696 border-left: 1px solid var(--border);
1697 padding-left: var(--space-2);
1698 }
1699 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1700 .blame-table {
1701 width: 100%;
1702 border-collapse: collapse;
1703 font-family: var(--font-mono);
1704 font-size: 12.5px;
1705 line-height: 1.6;
1706 }
1707 .blame-table tr { border-bottom: 1px solid transparent; }
1708 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1709 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1710 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1711 .blame-gutter {
1712 width: 220px;
1713 min-width: 220px;
1714 padding: 0 12px;
1715 vertical-align: top;
1716 background: rgba(255,255,255,0.012);
1717 border-right: 1px solid var(--border-subtle);
1718 font-variant-numeric: tabular-nums;
1719 color: var(--text-muted);
1720 font-size: 11px;
1721 white-space: nowrap;
1722 overflow: hidden;
1723 text-overflow: ellipsis;
1724 padding-top: 2px;
1725 padding-bottom: 2px;
1726 }
1727 .blame-gutter-inner {
1728 display: inline-flex;
1729 align-items: center;
1730 gap: 7px;
1731 max-width: 100%;
1732 overflow: hidden;
1733 }
1734 .blame-gutter-sha {
1735 font-family: var(--font-mono);
1736 font-size: 10.5px;
1737 font-weight: 600;
1738 color: #c4b5fd;
6fd5915Claude1739 background: rgba(91,110,232,0.10);
398a10cClaude1740 padding: 1px 7px;
1741 border-radius: 9999px;
6fd5915Claude1742 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1743 text-decoration: none;
1744 letter-spacing: 0.04em;
1745 flex-shrink: 0;
1746 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1747 }
1748 .blame-gutter-sha:hover {
6fd5915Claude1749 background: rgba(91,110,232,0.22);
398a10cClaude1750 color: #ddd6fe;
6fd5915Claude1751 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1752 text-decoration: none;
1753 }
1754 .blame-gutter-author {
1755 color: var(--text);
1756 overflow: hidden;
1757 text-overflow: ellipsis;
1758 white-space: nowrap;
1759 font-size: 11px;
1760 font-family: var(--font-sans, inherit);
1761 }
1762 .blame-line-num {
1763 width: 1%;
1764 min-width: 50px;
1765 padding: 0 12px;
1766 text-align: right;
1767 color: var(--text-faint);
1768 user-select: none;
1769 border-right: 1px solid var(--border-subtle);
1770 font-variant-numeric: tabular-nums;
1771 }
1772 .blame-line-content {
1773 padding: 0 14px;
1774 white-space: pre;
1775 color: var(--text);
1776 transition: background 120ms ease;
1777 }
efb11c5Claude1778
1779 /* ───────── search ───────── */
1780 .search-hero {
1781 position: relative;
1782 margin-bottom: var(--space-4);
1783 padding: var(--space-5) var(--space-6);
1784 background: var(--bg-elevated);
1785 border: 1px solid var(--border);
1786 border-radius: 16px;
1787 overflow: hidden;
1788 }
1789 .search-eyebrow {
1790 font-size: 12px;
1791 font-family: var(--font-mono);
1792 color: var(--text-muted);
1793 letter-spacing: 0.1em;
1794 text-transform: uppercase;
1795 margin-bottom: var(--space-2);
1796 }
1797 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1798 .search-title {
1799 font-family: var(--font-display);
1800 font-weight: 800;
1801 letter-spacing: -0.025em;
1802 font-size: clamp(22px, 3vw, 30px);
1803 line-height: 1.15;
1804 margin: 0 0 var(--space-3);
1805 color: var(--text-strong);
1806 }
1807 .search-form {
1808 display: flex;
1809 gap: var(--space-2);
1810 align-items: stretch;
1811 }
1812 .search-input-wrap {
1813 position: relative;
1814 flex: 1;
1815 display: flex;
1816 align-items: center;
1817 }
1818 .search-input-icon {
1819 position: absolute;
1820 left: 12px;
1821 color: var(--text-muted);
1822 font-size: 15px;
1823 pointer-events: none;
1824 }
1825 .search-input {
1826 appearance: none;
1827 width: 100%;
1828 padding: 10px 12px 10px 34px;
1829 background: var(--bg);
1830 border: 1px solid var(--border);
1831 border-radius: 10px;
1832 color: var(--text-strong);
1833 font-size: 14px;
1834 font-family: inherit;
1835 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1836 }
1837 .search-input:focus {
1838 outline: none;
1839 border-color: var(--accent);
6fd5915Claude1840 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1841 }
1842 .search-submit { min-width: 96px; }
1843 .search-results-head {
1844 display: flex;
1845 align-items: baseline;
1846 gap: var(--space-2);
1847 margin-bottom: var(--space-3);
1848 font-size: 13px;
1849 color: var(--text-muted);
1850 }
1851 .search-results-count strong {
1852 color: var(--text-strong);
1853 font-weight: 600;
1854 font-variant-numeric: tabular-nums;
1855 }
1856 .search-results-q { color: var(--text-strong); font-weight: 600; }
1857 .search-results-head code {
1858 font-family: var(--font-mono);
1859 font-size: 12px;
1860 background: var(--bg-secondary);
1861 border: 1px solid var(--border);
1862 border-radius: 5px;
1863 padding: 1px 6px;
1864 color: var(--text);
1865 }
1866 .search-empty {
1867 padding: var(--space-5) var(--space-6);
1868 background: var(--bg-elevated);
1869 border: 1px dashed var(--border);
1870 border-radius: 12px;
1871 color: var(--text-muted);
1872 font-size: 14px;
1873 }
1874 .search-empty strong { color: var(--text-strong); }
1875 .search-results {
1876 display: flex;
1877 flex-direction: column;
1878 gap: var(--space-3);
1879 }
1880 .search-file-head {
1881 display: flex;
1882 align-items: center;
1883 justify-content: space-between;
1884 gap: var(--space-2);
1885 }
1886 .search-file-link {
1887 font-family: var(--font-mono);
1888 font-size: 13px;
1889 color: var(--text-strong);
1890 font-weight: 600;
1891 }
1892 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1893 .search-file-count {
1894 font-family: var(--font-mono);
1895 font-size: 11.5px;
1896 color: var(--text-muted);
1897 font-variant-numeric: tabular-nums;
1898 }
1899`;
1900
fc1817aClaude1901// Home page
06d5ffeClaude1902web.get("/", async (c) => {
1903 const user = c.get("user");
1904
1905 if (user) {
0316dbbClaude1906 return c.redirect("/dashboard");
06d5ffeClaude1907 }
1908
8e9f1d9Claude1909 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1910 let publicStats: PublicStats | null = null;
8e9f1d9Claude1911 try {
1912 const [repoRow] = await db
1913 .select({ n: sql<number>`count(*)::int` })
1914 .from(repositories)
1915 .where(eq(repositories.isPrivate, false));
1916 const [userRow] = await db
1917 .select({ n: sql<number>`count(*)::int` })
1918 .from(users);
1919 stats = {
1920 publicRepos: Number(repoRow?.n ?? 0),
1921 users: Number(userRow?.n ?? 0),
1922 };
1923 } catch {
1924 stats = undefined;
1925 }
1926
52ad8b1Claude1927 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1928 try {
1929 publicStats = await computePublicStats();
1930 } catch {
1931 publicStats = null;
1932 }
1933
534f04aClaude1934 // Block M1 — initial SSR snapshot for the live-now demo feed.
1935 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1936 // in try/catch so a freak module-level explosion can't take down /.
8ed88f2ccantynz-alt1937 let liveFeed: LandingProLiveFeed | null = null;
534f04aClaude1938 try {
1939 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1940 listQueuedAiBuildIssues(3),
1941 listRecentAutoMerges(3, 24),
1942 listRecentAiReviews(3, 24),
1943 countAiReviewsSince(24),
1944 listDemoActivityFeed(10),
1945 ]);
1946 liveFeed = {
1947 queued: queued.map((i) => ({
1948 repo: i.repo,
1949 number: i.number,
1950 title: i.title,
8ed88f2ccantynz-alt1951 createdAt: new Date(i.createdAt),
534f04aClaude1952 })),
1953 merges: merges.map((m) => ({
1954 repo: m.repo,
1955 number: m.number,
1956 title: m.title,
1957 mergedAt: m.mergedAt,
1958 })),
1959 reviews: reviewList.map((r) => ({
1960 repo: r.repo,
1961 prNumber: r.prNumber,
1962 commentSnippet: r.commentSnippet,
1963 createdAt: r.createdAt,
1964 })),
1965 reviewCount,
1966 feed: feed.map((e) => ({
1967 kind: e.kind,
1968 repo: e.repo,
1969 ref: e.ref,
1970 at: e.at,
1971 })),
1972 };
1973 } catch {
1974 liveFeed = null;
1975 }
1976
29924bcClaude1977 void LandingPage;
f8e5feaccanty labs1978 void Landing2030Page;
1979 return c.html(
1980 "<!DOCTYPE html>" +
1981 String(
1982 <LandingProPage
1983 stats={stats}
1984 publicStats={publicStats}
1985 liveFeed={liveFeed}
1986 />
1987 )
1988 );
fc1817aClaude1989});
1990
06d5ffeClaude1991// New repository form
1992web.get("/new", requireAuth, (c) => {
1993 const user = c.get("user")!;
1994 const error = c.req.query("error");
1995
1996 return c.html(
1997 <Layout title="New repository" user={user}>
efb11c5Claude1998 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1999 <div class="new-repo-hero">
2000 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
2001 <div class="new-repo-hero-orb" />
2002 </div>
2003 <div class="new-repo-hero-inner">
2004 <div class="new-repo-eyebrow">
2005 <strong>Create</strong> · {user.username}
2006 </div>
2007 <h1 class="new-repo-title">
2008 Spin up a <span class="gradient-text">repository</span>.
2009 </h1>
2010 <p class="new-repo-sub">
2011 Push your first commit, and Gluecron wires up gate checks, AI review,
2012 and auto-merge from the moment your branch lands.
2013 </p>
2014 </div>
2015 </div>
06d5ffeClaude2016 <div class="new-repo-form">
efb11c5Claude2017 {error && (
2018 <div class="new-repo-error" role="alert">
2019 {decodeURIComponent(error)}
2020 </div>
2021 )}
2022 <form method="post" action="/new" class="new-repo-form-grid">
2023 <div class="new-repo-row">
2024 <label class="new-repo-label">Owner</label>
2025 <input
2026 type="text"
2027 value={user.username}
2028 disabled
2029 aria-label="Owner"
2030 class="new-repo-input new-repo-input-disabled"
2031 />
06d5ffeClaude2032 </div>
efb11c5Claude2033 <div class="new-repo-row">
2034 <label class="new-repo-label" for="name">
2035 Repository name
2036 </label>
06d5ffeClaude2037 <input
2038 type="text"
2039 id="name"
2040 name="name"
2041 required
2042 pattern="^[a-zA-Z0-9._-]+$"
2043 placeholder="my-project"
2044 autocomplete="off"
efb11c5Claude2045 class="new-repo-input"
06d5ffeClaude2046 />
efb11c5Claude2047 <p class="new-repo-hint">
2048 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2049 <code>{user.username}/&lt;name&gt;</code>.
2050 </p>
06d5ffeClaude2051 </div>
efb11c5Claude2052 <div class="new-repo-row">
2053 <label class="new-repo-label" for="description">
2054 Description{" "}
2055 <span class="new-repo-label-optional">(optional)</span>
2056 </label>
06d5ffeClaude2057 <input
2058 type="text"
2059 id="description"
2060 name="description"
2061 placeholder="A short description of your repository"
efb11c5Claude2062 class="new-repo-input"
06d5ffeClaude2063 />
2064 </div>
efb11c5Claude2065 <div class="new-repo-row">
2066 <span class="new-repo-label">Visibility</span>
2067 <div class="new-repo-visibility">
2068 <label class="new-repo-vis-card">
2069 <input
2070 type="radio"
2071 name="visibility"
2072 value="public"
2073 checked
2074 class="new-repo-vis-radio"
2075 />
2076 <span class="new-repo-vis-body">
2077 <span class="new-repo-vis-label">Public</span>
2078 <span class="new-repo-vis-desc">
2079 Anyone can see this repository. You choose who can commit.
2080 </span>
2081 </span>
2082 </label>
2083 <label class="new-repo-vis-card">
2084 <input
2085 type="radio"
2086 name="visibility"
2087 value="private"
2088 class="new-repo-vis-radio"
2089 />
2090 <span class="new-repo-vis-body">
2091 <span class="new-repo-vis-label">Private</span>
2092 <span class="new-repo-vis-desc">
2093 Only you (and collaborators you invite) can see this
2094 repository.
2095 </span>
2096 </span>
2097 </label>
2098 </div>
2099 </div>
398a10cClaude2100 <div class="new-repo-row">
2101 <span class="new-repo-label">
2102 Starter content{" "}
2103 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2104 </span>
2105 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2106 <label class="new-repo-template-chip">
2107 <input type="radio" name="starter" value="empty" checked />
2108 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2109 Empty
2110 </label>
2111 <label class="new-repo-template-chip">
2112 <input type="radio" name="starter" value="readme" />
2113 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2114 README
2115 </label>
2116 <label class="new-repo-template-chip">
2117 <input type="radio" name="starter" value="readme-mit" />
2118 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2119 README + MIT
2120 </label>
2121 <label class="new-repo-template-chip">
2122 <input type="radio" name="starter" value="node" />
2123 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2124 Node + .gitignore
2125 </label>
2126 </div>
2127 <p class="new-repo-hint">
2128 Just a UI hint — push your own commits to fill the repo.
2129 </p>
2130 </div>
44f1a02Claude2131 <div class="new-repo-row">
2132 <label class="new-repo-label" for="data_region">
2133 Data region
2134 </label>
2135 <select
2136 id="data_region"
2137 name="data_region"
2138 class="new-repo-input"
2139 style="cursor: pointer;"
2140 >
2141 <option value="us" selected>US (default)</option>
2142 <option value="eu">EU (Frankfurt)</option>
2143 </select>
2144 <p class="new-repo-hint">
2145 EU data residency requires a{" "}
2146 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2147 Pro plan or higher
2148 </a>
2149 . Repositories cannot be moved between regions after creation.
2150 </p>
2151 </div>
efb11c5Claude2152 <div class="new-repo-callout">
2153 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2154 <p class="new-repo-callout-body">
2155 Every push is gate-checked and reviewed by Claude automatically.
2156 Label an issue <code>ai-build</code> and Gluecron will open the PR
2157 for you.
2158 </p>
2159 </div>
2160 <div class="new-repo-actions">
398a10cClaude2161 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2162 Create repository
2163 </button>
2164 <a href="/dashboard" class="btn new-repo-cancel">
2165 Cancel
2166 </a>
06d5ffeClaude2167 </div>
2168 </form>
2169 </div>
2170 </Layout>
2171 );
2172});
2173
2174web.post("/new", requireAuth, async (c) => {
2175 const user = c.get("user")!;
2176 const body = await c.req.parseBody();
2177 const name = String(body.name || "").trim();
2178 const description = String(body.description || "").trim();
2179 const isPrivate = body.visibility === "private";
44f1a02Claude2180 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2181
2182 if (!name) {
2183 return c.redirect("/new?error=Repository+name+is+required");
2184 }
2185
c63b860Claude2186 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2187 // outage never blocks repo creation.
2188 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2189 const gate = await checkRepoCreateAllowed(user.id);
2190 if (!gate.ok) {
2191 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2192 }
2193
06d5ffeClaude2194 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2195 return c.redirect("/new?error=Invalid+repository+name");
2196 }
2197
2198 if (await repoExists(user.username, name)) {
2199 return c.redirect("/new?error=Repository+already+exists");
2200 }
2201
2202 const diskPath = await initBareRepo(user.username, name);
2203
3ef4c9dClaude2204 const [newRepo] = await db
2205 .insert(repositories)
2206 .values({
2207 name,
2208 ownerId: user.id,
2209 description: description || null,
2210 isPrivate,
2211 diskPath,
44f1a02Claude2212 dataRegion,
3ef4c9dClaude2213 })
2214 .returning();
2215
2216 if (newRepo) {
2217 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2218 await bootstrapRepository({
2219 repositoryId: newRepo.id,
2220 ownerUserId: user.id,
2221 defaultBranch: "main",
2222 });
2223 }
06d5ffeClaude2224
2225 return c.redirect(`/${user.username}/${name}`);
2226});
2227
11c3ab6ccanty labs2228// Daily brief — GET /brief
2229web.get("/brief", (c) => {
2230 const user = c.get("user");
8ed88f2ccantynz-alt2231 return c.html(
2232 <DailyBrief
2233 user={user ? { name: user.displayName || user.username } : undefined}
2234 />
2235 );
11c3ab6ccanty labs2236});
2237
2238// Trust report — GET /trust (public)
2239web.get("/trust", (c) => {
2240 return c.html(<TrustReport />);
2241});
2242
2243// Production layers — GET /layers
2244web.get("/layers", (c) => {
2245 const user = c.get("user");
2246 return c.html(<ProductionLayers user={user} />);
2247});
2248
2249// Distribution — GET /distribute
2250web.get("/distribute", (c) => {
2251 const user = c.get("user");
2252 return c.html(<DistributionView user={user} />);
2253});
2254
06d5ffeClaude2255// User profile
fc1817aClaude2256web.get("/:owner", async (c) => {
06d5ffeClaude2257 const { owner: ownerName } = c.req.param();
2258 const user = c.get("user");
2259
2260 // Avoid clashing with fixed routes
2261 if (
2262 ["login", "register", "logout", "new", "settings", "api"].includes(
2263 ownerName
2264 )
2265 ) {
2266 return c.notFound();
2267 }
2268
2269 let ownerUser;
2270 try {
2271 const [found] = await db
2272 .select()
2273 .from(users)
2274 .where(eq(users.username, ownerName))
2275 .limit(1);
2276 ownerUser = found;
2277 } catch {
2278 // DB not available — check if repos exist on disk
2279 ownerUser = null;
2280 }
2281
2282 // Even without DB, show repos if they exist on disk
2283 let repos: any[] = [];
2284 if (ownerUser) {
2285 const allRepos = await db
2286 .select()
2287 .from(repositories)
2288 .where(eq(repositories.ownerId, ownerUser.id))
2289 .orderBy(desc(repositories.updatedAt));
2290
2291 // Show public repos to everyone, private only to owner
2292 repos =
2293 user?.id === ownerUser.id
2294 ? allRepos
2295 : allRepos.filter((r) => !r.isPrivate);
2296 }
2297
7aa8b99Claude2298 // Block J4 — follow counts + viewer's follow state
2299 let followState = {
2300 followers: 0,
2301 following: 0,
2302 viewerFollows: false,
2303 };
2304 if (ownerUser) {
2305 try {
2306 const { followCounts, isFollowing } = await import("../lib/follows");
2307 const counts = await followCounts(ownerUser.id);
2308 followState.followers = counts.followers;
2309 followState.following = counts.following;
2310 if (user && user.id !== ownerUser.id) {
2311 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2312 }
2313 } catch {
2314 // DB hiccup — fall back to zeros.
2315 }
2316 }
2317 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2318
d412586Claude2319 // Block J5 — profile README. Render owner/owner repo's README on the
2320 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2321 // back to "<user>/.github" for org-style profile repos.
2322 let profileReadmeHtml: string | null = null;
2323 try {
2324 const candidates = [ownerName, ".github"];
2325 for (const rname of candidates) {
2326 if (await repoExists(ownerName, rname)) {
2327 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2328 const md = await getReadme(ownerName, rname, ref);
2329 if (md) {
2330 profileReadmeHtml = renderMarkdown(md);
2331 break;
2332 }
2333 }
2334 }
2335 } catch {
2336 profileReadmeHtml = null;
2337 }
2338
efb11c5Claude2339 const displayName = ownerUser?.displayName || ownerName;
2340 const memberSince = ownerUser?.createdAt
2341 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2342 : null;
2343
fc1817aClaude2344 return c.html(
06d5ffeClaude2345 <Layout title={ownerName} user={user}>
efb11c5Claude2346 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2347 <div class="profile-hero">
2348 <div class="profile-hero-orb-wrap" aria-hidden="true">
2349 <div class="profile-hero-orb" />
06d5ffeClaude2350 </div>
efb11c5Claude2351 <div class="profile-hero-inner">
2352 <div class="profile-hero-avatar" aria-hidden="true">
2353 {displayName[0].toUpperCase()}
2354 </div>
2355 <div class="profile-hero-text">
2356 <div class="profile-eyebrow">
2357 <strong>Developer</strong>
2358 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2359 <>
2360 {" "}· Joined{" "}
2361 {memberSince.toLocaleDateString("en-US", {
2362 month: "short",
2363 year: "numeric",
2364 })}
2365 </>
2366 )}
2367 </div>
2368 <h1 class="profile-name">
2369 <span class="gradient-text">{displayName}</span>
2370 </h1>
2371 <div class="profile-handle">@{ownerName}</div>
2372 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2373 <div class="profile-meta">
2374 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2375 <strong>{followState.followers}</strong> follower
2376 {followState.followers === 1 ? "" : "s"}
2377 </a>
2378 <a href={`/${ownerName}/following`} class="profile-meta-link">
2379 <strong>{followState.following}</strong> following
2380 </a>
2381 <a href={`/${ownerName}`} class="profile-meta-link">
2382 <strong>{repos.length}</strong> repo
2383 {repos.length === 1 ? "" : "s"}
2384 </a>
2385 {canFollow && (
2386 <form
2387 method="post"
2388 action={`/${ownerName}/${
2389 followState.viewerFollows ? "unfollow" : "follow"
2390 }`}
2391 class="profile-follow-form"
7aa8b99Claude2392 >
efb11c5Claude2393 <button
2394 type="submit"
2395 class={`btn ${
2396 followState.viewerFollows ? "" : "btn-primary"
2397 } btn-sm`}
2398 >
2399 {followState.viewerFollows ? "Unfollow" : "Follow"}
2400 </button>
2401 </form>
2402 )}
2403 </div>
7aa8b99Claude2404 </div>
06d5ffeClaude2405 </div>
2406 </div>
d412586Claude2407 {profileReadmeHtml && (
efb11c5Claude2408 <div class="profile-readme">
2409 <div class="profile-readme-head">
2410 <span class="profile-readme-icon">{"☰"}</span>
2411 <span>{ownerName}/{ownerName} README.md</span>
2412 </div>
2413 <div
2414 class="markdown-body profile-readme-body"
2415 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2416 />
2417 </div>
d412586Claude2418 )}
efb11c5Claude2419 <div class="profile-section-head">
2420 <h2 class="profile-section-title">Repositories</h2>
2421 <span class="profile-section-count">{repos.length}</span>
2422 </div>
06d5ffeClaude2423 {repos.length === 0 ? (
efb11c5Claude2424 <div class="profile-empty">
2425 <p class="profile-empty-text">
2426 No repositories yet
2427 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2428 </p>
2429 {user?.id === ownerUser?.id && (
2430 <a href="/new" class="btn btn-primary btn-sm">
2431 + Create your first
2432 </a>
2433 )}
2434 </div>
06d5ffeClaude2435 ) : (
2436 <div class="card-grid">
2437 {repos.map((repo) => (
2438 <RepoCard repo={repo} ownerName={ownerName} />
2439 ))}
2440 </div>
2441 )}
fc1817aClaude2442 </Layout>
2443 );
2444});
2445
06d5ffeClaude2446// Star/unstar a repo
2447web.post("/:owner/:repo/star", requireAuth, async (c) => {
2448 const { owner: ownerName, repo: repoName } = c.req.param();
2449 const user = c.get("user")!;
2450
2451 try {
2452 const [ownerUser] = await db
2453 .select()
2454 .from(users)
2455 .where(eq(users.username, ownerName))
2456 .limit(1);
2457 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2458
2459 const [repo] = await db
2460 .select()
2461 .from(repositories)
2462 .where(
2463 and(
2464 eq(repositories.ownerId, ownerUser.id),
2465 eq(repositories.name, repoName)
2466 )
2467 )
2468 .limit(1);
2469 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2470
2471 // Toggle star
2472 const [existing] = await db
2473 .select()
2474 .from(stars)
2475 .where(
2476 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2477 )
2478 .limit(1);
2479
2480 if (existing) {
2481 await db.delete(stars).where(eq(stars.id, existing.id));
2482 await db
2483 .update(repositories)
2484 .set({ starCount: Math.max(0, repo.starCount - 1) })
2485 .where(eq(repositories.id, repo.id));
2486 } else {
2487 await db.insert(stars).values({
2488 userId: user.id,
2489 repositoryId: repo.id,
2490 });
2491 await db
2492 .update(repositories)
2493 .set({ starCount: repo.starCount + 1 })
2494 .where(eq(repositories.id, repo.id));
a74f4edccanty labs2495 void fireWebhooks(repo.id, "star", { action: "created" });
06d5ffeClaude2496 }
2497 } catch {
2498 // DB error — ignore
2499 }
2500
2501 return c.redirect(`/${ownerName}/${repoName}`);
2502});
2503
641aa42Claude2504// ---------------------------------------------------------------------------
2505// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2506// ---------------------------------------------------------------------------
2507const obCss = `
2508 .ob-card {
2509 position: relative;
2510 margin: 14px 0 18px;
6fd5915Claude2511 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2512 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2513 border-radius: 14px;
2514 overflow: hidden;
2515 }
2516 .ob-card::before {
2517 content: '';
2518 position: absolute;
2519 top: 0; left: 0; right: 0;
2520 height: 2px;
6fd5915Claude2521 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2522 pointer-events: none;
2523 }
2524 .ob-header {
2525 padding: 16px 20px 10px;
2526 border-bottom: 1px solid var(--border);
2527 }
2528 .ob-header h3 {
2529 font-size: 16px;
2530 font-weight: 700;
2531 margin: 0 0 4px;
2532 color: var(--text-strong);
2533 }
2534 .ob-header p {
2535 font-size: 13px;
2536 color: var(--text-muted);
2537 margin: 0;
2538 }
2539 .ob-sections {
2540 display: grid;
2541 grid-template-columns: repeat(3, 1fr);
2542 gap: 0;
2543 }
2544 @media (max-width: 760px) {
2545 .ob-sections { grid-template-columns: 1fr; }
2546 }
2547 .ob-section {
2548 padding: 14px 20px;
2549 border-right: 1px solid var(--border);
2550 }
2551 .ob-section:last-child { border-right: none; }
2552 .ob-section h4 {
2553 font-size: 12px;
2554 font-weight: 700;
2555 text-transform: uppercase;
2556 letter-spacing: 0.08em;
2557 color: var(--accent);
2558 margin: 0 0 8px;
2559 }
2560 .ob-preview {
2561 font-family: var(--font-mono);
2562 font-size: 11.5px;
2563 background: var(--bg);
2564 border: 1px solid var(--border);
2565 border-radius: 6px;
2566 padding: 8px 10px;
2567 color: var(--text-muted);
2568 line-height: 1.55;
2569 margin-bottom: 8px;
2570 white-space: pre-wrap;
2571 overflow: hidden;
2572 max-height: 80px;
2573 position: relative;
2574 }
2575 .ob-preview::after {
2576 content: '';
2577 position: absolute;
2578 bottom: 0; left: 0; right: 0;
2579 height: 24px;
2580 background: linear-gradient(transparent, var(--bg));
2581 pointer-events: none;
2582 }
2583 .ob-labels {
2584 display: flex;
2585 flex-wrap: wrap;
2586 gap: 5px;
2587 margin-bottom: 8px;
2588 }
2589 .ob-label-chip {
2590 display: inline-flex;
2591 align-items: center;
2592 padding: 2px 8px;
2593 border-radius: 9999px;
2594 font-size: 11.5px;
2595 font-weight: 600;
2596 line-height: 1.5;
2597 border: 1px solid transparent;
2598 }
2599 .ob-section ul {
2600 margin: 0;
2601 padding: 0 0 0 16px;
2602 font-size: 13px;
2603 color: var(--text-muted);
2604 line-height: 1.7;
2605 }
2606 .ob-section ul li { margin-bottom: 2px; }
2607 .ob-footer {
2608 display: flex;
2609 align-items: center;
2610 justify-content: flex-end;
2611 padding: 10px 20px;
2612 border-top: 1px solid var(--border);
2613 gap: 10px;
2614 }
2615 .ob-dismiss {
2616 appearance: none;
2617 background: transparent;
2618 border: 1px solid var(--border);
2619 color: var(--text-muted);
2620 border-radius: 6px;
2621 padding: 5px 12px;
2622 font-size: 12.5px;
2623 font-family: inherit;
2624 cursor: pointer;
2625 transition: background var(--t-fast), color var(--t-fast);
2626 }
2627 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2628 .btn-sm {
2629 appearance: none;
2630 background: var(--bg-elevated);
2631 border: 1px solid var(--border);
2632 color: var(--text-strong);
2633 border-radius: 6px;
2634 padding: 5px 12px;
2635 font-size: 12.5px;
2636 font-weight: 600;
2637 font-family: inherit;
2638 cursor: pointer;
2639 text-decoration: none;
2640 display: inline-flex;
2641 align-items: center;
2642 transition: background var(--t-fast);
2643 }
2644 .btn-sm:hover { background: var(--bg-hover); }
2645 .btn-sm.btn-primary {
2646 background: var(--accent);
2647 color: #fff;
2648 border-color: var(--accent);
2649 }
2650 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2651`;
2652
2653// Onboarding card component — shown to repo owner until dismissed
2654function RepoOnboardingCard({
2655 owner,
2656 repo,
2657 data,
2658}: {
2659 owner: string;
2660 repo: string;
2661 data: typeof repoOnboardingData.$inferSelect;
2662}) {
2663 const labels = (data.suggestedLabels ?? []) as Array<{
2664 name: string;
2665 color: string;
2666 description: string;
2667 }>;
2668 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2669 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2670
2671 return (
2672 <>
2673 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2674 <div class="ob-card" id="repo-onboarding">
2675 <div class="ob-header">
2676 <h3>Get started with {owner}/{repo}</h3>
2677 <p>
2678 Detected: {data.detectedLanguage ?? "Unknown"}
2679 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2680 Here&rsquo;s what we suggest to hit the ground running.
2681 </p>
2682 </div>
2683 <div class="ob-sections">
2684 <div class="ob-section">
2685 <h4>Suggested README</h4>
2686 <div class="ob-preview">{readmePreview}</div>
2687 <button
2688 class="btn-sm"
2689 type="button"
2690 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(_){};})()`}
2691 aria-label="Copy suggested README to clipboard"
2692 >
2693 Copy to clipboard
2694 </button>
2695 </div>
2696 <div class="ob-section">
2697 <h4>Suggested labels ({labels.length})</h4>
2698 <div class="ob-labels">
2699 {labels.slice(0, 6).map((l) => (
2700 <span
2701 class="ob-label-chip"
2702 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2703 title={l.description}
2704 >
2705 {l.name}
2706 </span>
2707 ))}
2708 </div>
2709 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2710 <button class="btn-sm btn-primary" type="submit">
2711 Create all labels
2712 </button>
2713 </form>
2714 </div>
2715 <div class="ob-section">
2716 <h4>First steps</h4>
2717 <ul>
2718 {suggestions.map((s) => (
2719 <li>{s}</li>
2720 ))}
2721 </ul>
2722 </div>
2723 </div>
2724 <div class="ob-footer">
2725 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2726 <button class="ob-dismiss" type="submit">
2727 Dismiss
2728 </button>
2729 </form>
2730 </div>
2731 <script
2732 dangerouslySetInnerHTML={{
2733 __html: `
2734 (function(){
2735 var card = document.getElementById('repo-onboarding');
2736 if (!card) return;
2737 document.addEventListener('keydown', function(e){
2738 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2739 card.style.display = 'none';
2740 }
2741 });
2742 })();
2743 `,
2744 }}
2745 />
2746 </div>
2747 </>
2748 );
2749}
2750
2751// ---------------------------------------------------------------------------
2752// Setup routes — create suggested labels + dismiss onboarding
2753// ---------------------------------------------------------------------------
2754
2755// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2756// requireAuth + write access. Idempotent (label UPSERT by name).
2757web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2758 const { owner, repo } = c.req.param();
2759 const user = c.get("user")!;
2760
2761 // Resolve repo + verify write access
2762 let repoRow: { id: string; ownerId: string } | null = null;
2763 try {
2764 const [ownerUser] = await db
2765 .select({ id: users.id })
2766 .from(users)
2767 .where(eq(users.username, owner))
2768 .limit(1);
2769 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2770 const [r] = await db
2771 .select({ id: repositories.id, ownerId: repositories.ownerId })
2772 .from(repositories)
2773 .where(
2774 and(
2775 eq(repositories.ownerId, ownerUser.id),
2776 eq(repositories.name, repo)
2777 )
2778 )
2779 .limit(1);
2780 repoRow = r ?? null;
2781 } catch {
2782 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2783 }
2784 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2785 if (repoRow.ownerId !== user.id) {
2786 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2787 }
2788
2789 // Fetch the onboarding data
2790 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2791 try {
2792 const [r] = await db
2793 .select()
2794 .from(repoOnboardingData)
2795 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2796 .limit(1);
2797 obRow = r ?? null;
2798 } catch {
2799 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2800 }
2801 if (!obRow) {
2802 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2803 }
2804
2805 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2806 name: string;
2807 color: string;
2808 description: string;
2809 }>;
2810
2811 // Create labels — import the labels table which was already imported
2812 let created = 0;
2813 for (const l of suggestedLabels) {
2814 try {
2815 await db
2816 .insert(labels)
2817 .values({
2818 repositoryId: repoRow.id,
2819 name: l.name,
2820 color: l.color,
2821 description: l.description ?? "",
2822 })
2823 .onConflictDoNothing();
2824 created++;
2825 } catch {
2826 /* skip duplicates */
2827 }
2828 }
2829
2830 // Mark onboarding dismissed
2831 try {
2832 await db
2833 .update(repositories)
2834 .set({ onboardingShown: true })
2835 .where(eq(repositories.id, repoRow.id));
2836 } catch {
2837 /* ignore */
2838 }
2839
2840 return c.redirect(
2841 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2842 );
2843});
2844
2845// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2846web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2847 const { owner, repo } = c.req.param();
2848 const user = c.get("user")!;
2849
2850 try {
2851 const [ownerUser] = await db
2852 .select({ id: users.id })
2853 .from(users)
2854 .where(eq(users.username, owner))
2855 .limit(1);
2856 if (ownerUser) {
2857 const [r] = await db
2858 .select({ id: repositories.id, ownerId: repositories.ownerId })
2859 .from(repositories)
2860 .where(
2861 and(
2862 eq(repositories.ownerId, ownerUser.id),
2863 eq(repositories.name, repo)
2864 )
2865 )
2866 .limit(1);
2867 if (r && r.ownerId === user.id) {
2868 await db
2869 .update(repositories)
2870 .set({ onboardingShown: true })
2871 .where(eq(repositories.id, r.id));
2872 }
2873 }
2874 } catch {
2875 /* swallow — dismiss should never fail visibly */
2876 }
2877
2878 return c.redirect(`/${owner}/${repo}`);
2879});
2880
11c3ab6ccanty labs2881// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2882web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2883 const { owner, repo } = c.req.param();
2884 const user = c.get("user");
5bb52faccanty labs2885 const gate = await assertRepoReadable(c, owner, repo);
2886 if (gate) return gate;
11c3ab6ccanty labs2887 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2888});
2889
2890// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2891web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2892 const { owner, repo } = c.req.param();
2893 const user = c.get("user");
5bb52faccanty labs2894 const gate = await assertRepoReadable(c, owner, repo);
2895 if (gate) return gate;
11c3ab6ccanty labs2896 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2897});
2898
fc1817aClaude2899// Repository overview — file tree at HEAD
2900web.get("/:owner/:repo", async (c) => {
2901 const { owner, repo } = c.req.param();
06d5ffeClaude2902 const user = c.get("user");
fc1817aClaude2903
5bb52faccanty labs2904 // SECURITY: gate private-repo content before any render (incl. skeleton).
2905 const gate = await assertRepoReadable(c, owner, repo);
2906 if (gate) return gate;
2907
f1dc7c7Claude2908 // ── Loading skeleton (flag-gated) ──
2909 // Renders an SSR'd shell with file-tree + README placeholders when
2910 // `?skeleton=1` is set. Lets the user see the page structure before
2911 // git ops finish. Behind a flag for now so we never flash before the
2912 // real content lands.
2913 if (c.req.query("skeleton") === "1") {
2914 return c.html(
2915 <Layout title={`${owner}/${repo}`} user={user}>
2916 <style
2917 dangerouslySetInnerHTML={{
2918 __html: `
2919 .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; }
2920 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2921 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2922 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2923 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2924 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2925 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2926 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2927 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2928 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2929 .repo-skel-readme { height: 320px; border-radius: 12px; }
2930 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2931 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2932 `,
2933 }}
2934 />
2935 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2936 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2937 <div class="repo-skel-grid" aria-hidden="true">
2938 <div>
2939 <div class="repo-skel repo-skel-branch" />
2940 <div class="repo-skel-tree">
2941 {Array.from({ length: 8 }).map(() => (
2942 <div class="repo-skel repo-skel-tree-row" />
2943 ))}
2944 </div>
2945 <div class="repo-skel repo-skel-readme" />
2946 </div>
2947 <aside class="repo-skel-side">
2948 <div class="repo-skel repo-skel-side-card" />
2949 <div class="repo-skel repo-skel-side-card" />
2950 </aside>
2951 </div>
2952 <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">
2953 Loading {owner}/{repo}…
2954 </span>
2955 </Layout>
2956 );
2957 }
2958
8f50ed0Claude2959 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2960 trackByName(owner, repo, "view", {
2961 userId: user?.id || null,
2962 path: `/${owner}/${repo}`,
2963 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2964 userAgent: c.req.header("user-agent") || null,
2965 referer: c.req.header("referer") || null,
a28cedeClaude2966 }).catch((err) => {
2967 console.warn(
2968 `[web] view tracking failed for ${owner}/${repo}:`,
2969 err instanceof Error ? err.message : err
2970 );
2971 });
8f50ed0Claude2972
fc1817aClaude2973 if (!(await repoExists(owner, repo))) {
2974 return c.html(
06d5ffeClaude2975 <Layout title="Not Found" user={user}>
fc1817aClaude2976 <div class="empty-state">
2977 <h2>Repository not found</h2>
2978 <p>
2979 {owner}/{repo} does not exist.
2980 </p>
2981 </div>
2982 </Layout>,
2983 404
2984 );
2985 }
2986
05b973eClaude2987 // Parallelize all independent operations
2988 const [defaultBranch, branches] = await Promise.all([
2989 getDefaultBranch(owner, repo).then((b) => b || "main"),
2990 listBranches(owner, repo),
2991 ]);
2992 const [tree, starInfo] = await Promise.all([
2993 getTree(owner, repo, defaultBranch),
2994 // Star info fetched in parallel with tree
2995 (async () => {
2996 try {
2997 const [ownerUser] = await db
2998 .select()
2999 .from(users)
3000 .where(eq(users.username, owner))
3001 .limit(1);
71cd5ecClaude3002 if (!ownerUser)
3003 return {
3004 starCount: 0,
3005 starred: false,
3006 archived: false,
3007 isTemplate: false,
544d842Claude3008 forkCount: 0,
3009 description: null as string | null,
3010 pushedAt: null as Date | null,
3011 createdAt: null as Date | null,
cb5a796Claude3012 repoId: null as string | null,
3013 repoOwnerId: null as string | null,
71cd5ecClaude3014 };
05b973eClaude3015 const [repoRow] = await db
3016 .select()
3017 .from(repositories)
3018 .where(
3019 and(
3020 eq(repositories.ownerId, ownerUser.id),
3021 eq(repositories.name, repo)
3022 )
06d5ffeClaude3023 )
05b973eClaude3024 .limit(1);
71cd5ecClaude3025 if (!repoRow)
3026 return {
3027 starCount: 0,
3028 starred: false,
3029 archived: false,
3030 isTemplate: false,
544d842Claude3031 forkCount: 0,
3032 description: null as string | null,
3033 pushedAt: null as Date | null,
3034 createdAt: null as Date | null,
cb5a796Claude3035 repoId: null as string | null,
3036 repoOwnerId: null as string | null,
71cd5ecClaude3037 };
05b973eClaude3038 let starred = false;
06d5ffeClaude3039 if (user) {
3040 const [star] = await db
3041 .select()
3042 .from(stars)
3043 .where(
3044 and(
3045 eq(stars.userId, user.id),
3046 eq(stars.repositoryId, repoRow.id)
3047 )
3048 )
3049 .limit(1);
3050 starred = !!star;
3051 }
71cd5ecClaude3052 return {
3053 starCount: repoRow.starCount,
3054 starred,
3055 archived: repoRow.isArchived,
3056 isTemplate: repoRow.isTemplate,
544d842Claude3057 forkCount: repoRow.forkCount,
3058 description: repoRow.description as string | null,
3059 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3060 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3061 repoId: repoRow.id as string,
3062 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3063 };
05b973eClaude3064 } catch {
71cd5ecClaude3065 return {
3066 starCount: 0,
3067 starred: false,
3068 archived: false,
3069 isTemplate: false,
544d842Claude3070 forkCount: 0,
3071 description: null as string | null,
3072 pushedAt: null as Date | null,
3073 createdAt: null as Date | null,
cb5a796Claude3074 repoId: null as string | null,
3075 repoOwnerId: null as string | null,
71cd5ecClaude3076 };
06d5ffeClaude3077 }
05b973eClaude3078 })(),
3079 ]);
544d842Claude3080 const {
3081 starCount,
3082 starred,
3083 archived,
3084 isTemplate,
3085 forkCount,
3086 description,
3087 pushedAt,
3088 createdAt,
cb5a796Claude3089 repoId,
3090 repoOwnerId,
544d842Claude3091 } = starInfo;
3092
91a0204Claude3093 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3094 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3095 // and the badge simply doesn't render.
3096 let healthScore: HealthScore | null = null;
3097 if (repoId) {
3098 try {
3099 healthScore = await computeHealthScore(repoId);
3100 } catch {
3101 // swallow — badge is optional
3102 }
3103 }
3104
cb5a796Claude3105 // Pending-comments banner data (lazy + best-effort). Only the repo
3106 // owner sees the banner, so non-owner views skip the DB hit entirely.
3107 let repoHomePendingCount = 0;
3108 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3109 try {
3110 const { countPendingForRepo } = await import(
3111 "../lib/comment-moderation"
3112 );
3113 repoHomePendingCount = await countPendingForRepo(repoId);
3114 } catch {
3115 /* swallow */
3116 }
3117 }
3118
8c790e0Claude3119 // Push Watch discoverability — fetch the most recent push within 24 h.
3120 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3121 const recentPush: RecentPush | null = repoId
3122 ? await getRecentPush(repoId)
3123 : null;
3124
ebaae0fClaude3125 // AI stats strip — best-effort, fail-open to zero values.
3126 let aiStats: RepoAiStats = {
3127 mergedCount: 0,
3128 reviewCount: 0,
3129 securityAlertCount: 0,
3130 hoursSaved: "0.0",
3131 };
3132 if (repoId) {
3133 try {
3134 aiStats = await getRepoAiStats(repoId);
3135 } catch {
3136 /* swallow — show zero values */
3137 }
3138 }
3139
641aa42Claude3140 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3141 // if the DB is down the card simply won't appear. Only the owner sees it.
3142 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3143 let showOnboarding = false;
8102dd4ccantynz-alt3144 // The card is an EMPTY-REPO nudge ("add a README, add a .gitignore"). It must
3145 // only appear while the repo actually is empty — otherwise a populated repo
3146 // (files + branches pushed) keeps rendering "Get started" above the fold
3147 // forever, because `onboarding_shown` only flips on an explicit dismiss.
3148 const repoLooksEmpty = tree.length === 0 && branches.length === 0;
3149 if (repoLooksEmpty && repoId && user && repoOwnerId && user.id === repoOwnerId) {
641aa42Claude3150 try {
3151 // Check if onboarding_shown is still false (not yet dismissed)
3152 const [repoRow2] = await db
3153 .select({ onboardingShown: repositories.onboardingShown })
3154 .from(repositories)
3155 .where(eq(repositories.id, repoId))
3156 .limit(1);
3157 if (repoRow2 && !repoRow2.onboardingShown) {
3158 const [obRow] = await db
3159 .select()
3160 .from(repoOnboardingData)
3161 .where(eq(repoOnboardingData.repositoryId, repoId))
3162 .limit(1);
3163 onboardingRow = obRow ?? null;
3164 showOnboarding = !!onboardingRow;
3165 }
3166 } catch {
3167 /* swallow — onboarding is optional */
3168 }
3169 }
3170
544d842Claude3171 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3172 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3173 const repoHomeCss = `
3174 .repo-home-hero {
3175 position: relative;
3176 margin-bottom: var(--space-5);
3177 padding: var(--space-5) var(--space-6);
3178 background: var(--bg-elevated);
3179 border: 1px solid var(--border);
3180 border-radius: 16px;
3181 overflow: hidden;
3182 }
3183 .repo-home-hero::before {
3184 content: '';
3185 position: absolute;
3186 top: 0; left: 0; right: 0;
3187 height: 2px;
6fd5915Claude3188 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3189 opacity: 0.7;
3190 pointer-events: none;
3191 }
3192 .repo-home-hero-orb-wrap {
3193 position: absolute;
3194 inset: -25% -10% auto auto;
3195 width: 360px;
3196 height: 360px;
3197 pointer-events: none;
3198 z-index: 0;
3199 }
3200 .repo-home-hero-orb {
3201 position: absolute;
3202 inset: 0;
6fd5915Claude3203 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3204 filter: blur(80px);
3205 opacity: 0.7;
3206 animation: repoHomeOrb 14s ease-in-out infinite;
3207 }
3208 @keyframes repoHomeOrb {
3209 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3210 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3211 }
3212 @media (prefers-reduced-motion: reduce) {
3213 .repo-home-hero-orb { animation: none; }
3214 }
3215 .repo-home-hero-inner {
3216 position: relative;
3217 z-index: 1;
3218 }
3219 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3220 .repo-home-eyebrow {
3221 font-size: 12px;
3222 font-family: var(--font-mono);
3223 color: var(--text-muted);
3224 letter-spacing: 0.1em;
3225 text-transform: uppercase;
3226 margin-bottom: var(--space-2);
3227 }
3228 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3229 .repo-home-description {
3230 font-size: 15px;
3231 line-height: 1.55;
3232 color: var(--text);
3233 margin: 0;
3234 max-width: 720px;
3235 }
3236 .repo-home-description-empty {
3237 font-size: 14px;
3238 color: var(--text-muted);
3239 font-style: italic;
3240 margin: 0;
3241 }
3242 .repo-home-stat-row {
3243 display: flex;
3244 flex-wrap: wrap;
3245 gap: var(--space-4);
3246 margin-top: var(--space-3);
3247 font-size: 13px;
3248 color: var(--text-muted);
3249 }
3250 .repo-home-stat {
3251 display: inline-flex;
3252 align-items: center;
3253 gap: 6px;
3254 }
3255 .repo-home-stat strong {
3256 color: var(--text-strong);
3257 font-weight: 600;
3258 font-variant-numeric: tabular-nums;
3259 }
3260 .repo-home-stat .repo-home-stat-icon {
3261 color: var(--text-faint);
3262 font-size: 14px;
3263 line-height: 1;
3264 }
3265 .repo-home-stat a {
3266 color: var(--text-muted);
3267 transition: color var(--t-fast) var(--ease);
3268 }
3269 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3270
3271 /* Two-column layout: file tree + sidebar */
3272 .repo-home-grid {
3273 display: grid;
3274 grid-template-columns: minmax(0, 1fr) 280px;
3275 gap: var(--space-5);
3276 align-items: start;
3277 }
3278 @media (max-width: 960px) {
3279 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3280 }
3281 .repo-home-main { min-width: 0; }
3282
3283 /* Sidebar card */
3284 .repo-home-side {
3285 display: flex;
3286 flex-direction: column;
3287 gap: var(--space-4);
3288 }
3289 .repo-home-side-card {
3290 background: var(--bg-elevated);
3291 border: 1px solid var(--border);
3292 border-radius: 12px;
3293 padding: var(--space-4);
3294 }
3295 .repo-home-side-title {
3296 font-size: 11px;
3297 font-family: var(--font-mono);
3298 letter-spacing: 0.12em;
3299 text-transform: uppercase;
3300 color: var(--text-muted);
3301 margin: 0 0 var(--space-3);
3302 font-weight: 600;
3303 }
3304 .repo-home-side-row {
3305 display: flex;
3306 justify-content: space-between;
3307 align-items: center;
3308 gap: var(--space-2);
3309 font-size: 13px;
3310 padding: 6px 0;
3311 border-top: 1px solid var(--border);
3312 }
3313 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3314 .repo-home-side-key {
3315 color: var(--text-muted);
3316 display: inline-flex;
3317 align-items: center;
3318 gap: 6px;
3319 }
3320 .repo-home-side-val {
3321 color: var(--text-strong);
3322 font-weight: 500;
3323 font-variant-numeric: tabular-nums;
3324 max-width: 60%;
3325 text-align: right;
3326 overflow: hidden;
3327 text-overflow: ellipsis;
3328 white-space: nowrap;
3329 }
3330 .repo-home-side-val a { color: var(--text-strong); }
3331 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3332
3333 /* Clone / Code tabs */
3334 .repo-home-clone {
3335 background: var(--bg-elevated);
3336 border: 1px solid var(--border);
3337 border-radius: 12px;
3338 overflow: hidden;
3339 }
3340 .repo-home-clone-tabs {
3341 display: flex;
3342 gap: 0;
3343 background: var(--bg-secondary);
3344 border-bottom: 1px solid var(--border);
3345 padding: 0 var(--space-2);
3346 }
3347 .repo-home-clone-tab {
3348 appearance: none;
3349 background: transparent;
3350 border: 0;
3351 border-bottom: 2px solid transparent;
3352 padding: 9px 12px;
3353 font-size: 12px;
3354 font-weight: 500;
3355 color: var(--text-muted);
3356 cursor: pointer;
3357 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3358 font-family: inherit;
3359 margin-bottom: -1px;
3360 }
3361 .repo-home-clone-tab:hover { color: var(--text-strong); }
3362 .repo-home-clone-tab[aria-selected="true"] {
3363 color: var(--text-strong);
3364 border-bottom-color: var(--accent);
3365 }
3366 .repo-home-clone-body {
3367 padding: var(--space-3);
3368 display: flex;
3369 align-items: center;
3370 gap: var(--space-2);
3371 }
3372 .repo-home-clone-input {
3373 flex: 1;
3374 min-width: 0;
3375 font-family: var(--font-mono);
3376 font-size: 12px;
3377 background: var(--bg);
3378 border: 1px solid var(--border);
3379 border-radius: 8px;
3380 padding: 8px 10px;
3381 color: var(--text-strong);
3382 overflow: hidden;
3383 text-overflow: ellipsis;
3384 white-space: nowrap;
3385 }
3386 .repo-home-clone-copy {
3387 appearance: none;
3388 background: var(--bg-secondary);
3389 border: 1px solid var(--border);
3390 color: var(--text-strong);
3391 border-radius: 8px;
3392 padding: 8px 12px;
3393 font-size: 12px;
3394 font-weight: 600;
3395 cursor: pointer;
3396 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3397 font-family: inherit;
3398 }
3399 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3400 .repo-home-clone-pane { display: none; }
3401 .repo-home-clone-pane[data-active="true"] { display: flex; }
3402
3403 /* README card */
3404 .repo-home-readme {
3405 margin-top: var(--space-5);
3406 background: var(--bg-elevated);
3407 border: 1px solid var(--border);
3408 border-radius: 12px;
3409 overflow: hidden;
3410 }
3411 .repo-home-readme-head {
3412 display: flex;
3413 align-items: center;
3414 gap: 8px;
3415 padding: 10px 16px;
3416 background: var(--bg-secondary);
3417 border-bottom: 1px solid var(--border);
3418 font-size: 13px;
3419 color: var(--text-muted);
3420 }
3421 .repo-home-readme-head .repo-home-readme-icon {
3422 color: var(--accent);
3423 font-size: 14px;
3424 }
3425 .repo-home-readme-body {
3426 padding: var(--space-5) var(--space-6);
3427 }
3428
3429 /* Empty-state CTA */
3430 .repo-home-empty {
3431 position: relative;
3432 margin-top: var(--space-4);
3433 background: var(--bg-elevated);
3434 border: 1px solid var(--border);
3435 border-radius: 16px;
3436 padding: var(--space-6);
3437 overflow: hidden;
3438 }
3439 .repo-home-empty::before {
3440 content: '';
3441 position: absolute;
3442 top: 0; left: 0; right: 0;
3443 height: 2px;
6fd5915Claude3444 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3445 opacity: 0.7;
3446 pointer-events: none;
3447 }
3448 .repo-home-empty-eyebrow {
3449 font-size: 12px;
3450 font-family: var(--font-mono);
3451 color: var(--accent);
3452 letter-spacing: 0.12em;
3453 text-transform: uppercase;
3454 margin-bottom: var(--space-2);
3455 font-weight: 600;
3456 }
3457 .repo-home-empty-title {
3458 font-family: var(--font-display);
3459 font-weight: 800;
3460 letter-spacing: -0.025em;
3461 font-size: clamp(22px, 3vw, 30px);
3462 line-height: 1.1;
3463 margin: 0 0 var(--space-2);
3464 color: var(--text-strong);
3465 }
3466 .repo-home-empty-sub {
3467 color: var(--text-muted);
3468 font-size: 14px;
3469 line-height: 1.55;
3470 max-width: 640px;
3471 margin: 0 0 var(--space-4);
3472 }
3473 .repo-home-empty-snippet {
3474 background: var(--bg);
3475 border: 1px solid var(--border);
3476 border-radius: 10px;
3477 padding: var(--space-3) var(--space-4);
3478 font-family: var(--font-mono);
3479 font-size: 12.5px;
3480 line-height: 1.7;
3481 color: var(--text-strong);
3482 overflow-x: auto;
3483 white-space: pre;
3484 margin: 0;
3485 }
3486 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3487 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3488
91a0204Claude3489 /* Health score badge */
3490 .repo-health-badge {
3491 display: inline-flex;
3492 align-items: center;
3493 gap: 5px;
3494 padding: 3px 10px;
3495 border-radius: 999px;
3496 font-size: 11.5px;
3497 font-weight: 700;
3498 font-family: var(--font-mono);
3499 text-decoration: none;
3500 border: 1px solid transparent;
3501 transition: filter 120ms ease, opacity 120ms ease;
3502 vertical-align: middle;
3503 }
3504 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3505 .repo-health-badge-elite {
3506 background: rgba(52,211,153,0.15);
e589f77ccantynz-alt3507 color: var(--green);
91a0204Claude3508 border-color: rgba(52,211,153,0.30);
3509 }
3510 .repo-health-badge-strong {
3511 background: rgba(96,165,250,0.15);
3512 color: #93c5fd;
3513 border-color: rgba(96,165,250,0.30);
3514 }
3515 .repo-health-badge-improving {
3516 background: rgba(251,191,36,0.15);
3517 color: #fde68a;
3518 border-color: rgba(251,191,36,0.30);
3519 }
3520 .repo-health-badge-needs-attention {
3521 background: rgba(248,113,113,0.15);
e589f77ccantynz-alt3522 color: var(--red);
91a0204Claude3523 border-color: rgba(248,113,113,0.30);
3524 }
3525
3526 /* Three-option empty-state panel */
3527 .repo-empty-options {
3528 display: grid;
3529 grid-template-columns: repeat(3, 1fr);
3530 gap: var(--space-3);
3531 margin-top: var(--space-5);
3532 }
3533 @media (max-width: 760px) {
3534 .repo-empty-options { grid-template-columns: 1fr; }
3535 }
3536 .repo-empty-option {
3537 position: relative;
3538 background: var(--bg-elevated);
3539 border: 1px solid var(--border);
3540 border-radius: 14px;
3541 padding: var(--space-4) var(--space-4);
3542 display: flex;
3543 flex-direction: column;
3544 gap: var(--space-2);
3545 overflow: hidden;
3546 transition: border-color 140ms ease, background 140ms ease;
3547 }
3548 .repo-empty-option:hover {
6fd5915Claude3549 border-color: rgba(91,110,232,0.45);
3550 background: rgba(91,110,232,0.04);
91a0204Claude3551 }
3552 .repo-empty-option::before {
3553 content: '';
3554 position: absolute;
3555 top: 0; left: 0; right: 0;
3556 height: 2px;
6fd5915Claude3557 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3558 opacity: 0.55;
3559 pointer-events: none;
3560 }
3561 .repo-empty-option-label {
3562 font-size: 10px;
3563 font-family: var(--font-mono);
3564 font-weight: 700;
3565 letter-spacing: 0.12em;
3566 text-transform: uppercase;
3567 color: var(--accent);
3568 margin-bottom: 2px;
3569 }
3570 .repo-empty-option-title {
3571 font-family: var(--font-display);
3572 font-weight: 700;
3573 font-size: 16px;
3574 letter-spacing: -0.01em;
3575 color: var(--text-strong);
3576 margin: 0;
3577 }
3578 .repo-empty-option-sub {
3579 font-size: 13px;
3580 color: var(--text-muted);
3581 line-height: 1.5;
3582 margin: 0;
3583 flex: 1;
3584 }
3585 .repo-empty-option-snippet {
3586 background: var(--bg);
3587 border: 1px solid var(--border);
3588 border-radius: 8px;
3589 padding: var(--space-2) var(--space-3);
3590 font-family: var(--font-mono);
3591 font-size: 11.5px;
3592 line-height: 1.75;
3593 color: var(--text-strong);
3594 overflow-x: auto;
3595 white-space: pre;
3596 margin: var(--space-1) 0 0;
3597 }
3598 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3599 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3600 .repo-empty-option-cta {
3601 display: inline-flex;
3602 align-items: center;
3603 gap: 6px;
3604 margin-top: auto;
3605 padding-top: var(--space-2);
3606 font-size: 13px;
3607 font-weight: 600;
3608 color: var(--accent);
3609 text-decoration: none;
3610 transition: color 120ms ease;
3611 }
3612 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3613
544d842Claude3614 @media (max-width: 720px) {
3615 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3616 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3617 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3618 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3619 .repo-home-side-val { max-width: 55%; }
3620 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3621 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3622 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3623 }
ebaae0fClaude3624
3625 /* AI stats strip */
3626 .repo-ai-stats-strip {
3627 display: flex;
3628 align-items: center;
3629 gap: 6px;
3630 margin-top: 12px;
3631 margin-bottom: 4px;
3632 font-size: 12px;
3633 color: var(--text-muted);
3634 flex-wrap: wrap;
3635 }
3636 .repo-ai-stats-strip a {
3637 color: var(--text-muted);
3638 text-decoration: underline;
3639 text-decoration-color: transparent;
3640 transition: color 120ms ease, text-decoration-color 120ms ease;
3641 }
3642 .repo-ai-stats-strip a:hover {
3643 color: var(--accent);
3644 text-decoration-color: currentColor;
3645 }
3646 .repo-ai-stats-sep {
3647 opacity: 0.4;
3648 user-select: none;
3649 }
544d842Claude3650 `;
3651 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3652 // SSH URL — port-aware:
3653 // Standard port 22 → git@host:owner/repo.git
3654 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3655 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3656 try {
3657 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3658 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3659 ? host
3660 : "localhost";
3661 const sshPort = config.sshPort;
3662 if (sshPort === 22) {
3663 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3664 } else {
3665 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3666 }
3667 } catch {
3668 // Fall through to default.
3669 }
3670 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3671 const formatRelative = (date: Date | null): string => {
3672 if (!date) return "never";
3673 const ms = Date.now() - date.getTime();
3674 const s = Math.max(0, Math.round(ms / 1000));
3675 if (s < 60) return "just now";
3676 const m = Math.round(s / 60);
3677 if (m < 60) return `${m} min ago`;
3678 const h = Math.round(m / 60);
3679 if (h < 24) return `${h}h ago`;
3680 const d = Math.round(h / 24);
3681 if (d < 30) return `${d}d ago`;
3682 const mo = Math.round(d / 30);
3683 if (mo < 12) return `${mo}mo ago`;
3684 const y = Math.round(d / 365);
3685 return `${y}y ago`;
3686 };
06d5ffeClaude3687
fc1817aClaude3688 if (tree.length === 0) {
5acce80Claude3689 const repoOgDesc = description
3690 ? `${owner}/${repo} on Gluecron — ${description}`
3691 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3692 return c.html(
5acce80Claude3693 <Layout
3694 title={`${owner}/${repo}`}
3695 user={user}
3696 description={repoOgDesc}
3697 ogTitle={`${owner}/${repo} — Gluecron`}
3698 ogDescription={repoOgDesc}
3699 twitterCard="summary"
3700 >
544d842Claude3701 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3702 <style dangerouslySetInnerHTML={{ __html: `
3703 .empty-options-grid {
3704 display: grid;
3705 grid-template-columns: repeat(3, 1fr);
3706 gap: var(--space-4);
3707 margin-top: var(--space-4);
3708 }
3709 @media (max-width: 800px) {
3710 .empty-options-grid { grid-template-columns: 1fr; }
3711 }
3712 .empty-option-card {
3713 position: relative;
3714 background: var(--bg-elevated);
3715 border: 1px solid var(--border);
3716 border-radius: 14px;
3717 padding: var(--space-5);
3718 display: flex;
3719 flex-direction: column;
3720 gap: var(--space-3);
3721 overflow: hidden;
3722 transition: border-color 140ms ease, box-shadow 140ms ease;
3723 }
3724 .empty-option-card:hover {
6fd5915Claude3725 border-color: rgba(91,110,232,0.45);
3726 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3727 }
3728 .empty-option-card::before {
3729 content: '';
3730 position: absolute;
3731 top: 0; left: 0; right: 0;
3732 height: 2px;
6fd5915Claude3733 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3734 opacity: 0.55;
3735 pointer-events: none;
3736 }
3737 .empty-option-label {
3738 font-size: 10.5px;
3739 font-family: var(--font-mono);
3740 text-transform: uppercase;
3741 letter-spacing: 0.14em;
3742 color: var(--accent);
3743 font-weight: 700;
3744 }
3745 .empty-option-title {
3746 font-family: var(--font-display);
3747 font-weight: 700;
3748 font-size: 17px;
3749 letter-spacing: -0.01em;
3750 color: var(--text-strong);
3751 margin: 0;
3752 line-height: 1.25;
3753 }
3754 .empty-option-body {
3755 font-size: 13px;
3756 color: var(--text-muted);
3757 line-height: 1.55;
3758 margin: 0;
3759 flex: 1;
3760 }
3761 .empty-option-snippet {
3762 background: var(--bg);
3763 border: 1px solid var(--border);
3764 border-radius: 8px;
3765 padding: var(--space-3) var(--space-4);
3766 font-family: var(--font-mono);
3767 font-size: 11.5px;
3768 line-height: 1.75;
3769 color: var(--text-strong);
3770 overflow-x: auto;
3771 white-space: pre;
3772 margin: 0;
3773 }
3774 .empty-option-snippet .ec { color: var(--text-faint); }
3775 .empty-option-snippet .em { color: var(--accent); }
3776 ` }} />
f6730d0ccantynz-alt3777 <style dangerouslySetInnerHTML={{ __html: sharedComponentStyles }} />
544d842Claude3778 <div class="repo-home-hero">
3779 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3780 <div class="repo-home-hero-orb" />
3781 </div>
3782 <div class="repo-home-hero-inner">
3783 <div class="repo-home-eyebrow">
3784 <strong>Repository</strong> · {owner}
3785 </div>
91a0204Claude3786 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3787 <RepoHeader
3788 owner={owner}
3789 repo={repo}
3790 starCount={starCount}
3791 starred={starred}
3792 forkCount={forkCount}
3793 currentUser={user?.username}
3794 archived={archived}
3795 isTemplate={isTemplate}
8c790e0Claude3796 recentPush={recentPush}
91a0204Claude3797 />
3798 {healthScore && (() => {
3799 const gradeLabel: Record<string, string> = {
3800 elite: "Elite", strong: "Strong",
3801 improving: "Improving", "needs-attention": "Needs Attention",
3802 };
3803 return (
3804 <a
3805 href={`/${owner}/${repo}/insights/health`}
3806 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3807 title={`Health score: ${healthScore!.total}/100`}
3808 >
3809 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3810 </a>
3811 );
3812 })()}
3813 </div>
544d842Claude3814 {description ? (
3815 <p class="repo-home-description">{description}</p>
3816 ) : (
3817 <p class="repo-home-description-empty">
3818 No description yet — push a README to tell the world what this
3819 ships.
3820 </p>
3821 )}
3822 </div>
3823 </div>
fc1817aClaude3824 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3825 <div style="margin-top:var(--space-4)">
3826 <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)">
3827 Getting started
3828 </div>
544d842Claude3829 <h2 class="repo-home-empty-title">
91a0204Claude3830 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3831 </h2>
3832 <p class="repo-home-empty-sub">
91a0204Claude3833 Choose how you want to kick things off. Every push wires up gate
3834 checks and AI review automatically.
544d842Claude3835 </p>
91a0204Claude3836 <div class="empty-options-grid">
3837 <div class="empty-option-card">
3838 <div class="empty-option-label">Option A</div>
3839 <h3 class="empty-option-title">Push your first commit</h3>
3840 <p class="empty-option-body">
3841 Wire up an existing project in seconds. Run these commands from
3842 your project directory.
3843 </p>
3844 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3845<span class="em">git remote add</span> origin {cloneHttpsUrl}
3846<span class="em">git branch</span> -M main
3847<span class="em">git push</span> -u origin main</pre>
3848 </div>
3849 <div class="empty-option-card">
3850 <div class="empty-option-label">Option B</div>
3851 <h3 class="empty-option-title">Import from GitHub</h3>
3852 <p class="empty-option-body">
3853 Mirror an existing GitHub repository here in one click. Gluecron
3854 syncs the full history and branches.
3855 </p>
f6730d0ccantynz-alt3856 <Button href="/import" variant="secondary" style="align-self:flex-start">
91a0204Claude3857 Import repository
f6730d0ccantynz-alt3858 </Button>
91a0204Claude3859 </div>
3860 <div class="empty-option-card">
3861 <div class="empty-option-label">Option C</div>
3862 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3863 <p class="empty-option-body">
3864 Let AI write your first feature. Describe what you want to build
3865 and Gluecron opens a pull request with the code.
3866 </p>
f6730d0ccantynz-alt3867 <Button href={`/${owner}/${repo}/specs`} variant="primary" style="align-self:flex-start">
91a0204Claude3868 Let AI write your first feature
f6730d0ccantynz-alt3869 </Button>
91a0204Claude3870 </div>
3871 </div>
fc1817aClaude3872 </div>
3873 </Layout>
3874 );
3875 }
3876
3877 const readme = await getReadme(owner, repo, defaultBranch);
3878
544d842Claude3879 // Sidebar facts — derived from data we already have.
3880 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3881 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3882
5acce80Claude3883 const repoOgDesc = description
3884 ? `${owner}/${repo} on Gluecron — ${description}`
3885 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3886
fc1817aClaude3887 return c.html(
5acce80Claude3888 <Layout
3889 title={`${owner}/${repo}`}
3890 user={user}
3891 description={repoOgDesc}
3892 ogTitle={`${owner}/${repo} — Gluecron`}
3893 ogDescription={repoOgDesc}
3894 twitterCard="summary"
3895 >
544d842Claude3896 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3897 {/* Repo-context commands for the command palette (Feature 2) */}
3898 <script
3899 id="cmdk-repo-context"
3900 dangerouslySetInnerHTML={{
3901 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3902 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3903 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3904 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3905 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3906 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3907 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3908 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3909 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3910 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3911 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3912 ])};`,
3913 }}
3914 />
544d842Claude3915 <div class="repo-home-hero">
3916 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3917 <div class="repo-home-hero-orb" />
3918 </div>
3919 <div class="repo-home-hero-inner">
3920 <div class="repo-home-eyebrow">
3921 <strong>Repository</strong> · {owner}
3922 </div>
91a0204Claude3923 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3924 <RepoHeader
3925 owner={owner}
3926 repo={repo}
3927 starCount={starCount}
3928 starred={starred}
3929 forkCount={forkCount}
3930 currentUser={user?.username}
3931 archived={archived}
3932 isTemplate={isTemplate}
8c790e0Claude3933 recentPush={recentPush}
91a0204Claude3934 />
3935 {healthScore && (() => {
3936 const gradeLabel: Record<string, string> = {
3937 elite: "Elite", strong: "Strong",
3938 improving: "Improving", "needs-attention": "Needs Attention",
3939 };
3940 return (
3941 <a
3942 href={`/${owner}/${repo}/insights/health`}
3943 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3944 title={`Health score: ${healthScore!.total}/100`}
3945 >
3946 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3947 </a>
3948 );
3949 })()}
3950 </div>
544d842Claude3951 {description ? (
3952 <p class="repo-home-description">{description}</p>
3953 ) : (
3954 <p class="repo-home-description-empty">
3955 No description yet.
3956 </p>
3957 )}
3958 <div class="repo-home-stat-row" aria-label="Repository stats">
3959 <span class="repo-home-stat" title="Default branch">
3960 <span class="repo-home-stat-icon">{"⎇"}</span>
3961 <strong>{defaultBranch}</strong>
3962 </span>
3963 <a
3964 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3965 class="repo-home-stat"
3966 title="Browse all branches"
3967 >
3968 <span class="repo-home-stat-icon">{"⊢"}</span>
3969 <strong>{branches.length}</strong>{" "}
3970 branch{branches.length === 1 ? "" : "es"}
3971 </a>
3972 <span class="repo-home-stat" title="Top-level entries">
3973 <span class="repo-home-stat-icon">{"■"}</span>
3974 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3975 {dirCount > 0 && (
3976 <>
3977 {" · "}
3978 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3979 </>
3980 )}
3981 </span>
3982 {pushedAt && (
3983 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3984 <span class="repo-home-stat-icon">{"↻"}</span>
3985 Updated <strong>{formatRelative(pushedAt)}</strong>
3986 </span>
3987 )}
3988 </div>
3989 </div>
3990 </div>
71cd5ecClaude3991 {isTemplate && user && user.username !== owner && (
3992 <div
3993 class="panel"
dc26881CC LABS App3994 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3995 >
3996 <div style="font-size:13px">
3997 <strong>Template repository.</strong> Create a new repository from
3998 this template's files.
3999 </div>
4000 <form
001af43Claude4001 method="post"
71cd5ecClaude4002 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App4003 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude4004 >
4005 <input
4006 type="text"
4007 name="name"
4008 placeholder="new-repo-name"
4009 required
2c3ba6ecopilot-swe-agent[bot]4010 aria-label="New repository name"
71cd5ecClaude4011 style="width:200px"
4012 />
4013 <button type="submit" class="btn btn-primary">
4014 Use this template
4015 </button>
4016 </form>
4017 </div>
4018 )}
fc1817aClaude4019 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4020 <RepoHomePendingBanner
4021 owner={owner}
4022 repo={repo}
4023 count={repoHomePendingCount}
4024 />
641aa42Claude4025 {showOnboarding && onboardingRow && (
4026 <RepoOnboardingCard
4027 owner={owner}
4028 repo={repo}
4029 data={onboardingRow}
4030 />
4031 )}
c6018a5Claude4032 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4033 row sits just below the nav as a slim CTA strip. Scoped under
4034 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4035 <style
4036 dangerouslySetInnerHTML={{
4037 __html: `
4038 .repo-ai-cta-row {
4039 display: flex;
4040 flex-wrap: wrap;
4041 gap: 8px;
4042 margin: 12px 0 18px;
4043 padding: 10px 14px;
6fd5915Claude4044 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4045 border: 1px solid var(--border);
4046 border-radius: 10px;
4047 position: relative;
4048 overflow: hidden;
4049 }
4050 .repo-ai-cta-row::before {
4051 content: '';
4052 position: absolute;
4053 top: 0; left: 0; right: 0;
4054 height: 1px;
6fd5915Claude4055 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4056 opacity: 0.7;
4057 pointer-events: none;
4058 }
4059 .repo-ai-cta-label {
4060 font-size: 11px;
4061 font-weight: 600;
4062 letter-spacing: 0.06em;
4063 text-transform: uppercase;
4064 color: var(--accent);
4065 align-self: center;
4066 padding-right: 8px;
4067 border-right: 1px solid var(--border);
4068 margin-right: 4px;
4069 }
4070 .repo-ai-cta {
4071 display: inline-flex;
4072 align-items: center;
4073 gap: 6px;
4074 padding: 5px 10px;
4075 font-size: 12.5px;
4076 font-weight: 500;
4077 color: var(--text);
4078 background: var(--bg);
4079 border: 1px solid var(--border);
4080 border-radius: 7px;
4081 text-decoration: none;
4082 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4083 }
4084 .repo-ai-cta:hover {
6fd5915Claude4085 border-color: rgba(91,110,232,0.45);
c6018a5Claude4086 color: var(--text-strong);
6fd5915Claude4087 background: rgba(91,110,232,0.06);
c6018a5Claude4088 text-decoration: none;
4089 }
4090 .repo-ai-cta-icon {
4091 opacity: 0.75;
4092 font-size: 12px;
4093 }
4094 @media (max-width: 640px) {
4095 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4096 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4097 }
4098 `,
4099 }}
4100 />
4101 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4102 <span class="repo-ai-cta-label">AI surfaces</span>
4103 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4104 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4105 Chat
4106 </a>
4107 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4108 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4109 Previews
4110 </a>
4111 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4112 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4113 Migrations
4114 </a>
53c9249Claude4115 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4116 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4117 AI Search
c6018a5Claude4118 </a>
4119 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4120 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4121 AI release notes
4122 </a>
3646bfeClaude4123 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4124 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4125 Dev environment
4126 </a>
c6018a5Claude4127 </nav>
544d842Claude4128 <div class="repo-home-grid">
4129 <div class="repo-home-main">
4130 <BranchSwitcher
4131 owner={owner}
4132 repo={repo}
4133 currentRef={defaultBranch}
4134 branches={branches}
4135 pathType="tree"
4136 />
4137 <FileTable
4138 entries={tree}
4139 owner={owner}
4140 repo={repo}
4141 ref={defaultBranch}
4142 path=""
4143 />
ebaae0fClaude4144 {/* AI stats strip — one-line summary of AI value for this repo */}
4145 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4146 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4147 <span>{"⚡"}</span>
4148 {aiStats.mergedCount > 0 ? (
4149 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4150 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4151 </a>
4152 ) : (
4153 <span>0 AI merges this week</span>
4154 )}
4155 <span class="repo-ai-stats-sep">{"·"}</span>
4156 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4157 <span class="repo-ai-stats-sep">{"·"}</span>
4158 {aiStats.securityAlertCount > 0 ? (
4159 <a href={`/${owner}/${repo}/issues?label=security`}>
4160 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4161 </a>
4162 ) : (
4163 <span>0 open security alerts</span>
4164 )}
4165 </div>
4166 ) : (
4167 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4168 <span>{"⚡"}</span>
4169 <span>AI is watching — no activity this week</span>
4170 </div>
4171 )}
544d842Claude4172 {readme && (() => {
4173 const readmeHtml = renderMarkdown(readme);
4174 return (
4175 <div class="repo-home-readme">
4176 <div class="repo-home-readme-head">
4177 <span class="repo-home-readme-icon">{"☰"}</span>
4178 <span>README.md</span>
4179 </div>
4180 <style>{markdownCss}</style>
4181 <div class="markdown-body repo-home-readme-body">
4182 {html([readmeHtml] as unknown as TemplateStringsArray)}
4183 </div>
4184 </div>
4185 );
4186 })()}
4187 </div>
4188 <aside class="repo-home-side" aria-label="Repository details">
4189 <div class="repo-home-clone">
4190 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4191 <button
4192 type="button"
4193 class="repo-home-clone-tab"
4194 role="tab"
4195 aria-selected="true"
4196 data-pane="https"
4197 data-repo-home-clone-tab
4198 >
4199 HTTPS
4200 </button>
4201 <button
4202 type="button"
4203 class="repo-home-clone-tab"
4204 role="tab"
4205 aria-selected="false"
4206 data-pane="ssh"
4207 data-repo-home-clone-tab
4208 >
4209 SSH
4210 </button>
4211 <button
4212 type="button"
4213 class="repo-home-clone-tab"
4214 role="tab"
4215 aria-selected="false"
4216 data-pane="cli"
4217 data-repo-home-clone-tab
4218 >
4219 CLI
4220 </button>
4221 </div>
4222 <div
4223 class="repo-home-clone-pane"
4224 data-pane="https"
4225 data-active="true"
4226 role="tabpanel"
4227 >
4228 <div class="repo-home-clone-body">
4229 <input
4230 class="repo-home-clone-input"
4231 type="text"
4232 value={cloneHttpsUrl}
4233 readonly
4234 aria-label="HTTPS clone URL"
4235 data-repo-home-clone-input
4236 />
4237 <button
4238 type="button"
4239 class="repo-home-clone-copy"
4240 data-repo-home-copy={cloneHttpsUrl}
4241 >
4242 Copy
4243 </button>
4244 </div>
4245 </div>
4246 <div
4247 class="repo-home-clone-pane"
4248 data-pane="ssh"
4249 data-active="false"
4250 role="tabpanel"
4251 >
4252 <div class="repo-home-clone-body">
4253 <input
4254 class="repo-home-clone-input"
4255 type="text"
4256 value={cloneSshUrl}
4257 readonly
4258 aria-label="SSH clone URL"
4259 data-repo-home-clone-input
4260 />
4261 <button
4262 type="button"
4263 class="repo-home-clone-copy"
4264 data-repo-home-copy={cloneSshUrl}
4265 >
4266 Copy
4267 </button>
4268 </div>
4269 </div>
4270 <div
4271 class="repo-home-clone-pane"
4272 data-pane="cli"
4273 data-active="false"
4274 role="tabpanel"
4275 >
4276 <div class="repo-home-clone-body">
4277 <input
4278 class="repo-home-clone-input"
4279 type="text"
4280 value={cloneCliCmd}
4281 readonly
4282 aria-label="Gluecron CLI clone command"
4283 data-repo-home-clone-input
4284 />
4285 <button
4286 type="button"
4287 class="repo-home-clone-copy"
4288 data-repo-home-copy={cloneCliCmd}
4289 >
4290 Copy
4291 </button>
4292 </div>
79136bbClaude4293 </div>
fc1817aClaude4294 </div>
544d842Claude4295 <div class="repo-home-side-card">
4296 <h3 class="repo-home-side-title">About</h3>
4297 <div class="repo-home-side-row">
4298 <span class="repo-home-side-key">Default branch</span>
4299 <span class="repo-home-side-val">{defaultBranch}</span>
4300 </div>
4301 <div class="repo-home-side-row">
4302 <span class="repo-home-side-key">Branches</span>
4303 <span class="repo-home-side-val">
4304 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4305 {branches.length}
4306 </a>
4307 </span>
4308 </div>
4309 <div class="repo-home-side-row">
4310 <span class="repo-home-side-key">Stars</span>
4311 <span class="repo-home-side-val">{starCount}</span>
4312 </div>
4313 <div class="repo-home-side-row">
4314 <span class="repo-home-side-key">Forks</span>
4315 <span class="repo-home-side-val">{forkCount}</span>
4316 </div>
4317 {pushedAt && (
4318 <div class="repo-home-side-row">
4319 <span class="repo-home-side-key">Last push</span>
4320 <span class="repo-home-side-val">
4321 {formatRelative(pushedAt)}
4322 </span>
4323 </div>
4324 )}
4325 {createdAt && (
4326 <div class="repo-home-side-row">
4327 <span class="repo-home-side-key">Created</span>
4328 <span class="repo-home-side-val">
4329 {formatRelative(createdAt)}
4330 </span>
4331 </div>
4332 )}
4333 {(archived || isTemplate) && (
4334 <div class="repo-home-side-row">
4335 <span class="repo-home-side-key">State</span>
4336 <span class="repo-home-side-val">
4337 {archived ? "Archived" : "Template"}
4338 </span>
4339 </div>
4340 )}
4341 </div>
f1dc38bClaude4342 {/* Claude AI — quick-access card for authenticated users */}
4343 {user && (
4344 <div class="repo-home-side-card" style="margin-top:12px">
4345 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4346 ✨ Claude AI
4347 </h3>
4348 <a
4349 href={`/${owner}/${repo}/claude`}
e589f77ccantynz-alt4350 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"
f1dc38bClaude4351 >
4352 Claude Code sessions
4353 </a>
4354 <a
4355 href={`/${owner}/${repo}/ask`}
4356 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4357 >
4358 Ask AI about this repo
4359 </a>
4360 </div>
4361 )}
544d842Claude4362 </aside>
4363 </div>
4364 <script
4365 dangerouslySetInnerHTML={{
4366 __html: `
4367 (function(){
4368 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4369 tabs.forEach(function(tab){
4370 tab.addEventListener('click', function(){
4371 var target = tab.getAttribute('data-pane');
4372 tabs.forEach(function(t){
4373 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4374 });
4375 var panes = document.querySelectorAll('.repo-home-clone-pane');
4376 panes.forEach(function(p){
4377 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4378 });
4379 });
4380 });
4381 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4382 copyBtns.forEach(function(btn){
4383 btn.addEventListener('click', function(){
4384 var text = btn.getAttribute('data-repo-home-copy') || '';
4385 var done = function(){
4386 var prev = btn.textContent;
4387 btn.textContent = 'Copied';
4388 setTimeout(function(){ btn.textContent = prev; }, 1200);
4389 };
4390 if (navigator.clipboard && navigator.clipboard.writeText) {
4391 navigator.clipboard.writeText(text).then(done, done);
4392 } else {
4393 var ta = document.createElement('textarea');
4394 ta.value = text;
4395 document.body.appendChild(ta);
4396 ta.select();
4397 try { document.execCommand('copy'); } catch (e) {}
4398 document.body.removeChild(ta);
4399 done();
4400 }
4401 });
4402 });
4403 })();
4404 `,
4405 }}
4406 />
fc1817aClaude4407 </Layout>
4408 );
4409});
4410
4411// Browse tree at ref/path
4412web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4413 const { owner, repo } = c.req.param();
06d5ffeClaude4414 const user = c.get("user");
5bb52faccanty labs4415 const gate = await assertRepoReadable(c, owner, repo);
4416 if (gate) return gate;
fc1817aClaude4417 const refAndPath = c.req.param("ref");
4418
4419 const branches = await listBranches(owner, repo);
4420 let ref = "";
4421 let treePath = "";
4422
4423 for (const branch of branches) {
4424 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4425 ref = branch;
4426 treePath = refAndPath.slice(branch.length + 1);
4427 break;
4428 }
4429 }
4430
4431 if (!ref) {
4432 const slashIdx = refAndPath.indexOf("/");
4433 if (slashIdx === -1) {
4434 ref = refAndPath;
4435 } else {
4436 ref = refAndPath.slice(0, slashIdx);
4437 treePath = refAndPath.slice(slashIdx + 1);
4438 }
4439 }
4440
4441 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4442 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4443 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4444
4445 return c.html(
06d5ffeClaude4446 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4447 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4448 <RepoHeader owner={owner} repo={repo} />
4449 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4450 <div class="tree-header">
4451 <div class="tree-header-row">
4452 <BranchSwitcher
4453 owner={owner}
4454 repo={repo}
4455 currentRef={ref}
4456 branches={branches}
4457 pathType="tree"
4458 subPath={treePath}
4459 />
4460 <div class="tree-header-stats">
4461 <span class="tree-stat" title="Entries in this directory">
4462 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4463 {dirCount > 0 && (
4464 <>
4465 {" · "}
4466 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4467 </>
4468 )}
4469 </span>
4470 <a
4471 href={`/${owner}/${repo}/search`}
4472 class="tree-stat tree-stat-link"
4473 title="Search code in this repository"
4474 >
4475 {"⌕"} Search
4476 </a>
4477 </div>
4478 </div>
4479 <div class="tree-breadcrumb-row">
4480 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4481 </div>
4482 </div>
fc1817aClaude4483 <FileTable
4484 entries={tree}
4485 owner={owner}
4486 repo={repo}
4487 ref={ref}
4488 path={treePath}
4489 />
4490 </Layout>
4491 );
4492});
4493
06d5ffeClaude4494// View file blob with syntax highlighting
fc1817aClaude4495web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4496 const { owner, repo } = c.req.param();
06d5ffeClaude4497 const user = c.get("user");
5bb52faccanty labs4498 const gate = await assertRepoReadable(c, owner, repo);
4499 if (gate) return gate;
fc1817aClaude4500 const refAndPath = c.req.param("ref");
4501
4502 const branches = await listBranches(owner, repo);
4503 let ref = "";
4504 let filePath = "";
4505
4506 for (const branch of branches) {
4507 if (refAndPath.startsWith(branch + "/")) {
4508 ref = branch;
4509 filePath = refAndPath.slice(branch.length + 1);
4510 break;
4511 }
4512 }
4513
4514 if (!ref) {
4515 const slashIdx = refAndPath.indexOf("/");
4516 if (slashIdx === -1) return c.text("Not found", 404);
4517 ref = refAndPath.slice(0, slashIdx);
4518 filePath = refAndPath.slice(slashIdx + 1);
4519 }
4520
4521 const blob = await getBlob(owner, repo, ref, filePath);
4522 if (!blob) {
4523 return c.html(
06d5ffeClaude4524 <Layout title="Not Found" user={user}>
fc1817aClaude4525 <div class="empty-state">
4526 <h2>File not found</h2>
4527 </div>
4528 </Layout>,
4529 404
4530 );
4531 }
4532
06d5ffeClaude4533 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4534 const lineCount = blob.isBinary
4535 ? 0
4536 : (blob.content.endsWith("\n")
4537 ? blob.content.split("\n").length - 1
4538 : blob.content.split("\n").length);
4539 const formatBytes = (n: number): string => {
4540 if (n < 1024) return `${n} B`;
4541 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4542 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4543 };
fc1817aClaude4544
4545 return c.html(
06d5ffeClaude4546 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4547 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4548 <RepoHeader owner={owner} repo={repo} />
4549 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4550 <div class="blob-toolbar">
4551 <BranchSwitcher
4552 owner={owner}
4553 repo={repo}
4554 currentRef={ref}
4555 branches={branches}
4556 pathType="blob"
4557 subPath={filePath}
4558 />
4559 <div class="blob-breadcrumb">
4560 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4561 </div>
4562 </div>
4563 <div class="blob-view blob-card">
4564 <div class="blob-header blob-header-polished">
4565 <div class="blob-header-meta">
4566 <span class="blob-header-icon" aria-hidden="true">
4567 {"📄"}
4568 </span>
4569 <span class="blob-header-name">{fileName}</span>
4570 <span class="blob-header-size">
4571 {formatBytes(blob.size)}
4572 {!blob.isBinary && (
4573 <>
4574 {" · "}
4575 {lineCount} line{lineCount === 1 ? "" : "s"}
4576 </>
4577 )}
4578 </span>
4579 </div>
4580 <div class="blob-header-actions">
4581 <a
4582 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4583 class="blob-pill"
4584 >
79136bbClaude4585 Raw
4586 </a>
efb11c5Claude4587 <a
4588 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4589 class="blob-pill"
4590 >
79136bbClaude4591 Blame
4592 </a>
efb11c5Claude4593 <a
4594 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4595 class="blob-pill"
4596 >
16b325cClaude4597 History
4598 </a>
0074234Claude4599 {user && (
efb11c5Claude4600 <a
4601 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4602 class="blob-pill blob-pill-accent"
4603 >
0074234Claude4604 Edit
4605 </a>
4606 )}
efb11c5Claude4607 </div>
fc1817aClaude4608 </div>
4609 {blob.isBinary ? (
efb11c5Claude4610 <div class="blob-binary">
fc1817aClaude4611 Binary file not shown.
4612 </div>
06d5ffeClaude4613 ) : (() => {
4614 const { html: highlighted, language } = highlightCode(
4615 blob.content,
4616 fileName
4617 );
4618 if (language) {
4619 return (
4620 <HighlightedCode
4621 highlightedHtml={highlighted}
efb11c5Claude4622 lineCount={lineCount}
06d5ffeClaude4623 />
4624 );
4625 }
4626 const lines = blob.content.split("\n");
4627 if (lines[lines.length - 1] === "") lines.pop();
4628 return <PlainCode lines={lines} />;
4629 })()}
fc1817aClaude4630 </div>
4631 </Layout>
4632 );
4633});
4634
398a10cClaude4635// ─── Branches list ────────────────────────────────────────────────────────
4636// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4637// author + relative time + ahead/behind vs the default branch. No DB. All
4638// data comes from git plumbing; failures degrade gracefully (counts omitted).
4639web.get("/:owner/:repo/branches", async (c) => {
4640 const { owner, repo } = c.req.param();
4641 const user = c.get("user");
5bb52faccanty labs4642 const gate = await assertRepoReadable(c, owner, repo);
4643 if (gate) return gate;
398a10cClaude4644
4645 if (!(await repoExists(owner, repo))) return c.notFound();
4646
4647 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4648 const branches = await listBranches(owner, repo);
4649 const repoDir = getRepoPath(owner, repo);
4650
4651 type BranchRow = {
4652 name: string;
4653 isDefault: boolean;
4654 sha: string;
4655 subject: string;
4656 author: string;
4657 date: string;
4658 ahead: number;
4659 behind: number;
4660 };
4661
4662 const runGit = async (args: string[]): Promise<string> => {
4663 try {
4664 const proc = Bun.spawn(["git", ...args], {
4665 cwd: repoDir,
4666 stdout: "pipe",
4667 stderr: "pipe",
4668 });
4669 const out = await new Response(proc.stdout).text();
4670 await proc.exited;
4671 return out;
4672 } catch {
4673 return "";
4674 }
4675 };
4676
4677 const meta = await runGit([
4678 "for-each-ref",
4679 "--sort=-committerdate",
4680 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4681 "refs/heads/",
4682 ]);
4683 const metaByName: Record<
4684 string,
4685 { sha: string; subject: string; author: string; date: string }
4686 > = {};
4687 for (const line of meta.split("\n").filter(Boolean)) {
4688 const [name, sha, subject, author, date] = line.split("\0");
4689 metaByName[name] = { sha, subject, author, date };
4690 }
4691
4692 const branchOrder = [...branches].sort((a, b) => {
4693 if (a === defaultBranch) return -1;
4694 if (b === defaultBranch) return 1;
4695 const aDate = metaByName[a]?.date || "";
4696 const bDate = metaByName[b]?.date || "";
4697 return bDate.localeCompare(aDate);
4698 });
4699
4700 const rows: BranchRow[] = [];
4701 for (const name of branchOrder) {
4702 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4703 let ahead = 0;
4704 let behind = 0;
4705 if (name !== defaultBranch && metaByName[defaultBranch]) {
4706 const out = await runGit([
4707 "rev-list",
4708 "--left-right",
4709 "--count",
4710 `${defaultBranch}...${name}`,
4711 ]);
4712 const parts = out.trim().split(/\s+/);
4713 if (parts.length === 2) {
4714 behind = parseInt(parts[0], 10) || 0;
4715 ahead = parseInt(parts[1], 10) || 0;
4716 }
4717 }
4718 rows.push({
4719 name,
4720 isDefault: name === defaultBranch,
4721 sha: m.sha,
4722 subject: m.subject,
4723 author: m.author,
4724 date: m.date,
4725 ahead,
4726 behind,
4727 });
4728 }
4729
4730 const relative = (iso: string): string => {
4731 if (!iso) return "—";
4732 const d = new Date(iso);
4733 if (Number.isNaN(d.getTime())) return "—";
4734 const diff = Date.now() - d.getTime();
4735 const m = Math.floor(diff / 60000);
4736 if (m < 1) return "just now";
4737 if (m < 60) return `${m} min ago`;
4738 const h = Math.floor(m / 60);
4739 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4740 const dd = Math.floor(h / 24);
4741 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4742 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4743 };
4744
4745 const success = c.req.query("success");
4746 const error = c.req.query("error");
4747
4748 return c.html(
4749 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4750 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4751 <RepoHeader owner={owner} repo={repo} />
4752 <RepoNav owner={owner} repo={repo} active="code" />
4753 <div
4754 class="branches-wrap"
eed4684Claude4755 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4756 >
4757 <header class="branches-head" style="margin-bottom:var(--space-5)">
4758 <div
4759 class="branches-eyebrow"
4760 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"
4761 >
4762 <span
4763 class="branches-eyebrow-dot"
4764 aria-hidden="true"
6fd5915Claude4765 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)"
398a10cClaude4766 />
4767 Repository · Branches
4768 </div>
4769 <h1
4770 class="branches-title"
4771 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)"
4772 >
4773 <span class="gradient-text">{rows.length}</span>{" "}
4774 branch{rows.length === 1 ? "" : "es"}
4775 </h1>
4776 <p
4777 class="branches-sub"
4778 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4779 >
4780 All work-in-progress lines for{" "}
4781 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4782 counts are relative to{" "}
4783 <code style="font-size:12.5px">{defaultBranch}</code>.
4784 </p>
4785 </header>
4786
4787 {success && (
4788 <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">
4789 {decodeURIComponent(success)}
4790 </div>
4791 )}
4792 {error && (
4793 <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">
4794 {decodeURIComponent(error)}
4795 </div>
4796 )}
4797
4798 {rows.length === 0 ? (
4799 <div class="commits-empty">
4800 <div class="commits-empty-orb" aria-hidden="true" />
4801 <div class="commits-empty-inner">
4802 <div class="commits-empty-icon" aria-hidden="true">
4803 <svg
4804 width="22"
4805 height="22"
4806 viewBox="0 0 24 24"
4807 fill="none"
4808 stroke="currentColor"
4809 stroke-width="2"
4810 stroke-linecap="round"
4811 stroke-linejoin="round"
4812 >
4813 <line x1="6" y1="3" x2="6" y2="15" />
4814 <circle cx="18" cy="6" r="3" />
4815 <circle cx="6" cy="18" r="3" />
4816 <path d="M18 9a9 9 0 0 1-9 9" />
4817 </svg>
4818 </div>
4819 <h3 class="commits-empty-title">No branches yet</h3>
4820 <p class="commits-empty-sub">
4821 Push your first commit to create the default branch.
4822 </p>
4823 </div>
4824 </div>
4825 ) : (
4826 <div class="branches-list">
4827 {rows.map((r) => (
4828 <div class="branches-row">
4829 <div class="branches-row-icon" aria-hidden="true">
4830 <svg
4831 width="14"
4832 height="14"
4833 viewBox="0 0 24 24"
4834 fill="none"
4835 stroke="currentColor"
4836 stroke-width="2"
4837 stroke-linecap="round"
4838 stroke-linejoin="round"
4839 >
4840 <line x1="6" y1="3" x2="6" y2="15" />
4841 <circle cx="18" cy="6" r="3" />
4842 <circle cx="6" cy="18" r="3" />
4843 <path d="M18 9a9 9 0 0 1-9 9" />
4844 </svg>
4845 </div>
4846 <div class="branches-row-main">
4847 <div class="branches-row-name">
4848 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4849 {r.isDefault && (
4850 <span
4851 class="branches-row-default"
4852 title="Default branch"
4853 >
4854 Default
4855 </span>
4856 )}
4857 </div>
4858 <div class="branches-row-meta">
4859 {r.subject ? (
4860 <>
4861 <strong>{r.author || "—"}</strong>
4862 <span class="sep">·</span>
4863 <span
4864 title={r.date ? new Date(r.date).toISOString() : ""}
4865 >
4866 updated {relative(r.date)}
4867 </span>
4868 {r.sha && (
4869 <>
4870 <span class="sep">·</span>
4871 <a
4872 href={`/${owner}/${repo}/commit/${r.sha}`}
4873 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4874 >
4875 {r.sha.slice(0, 7)}
4876 </a>
4877 </>
4878 )}
4879 </>
4880 ) : (
4881 <span>No commit metadata</span>
4882 )}
4883 </div>
4884 </div>
4885 <div class="branches-row-side">
4886 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4887 <span
4888 class="branches-row-divergence"
4889 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4890 >
4891 <span class="ahead">{r.ahead} ahead</span>
4892 <span style="opacity:0.4">|</span>
4893 <span class="behind">{r.behind} behind</span>
4894 </span>
4895 )}
4896 <div class="branches-row-actions">
4897 <a
4898 href={`/${owner}/${repo}/commits/${r.name}`}
4899 class="branches-btn"
4900 title="View commits on this branch"
4901 >
4902 Commits
4903 </a>
4904 {!r.isDefault &&
4905 user &&
4906 user.username === owner && (
4907 <form
4908 method="post"
4909 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4910 style="margin:0"
4911 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4912 >
4913 <button
4914 type="submit"
4915 class="branches-btn branches-btn-danger"
4916 >
4917 Delete
4918 </button>
4919 </form>
4920 )}
4921 </div>
4922 </div>
4923 </div>
4924 ))}
4925 </div>
4926 )}
4927 </div>
4928 </Layout>
4929 );
4930});
4931
4932// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4933// that are not merged into the default branch — matches the explicit
4934// confirmation on the row's delete button.
4935web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4936 const { owner, repo } = c.req.param();
4937 const branchName = decodeURIComponent(c.req.param("name"));
4938 const user = c.get("user")!;
4939
4940 // Owner-only check (mirrors collaborators.tsx pattern).
4941 const [ownerRow] = await db
4942 .select()
4943 .from(users)
4944 .where(eq(users.username, owner))
4945 .limit(1);
4946 if (!ownerRow || ownerRow.id !== user.id) {
4947 return c.redirect(
4948 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4949 );
4950 }
4951
4952 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4953 if (branchName === defaultBranch) {
4954 return c.redirect(
4955 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4956 );
4957 }
4958
4959 const branches = await listBranches(owner, repo);
4960 if (!branches.includes(branchName)) {
4961 return c.redirect(
4962 `/${owner}/${repo}/branches?error=Branch+not+found`
4963 );
4964 }
4965
4966 try {
4967 const repoDir = getRepoPath(owner, repo);
4968 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4969 cwd: repoDir,
4970 stdout: "pipe",
4971 stderr: "pipe",
4972 });
4973 await proc.exited;
4974 if (proc.exitCode !== 0) {
4975 return c.redirect(
4976 `/${owner}/${repo}/branches?error=Delete+failed`
4977 );
4978 }
4979 } catch {
4980 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4981 }
4982
4983 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4984});
4985
4986// ─── Tags list ────────────────────────────────────────────────────────────
4987// Pulls from `listTags` (sorted newest first). For each tag we look up an
4988// associated release row so the "View release" CTA can deep-link directly
4989// without making the user hunt for it.
4990web.get("/:owner/:repo/tags", async (c) => {
4991 const { owner, repo } = c.req.param();
4992 const user = c.get("user");
5bb52faccanty labs4993 const gate = await assertRepoReadable(c, owner, repo);
4994 if (gate) return gate;
398a10cClaude4995
4996 if (!(await repoExists(owner, repo))) return c.notFound();
4997
4998 const tags = await listTags(owner, repo);
4999
5000 // Map tags -> releases. Best-effort; releases table may not exist in
5001 // every test setup, so any error falls through with an empty set.
5002 const tagsWithReleases = new Set<string>();
5003 try {
5004 const [ownerRow] = await db
5005 .select()
5006 .from(users)
5007 .where(eq(users.username, owner))
5008 .limit(1);
5009 if (ownerRow) {
5010 const [repoRow] = await db
5011 .select()
5012 .from(repositories)
5013 .where(
5014 and(
5015 eq(repositories.ownerId, ownerRow.id),
5016 eq(repositories.name, repo)
5017 )
5018 )
5019 .limit(1);
5020 if (repoRow) {
5021 // Raw SQL so we don't need to import the releases schema here.
5022 const result = await db.execute(
5023 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5024 );
5025 const rows: any[] = (result as any).rows || (result as any) || [];
5026 for (const row of rows) {
5027 const tag = row?.tag;
5028 if (typeof tag === "string") tagsWithReleases.add(tag);
5029 }
5030 }
5031 }
5032 } catch {
5033 // No releases table or DB error — leave set empty.
5034 }
5035
5036 const relative = (iso: string): string => {
5037 if (!iso) return "—";
5038 const d = new Date(iso);
5039 if (Number.isNaN(d.getTime())) return "—";
5040 const diff = Date.now() - d.getTime();
5041 const m = Math.floor(diff / 60000);
5042 if (m < 1) return "just now";
5043 if (m < 60) return `${m} min ago`;
5044 const h = Math.floor(m / 60);
5045 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5046 const dd = Math.floor(h / 24);
5047 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5048 return d.toLocaleDateString("en-US", {
5049 month: "short",
5050 day: "numeric",
5051 year: "numeric",
5052 });
5053 };
5054
5055 return c.html(
5056 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5057 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5058 <RepoHeader owner={owner} repo={repo} />
5059 <RepoNav owner={owner} repo={repo} active="code" />
5060 <div
5061 class="tags-wrap"
eed4684Claude5062 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5063 >
5064 <header class="tags-head" style="margin-bottom:var(--space-5)">
5065 <div
5066 class="tags-eyebrow"
5067 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"
5068 >
5069 <span
5070 class="tags-eyebrow-dot"
5071 aria-hidden="true"
6fd5915Claude5072 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)"
398a10cClaude5073 />
5074 Repository · Tags
5075 </div>
5076 <h1
5077 class="tags-title"
5078 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)"
5079 >
5080 <span class="gradient-text">{tags.length}</span>{" "}
5081 tag{tags.length === 1 ? "" : "s"}
5082 </h1>
5083 <p
5084 class="tags-sub"
5085 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5086 >
5087 Named points in the history — typically releases, milestones,
5088 or shipped versions. Click a tag to browse the tree at that
5089 revision.
5090 </p>
5091 </header>
5092
5093 {tags.length === 0 ? (
5094 <div class="commits-empty">
5095 <div class="commits-empty-orb" aria-hidden="true" />
5096 <div class="commits-empty-inner">
5097 <div class="commits-empty-icon" aria-hidden="true">
5098 <svg
5099 width="22"
5100 height="22"
5101 viewBox="0 0 24 24"
5102 fill="none"
5103 stroke="currentColor"
5104 stroke-width="2"
5105 stroke-linecap="round"
5106 stroke-linejoin="round"
5107 >
5108 <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" />
5109 <line x1="7" y1="7" x2="7.01" y2="7" />
5110 </svg>
5111 </div>
5112 <h3 class="commits-empty-title">No tags yet</h3>
5113 <p class="commits-empty-sub">
5114 Tag a commit to mark a release or milestone. From the CLI:{" "}
5115 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5116 </p>
5117 </div>
5118 </div>
5119 ) : (
5120 <div class="tags-list">
5121 {tags.map((t) => {
5122 const hasRelease = tagsWithReleases.has(t.name);
5123 return (
5124 <div class="tags-row">
5125 <div class="tags-row-icon" aria-hidden="true">
5126 <svg
5127 width="14"
5128 height="14"
5129 viewBox="0 0 24 24"
5130 fill="none"
5131 stroke="currentColor"
5132 stroke-width="2"
5133 stroke-linecap="round"
5134 stroke-linejoin="round"
5135 >
5136 <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" />
5137 <line x1="7" y1="7" x2="7.01" y2="7" />
5138 </svg>
5139 </div>
5140 <div class="tags-row-main">
5141 <div class="tags-row-name">
5142 <a
5143 href={`/${owner}/${repo}/tree/${t.name}`}
5144 style="text-decoration:none"
5145 >
5146 <span class="tags-row-version">{t.name}</span>
5147 </a>
5148 </div>
5149 <div class="tags-row-meta">
5150 <span
5151 title={t.date ? new Date(t.date).toISOString() : ""}
5152 >
5153 Tagged {relative(t.date)}
5154 </span>
5155 <span class="sep">·</span>
5156 <a
5157 href={`/${owner}/${repo}/commit/${t.sha}`}
5158 class="tags-row-sha"
5159 title={t.sha}
5160 >
5161 {t.sha.slice(0, 7)}
5162 </a>
5163 </div>
5164 </div>
5165 <div class="tags-row-side">
5166 <a
5167 href={`/${owner}/${repo}/tree/${t.name}`}
5168 class="tags-row-link"
5169 >
5170 Browse files
5171 </a>
5172 {hasRelease && (
5173 <a
5174 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5175 class="tags-row-link"
6fd5915Claude5176 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5177 >
5178 View release
5179 </a>
5180 )}
5181 </div>
5182 </div>
5183 );
5184 })}
5185 </div>
5186 )}
5187 </div>
5188 </Layout>
5189 );
5190});
5191
fc1817aClaude5192// Commit log
5193web.get("/:owner/:repo/commits/:ref?", async (c) => {
5194 const { owner, repo } = c.req.param();
06d5ffeClaude5195 const user = c.get("user");
5bb52faccanty labs5196 const gate = await assertRepoReadable(c, owner, repo);
5197 if (gate) return gate;
fc1817aClaude5198 const ref =
5199 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5200 const branches = await listBranches(owner, repo);
fc1817aClaude5201
5202 const commits = await listCommits(owner, repo, ref, 50);
5203
3951454Claude5204 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5205 // Also resolve repoId here so we can query the recent push for the
5206 // Push Watch live/watch indicator in the header.
3951454Claude5207 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5208 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5209 try {
5210 const [ownerRow] = await db
5211 .select()
5212 .from(users)
5213 .where(eq(users.username, owner))
5214 .limit(1);
5215 if (ownerRow) {
5216 const [repoRow] = await db
5217 .select()
5218 .from(repositories)
5219 .where(
5220 and(
5221 eq(repositories.ownerId, ownerRow.id),
5222 eq(repositories.name, repo)
5223 )
5224 )
5225 .limit(1);
8c790e0Claude5226 if (repoRow) {
5227 // Fetch verifications and recent push in parallel.
5228 const [verRows, rp] = await Promise.all([
5229 commits.length > 0
5230 ? db
5231 .select()
5232 .from(commitVerifications)
5233 .where(
5234 and(
5235 eq(commitVerifications.repositoryId, repoRow.id),
5236 inArray(
5237 commitVerifications.commitSha,
5238 commits.map((c) => c.sha)
5239 )
5240 )
5241 )
5242 : Promise.resolve([]),
5243 getRecentPush(repoRow.id),
5244 ]);
5245 for (const r of verRows) {
3951454Claude5246 verifications[r.commitSha] = {
5247 verified: r.verified,
5248 reason: r.reason,
5249 };
5250 }
8c790e0Claude5251 commitsPageRecentPush = rp;
3951454Claude5252 }
5253 }
5254 } catch {
5255 // DB unavailable — skip the badges gracefully.
5256 }
5257
fc1817aClaude5258 return c.html(
06d5ffeClaude5259 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5260 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5261 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5262 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5263 <div class="commits-hero">
5264 <div class="commits-hero-orb-wrap" aria-hidden="true">
5265 <div class="commits-hero-orb" />
fc1817aClaude5266 </div>
efb11c5Claude5267 <div class="commits-hero-inner">
5268 <div class="commits-eyebrow">
5269 <strong>History</strong> · {owner}/{repo}
5270 </div>
5271 <h1 class="commits-title">
5272 <span class="gradient-text">{commits.length}</span> recent commit
5273 {commits.length === 1 ? "" : "s"} on{" "}
5274 <span class="commits-branch">{ref}</span>
5275 </h1>
5276 <p class="commits-sub">
5277 Browse the project's history. Click any commit to see the full
5278 diff, AI review notes, and signature status.
5279 </p>
5280 </div>
5281 </div>
5282 <div class="commits-toolbar">
5283 <BranchSwitcher
3951454Claude5284 owner={owner}
5285 repo={repo}
efb11c5Claude5286 currentRef={ref}
5287 branches={branches}
5288 pathType="commits"
3951454Claude5289 />
398a10cClaude5290 <div class="commits-toolbar-actions">
5291 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5292 {"⊢"} Branches
5293 </a>
5294 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5295 {"#"} Tags
5296 </a>
5297 </div>
efb11c5Claude5298 </div>
5299 {commits.length === 0 ? (
5300 <div class="commits-empty">
398a10cClaude5301 <div class="commits-empty-orb" aria-hidden="true" />
5302 <div class="commits-empty-inner">
5303 <div class="commits-empty-icon" aria-hidden="true">
5304 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5305 <circle cx="12" cy="12" r="4" />
5306 <line x1="1.05" y1="12" x2="7" y2="12" />
5307 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5308 </svg>
5309 </div>
5310 <h3 class="commits-empty-title">No commits yet</h3>
5311 <p class="commits-empty-sub">
5312 This branch is empty. Push your first commit, or use the
5313 web editor to create a file.
5314 </p>
5315 </div>
efb11c5Claude5316 </div>
5317 ) : (
5318 <div class="commits-list-wrap">
398a10cClaude5319 {(() => {
5320 // Group commits by day for the section headers.
5321 const dayLabel = (iso: string): string => {
5322 const d = new Date(iso);
5323 if (Number.isNaN(d.getTime())) return "Unknown";
5324 const today = new Date();
5325 const yesterday = new Date(today);
5326 yesterday.setDate(today.getDate() - 1);
5327 const sameDay = (a: Date, b: Date) =>
5328 a.getFullYear() === b.getFullYear() &&
5329 a.getMonth() === b.getMonth() &&
5330 a.getDate() === b.getDate();
5331 if (sameDay(d, today)) return "Today";
5332 if (sameDay(d, yesterday)) return "Yesterday";
5333 return d.toLocaleDateString("en-US", {
5334 month: "long",
5335 day: "numeric",
5336 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5337 });
5338 };
5339 const relative = (iso: string): string => {
5340 const d = new Date(iso);
5341 if (Number.isNaN(d.getTime())) return "";
5342 const diff = Date.now() - d.getTime();
5343 const m = Math.floor(diff / 60000);
5344 if (m < 1) return "just now";
5345 if (m < 60) return `${m} min ago`;
5346 const h = Math.floor(m / 60);
5347 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5348 const dd = Math.floor(h / 24);
5349 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5350 return d.toLocaleDateString("en-US", {
5351 month: "short",
5352 day: "numeric",
5353 });
5354 };
5355 const initial = (name: string): string =>
5356 (name || "?").trim().charAt(0).toUpperCase() || "?";
5357 // Build groups preserving order.
5358 const groups: Array<{ label: string; items: typeof commits }> = [];
5359 let lastLabel = "";
5360 for (const cm of commits) {
5361 const label = dayLabel(cm.date);
5362 if (label !== lastLabel) {
5363 groups.push({ label, items: [] });
5364 lastLabel = label;
5365 }
5366 groups[groups.length - 1].items.push(cm);
5367 }
5368 return groups.map((g) => (
5369 <>
5370 <div class="commits-day-head">
5371 <span class="commits-day-head-dot" aria-hidden="true" />
5372 Commits on {g.label}
5373 </div>
5374 {g.items.map((cm) => {
5375 const v = verifications[cm.sha];
5376 return (
5377 <div class="commits-row">
5378 <div class="commits-avatar" aria-hidden="true">
5379 {initial(cm.author)}
5380 </div>
5381 <div class="commits-row-body">
5382 <div class="commits-row-msg">
5383 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5384 {cm.message}
5385 </a>
5386 {v?.verified && (
5387 <span
5388 class="commits-row-verified"
5389 title="Signed with a registered key"
5390 >
5391 Verified
5392 </span>
5393 )}
5394 </div>
5395 <div class="commits-row-meta">
5396 <strong>{cm.author}</strong>
5397 <span class="sep">·</span>
5398 <span
5399 class="commits-row-time"
5400 title={new Date(cm.date).toISOString()}
5401 >
5402 committed {relative(cm.date)}
5403 </span>
5404 {cm.parentShas.length > 1 && (
5405 <>
5406 <span class="sep">·</span>
5407 <span>merge of {cm.parentShas.length} parents</span>
5408 </>
5409 )}
5410 </div>
5411 </div>
5412 <div class="commits-row-side">
5413 <a
5414 href={`/${owner}/${repo}/commit/${cm.sha}`}
5415 class="commits-row-sha"
5416 title={cm.sha}
5417 >
5418 {cm.sha.slice(0, 7)}
5419 </a>
8c790e0Claude5420 <a
5421 href={`/${owner}/${repo}/push/${cm.sha}`}
5422 class="commits-row-watch"
5423 title="Watch gate + deploy results for this push"
5424 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5425 >
5426 <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">
5427 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5428 <circle cx="12" cy="12" r="3" />
5429 </svg>
5430 </a>
398a10cClaude5431 <button
5432 type="button"
5433 class="commits-row-copy"
5434 data-copy-sha={cm.sha}
5435 title="Copy full SHA"
5436 aria-label={`Copy SHA ${cm.sha}`}
5437 >
5438 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5439 <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" />
5440 <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" />
5441 </svg>
5442 </button>
5443 </div>
5444 </div>
5445 );
5446 })}
5447 </>
5448 ));
5449 })()}
efb11c5Claude5450 </div>
fc1817aClaude5451 )}
398a10cClaude5452 <script
5453 dangerouslySetInnerHTML={{
5454 __html: `
5455 (function(){
5456 document.addEventListener('click', function(e){
5457 var t = e.target; if (!t) return;
5458 var btn = t.closest && t.closest('[data-copy-sha]');
5459 if (!btn) return;
5460 e.preventDefault();
5461 var sha = btn.getAttribute('data-copy-sha') || '';
5462 if (!navigator.clipboard) return;
5463 navigator.clipboard.writeText(sha).then(function(){
5464 btn.classList.add('is-copied');
5465 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5466 }).catch(function(){});
5467 });
5468 })();
5469 `,
5470 }}
5471 />
fc1817aClaude5472 </Layout>
5473 );
5474});
5475
5476// Single commit with diff
5477web.get("/:owner/:repo/commit/:sha", async (c) => {
5478 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5479 const user = c.get("user");
5bb52faccanty labs5480 const gate = await assertRepoReadable(c, owner, repo);
5481 if (gate) return gate;
fc1817aClaude5482
05b973eClaude5483 // Fetch commit, full message, and diff in parallel
5484 const [commit, fullMessage, diffResult] = await Promise.all([
5485 getCommit(owner, repo, sha),
5486 getCommitFullMessage(owner, repo, sha),
5487 getDiff(owner, repo, sha),
5488 ]);
fc1817aClaude5489 if (!commit) {
5490 return c.html(
06d5ffeClaude5491 <Layout title="Not Found" user={user}>
fc1817aClaude5492 <div class="empty-state">
5493 <h2>Commit not found</h2>
5494 </div>
5495 </Layout>,
5496 404
5497 );
5498 }
5499
3951454Claude5500 // Block J3 — try to verify this commit's signature.
5501 let verification:
5502 | { verified: boolean; reason: string; signatureType: string | null }
5503 | null = null;
0cdfd89Claude5504 // Block J8 — external CI commit statuses rollup.
5505 let statusCombined:
5506 | {
5507 state: "pending" | "success" | "failure";
5508 total: number;
5509 contexts: Array<{
5510 context: string;
5511 state: string;
5512 description: string | null;
5513 targetUrl: string | null;
5514 }>;
5515 }
5516 | null = null;
3951454Claude5517 try {
5518 const [ownerRow] = await db
5519 .select()
5520 .from(users)
5521 .where(eq(users.username, owner))
5522 .limit(1);
5523 if (ownerRow) {
5524 const [repoRow] = await db
5525 .select()
5526 .from(repositories)
5527 .where(
5528 and(
5529 eq(repositories.ownerId, ownerRow.id),
5530 eq(repositories.name, repo)
5531 )
5532 )
5533 .limit(1);
5534 if (repoRow) {
5535 const { verifyCommit } = await import("../lib/signatures");
5536 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5537 verification = {
5538 verified: v.verified,
5539 reason: v.reason,
5540 signatureType: v.signatureType,
5541 };
0cdfd89Claude5542 try {
5543 const { combinedStatus } = await import("../lib/commit-statuses");
5544 const combined = await combinedStatus(repoRow.id, commit.sha);
5545 if (combined.total > 0) {
5546 statusCombined = {
5547 state: combined.state as any,
5548 total: combined.total,
5549 contexts: combined.contexts.map((c) => ({
5550 context: c.context,
5551 state: c.state,
5552 description: c.description,
5553 targetUrl: c.targetUrl,
5554 })),
5555 };
5556 }
5557 } catch {
5558 statusCombined = null;
5559 }
3951454Claude5560 }
5561 }
5562 } catch {
5563 verification = null;
5564 }
5565
05b973eClaude5566 const { files, raw } = diffResult;
fc1817aClaude5567
efb11c5Claude5568 // Diff stats: count additions / deletions across all files for the
5569 // header summary bar. Computed here from the parsed diff so we don't
5570 // touch the DiffView component.
5571 let additions = 0;
5572 let deletions = 0;
5573 for (const f of files) {
5574 const hunks = (f as any).hunks as Array<any> | undefined;
5575 if (Array.isArray(hunks)) {
5576 for (const h of hunks) {
5577 const lines = (h?.lines || []) as Array<any>;
5578 for (const ln of lines) {
5579 const t = ln?.type || ln?.kind;
5580 if (t === "add" || t === "added" || t === "+") additions += 1;
5581 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5582 deletions += 1;
5583 }
5584 }
5585 }
5586 }
5587 // Fall back: scan raw if file-level counting yielded zero (it's just a
5588 // header polish — never let a parsing miss break the page).
5589 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5590 for (const line of raw.split("\n")) {
5591 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5592 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5593 }
5594 }
5595 const fileCount = files.length;
5596
fc1817aClaude5597 return c.html(
06d5ffeClaude5598 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5599 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5600 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5601 <div class="commit-detail-card">
5602 <div class="commit-detail-eyebrow">
5603 <strong>Commit</strong>
5604 <span class="commit-detail-sha-pill" title={commit.sha}>
5605 {commit.sha.slice(0, 7)}
5606 </span>
3951454Claude5607 {verification && verification.reason !== "unsigned" && (
5608 <span
efb11c5Claude5609 class={`commit-detail-verify ${
3951454Claude5610 verification.verified
efb11c5Claude5611 ? "commit-detail-verify-ok"
5612 : "commit-detail-verify-warn"
3951454Claude5613 }`}
5614 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5615 >
5616 {verification.verified ? "Verified" : verification.reason}
5617 </span>
5618 )}
fc1817aClaude5619 </div>
efb11c5Claude5620 <h1 class="commit-detail-title">{commit.message}</h1>
5621 {fullMessage !== commit.message && (
5622 <pre class="commit-detail-body">{fullMessage}</pre>
5623 )}
5624 <div class="commit-detail-meta">
5625 <span class="commit-detail-author">
5626 <strong>{commit.author}</strong> committed on{" "}
5627 {new Date(commit.date).toLocaleDateString("en-US", {
5628 month: "long",
5629 day: "numeric",
5630 year: "numeric",
5631 })}
5632 </span>
fc1817aClaude5633 {commit.parentShas.length > 0 && (
efb11c5Claude5634 <span class="commit-detail-parents">
5635 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5636 {commit.parentShas.map((p, idx) => (
5637 <>
5638 {idx > 0 && " "}
5639 <a
5640 href={`/${owner}/${repo}/commit/${p}`}
5641 class="commit-detail-sha-link"
5642 >
5643 {p.slice(0, 7)}
5644 </a>
5645 </>
fc1817aClaude5646 ))}
5647 </span>
5648 )}
5649 </div>
efb11c5Claude5650 <div class="commit-detail-stats">
5651 <span class="commit-detail-stat">
5652 <strong>{fileCount}</strong>{" "}
5653 file{fileCount === 1 ? "" : "s"} changed
5654 </span>
5655 <span class="commit-detail-stat commit-detail-stat-add">
5656 <span class="commit-detail-stat-mark">+</span>
5657 <strong>{additions}</strong>
5658 </span>
5659 <span class="commit-detail-stat commit-detail-stat-del">
5660 <span class="commit-detail-stat-mark">−</span>
5661 <strong>{deletions}</strong>
5662 </span>
5663 <span class="commit-detail-sha-full" title="Full SHA">
5664 {commit.sha}
5665 </span>
5666 </div>
0cdfd89Claude5667 {statusCombined && (
efb11c5Claude5668 <div class="commit-detail-checks">
5669 <div class="commit-detail-checks-head">
5670 <strong>Checks</strong>
5671 <span class="commit-detail-checks-summary">
5672 {statusCombined.total} total ·{" "}
5673 <span
5674 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5675 >
5676 {statusCombined.state}
5677 </span>
0cdfd89Claude5678 </span>
efb11c5Claude5679 </div>
5680 <div class="commit-detail-check-row">
0cdfd89Claude5681 {statusCombined.contexts.map((cx) => (
5682 <span
efb11c5Claude5683 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5684 title={cx.description || cx.context}
5685 >
5686 {cx.targetUrl ? (
efb11c5Claude5687 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5688 {cx.context}: {cx.state}
5689 </a>
5690 ) : (
5691 <>
5692 {cx.context}: {cx.state}
5693 </>
5694 )}
5695 </span>
5696 ))}
5697 </div>
5698 </div>
5699 )}
fc1817aClaude5700 </div>
ea9ed4cClaude5701 <DiffView
5702 raw={raw}
5703 files={files}
5704 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5705 />
fc1817aClaude5706 </Layout>
5707 );
5708});
5709
79136bbClaude5710// Raw file download
5711web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5712 const { owner, repo } = c.req.param();
5bb52faccanty labs5713 const gate = await assertRepoReadable(c, owner, repo);
5714 if (gate) return gate;
79136bbClaude5715 const refAndPath = c.req.param("ref");
5716
5717 const branches = await listBranches(owner, repo);
5718 let ref = "";
5719 let filePath = "";
5720
5721 for (const branch of branches) {
5722 if (refAndPath.startsWith(branch + "/")) {
5723 ref = branch;
5724 filePath = refAndPath.slice(branch.length + 1);
5725 break;
5726 }
5727 }
5728
5729 if (!ref) {
5730 const slashIdx = refAndPath.indexOf("/");
5731 if (slashIdx === -1) return c.text("Not found", 404);
5732 ref = refAndPath.slice(0, slashIdx);
5733 filePath = refAndPath.slice(slashIdx + 1);
5734 }
5735
5736 const data = await getRawBlob(owner, repo, ref, filePath);
5737 if (!data) return c.text("Not found", 404);
5738
5739 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5740 return new Response(data as BodyInit, {
79136bbClaude5741 headers: {
5742 "Content-Type": "application/octet-stream",
5743 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5744 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5745 },
5746 });
5747});
5748
5749// Blame view
5750web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5751 const { owner, repo } = c.req.param();
5752 const user = c.get("user");
5bb52faccanty labs5753 const gate = await assertRepoReadable(c, owner, repo);
5754 if (gate) return gate;
79136bbClaude5755 const refAndPath = c.req.param("ref");
5756
5757 const branches = await listBranches(owner, repo);
5758 let ref = "";
5759 let filePath = "";
5760
5761 for (const branch of branches) {
5762 if (refAndPath.startsWith(branch + "/")) {
5763 ref = branch;
5764 filePath = refAndPath.slice(branch.length + 1);
5765 break;
5766 }
5767 }
5768
5769 if (!ref) {
5770 const slashIdx = refAndPath.indexOf("/");
5771 if (slashIdx === -1) return c.text("Not found", 404);
5772 ref = refAndPath.slice(0, slashIdx);
5773 filePath = refAndPath.slice(slashIdx + 1);
5774 }
5775
5776 const blameLines = await getBlame(owner, repo, ref, filePath);
5777 if (blameLines.length === 0) {
5778 return c.html(
5779 <Layout title="Not Found" user={user}>
5780 <div class="empty-state">
5781 <h2>File not found</h2>
5782 </div>
5783 </Layout>,
5784 404
5785 );
5786 }
5787
5788 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5789 // Unique contributors (by author) tracked once for the header chip.
5790 const blameAuthors = new Set<string>();
5791 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5792
5793 return c.html(
5794 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5795 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5796 <RepoHeader owner={owner} repo={repo} />
5797 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5798 <header class="blame-head">
5799 <div class="blame-eyebrow">
5800 <span class="blame-eyebrow-dot" aria-hidden="true" />
5801 Blame · Line-by-line history
5802 </div>
5803 <h1 class="blame-title">
5804 <code>{fileName}</code>
5805 </h1>
5806 <p class="blame-sub">
5807 Each line is annotated with the commit that last touched it.
5808 Click any SHA to jump to that commit and see the surrounding
5809 change.
5810 </p>
5811 </header>
efb11c5Claude5812 <div class="blame-toolbar">
5813 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5814 </div>
398a10cClaude5815 <div class="blame-card">
5816 <div class="blame-header">
efb11c5Claude5817 <div class="blame-header-meta">
5818 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5819 <span class="blame-header-name">{fileName}</span>
5820 <span class="blame-header-tag">Blame</span>
5821 <span class="blame-header-stats">
5822 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5823 {blameAuthors.size} contributor
5824 {blameAuthors.size === 1 ? "" : "s"}
5825 </span>
5826 </div>
5827 <div class="blame-header-actions">
5828 <a
5829 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5830 class="blob-pill"
5831 >
5832 Normal view
5833 </a>
5834 <a
5835 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5836 class="blob-pill"
5837 >
5838 Raw
5839 </a>
5840 </div>
79136bbClaude5841 </div>
398a10cClaude5842 <div style="overflow-x:auto">
5843 <table class="blame-table">
79136bbClaude5844 <tbody>
5845 {blameLines.map((line, i) => {
5846 const showInfo =
5847 i === 0 || blameLines[i - 1].sha !== line.sha;
5848 return (
398a10cClaude5849 <tr class={showInfo ? "blame-row-first" : ""}>
5850 <td class="blame-gutter">
79136bbClaude5851 {showInfo && (
398a10cClaude5852 <span class="blame-gutter-inner">
79136bbClaude5853 <a
5854 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5855 class="blame-gutter-sha"
5856 title={`Commit ${line.sha}`}
79136bbClaude5857 >
5858 {line.sha.slice(0, 7)}
398a10cClaude5859 </a>
5860 <span class="blame-gutter-author" title={line.author}>
5861 {line.author}
5862 </span>
5863 </span>
79136bbClaude5864 )}
5865 </td>
398a10cClaude5866 <td class="blame-line-num">{line.lineNum}</td>
5867 <td class="blame-line-content">{line.content}</td>
79136bbClaude5868 </tr>
5869 );
5870 })}
5871 </tbody>
5872 </table>
5873 </div>
5874 </div>
5875 </Layout>
5876 );
5877});
5878
53c9249Claude5879// Search — keyword + optional Claude semantic mode
79136bbClaude5880web.get("/:owner/:repo/search", async (c) => {
5881 const { owner, repo } = c.req.param();
5882 const user = c.get("user");
5bb52faccanty labs5883 const gate = await assertRepoReadable(c, owner, repo);
5884 if (gate) return gate;
79136bbClaude5885 const q = c.req.query("q") || "";
53c9249Claude5886 const aiAvailable = isAiAvailable();
5887 // Default to semantic when Claude is available and no explicit mode set.
5888 const modeParam = c.req.query("mode");
5889 const mode: "semantic" | "keyword" =
5890 modeParam === "keyword"
5891 ? "keyword"
5892 : modeParam === "semantic"
5893 ? "semantic"
5894 : aiAvailable
5895 ? "semantic"
5896 : "keyword";
5897 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5898
5899 if (!(await repoExists(owner, repo))) return c.notFound();
5900
5901 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5902
5903 // Keyword results (always available as fallback / when in keyword mode)
5904 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5905 // Semantic results (Claude-powered)
5906 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5907 let semanticHits: SemanticHit[] = [];
5908 let semanticMode: "semantic" | "keyword" = "semantic";
5909 let quotaExceeded = false;
79136bbClaude5910
5911 if (q.trim()) {
53c9249Claude5912 if (isSemantic) {
5913 // Resolve repo DB id for caching
5914 const [ownerRow] = await db
5915 .select({ id: users.id })
5916 .from(users)
5917 .where(eq(users.username, owner))
5918 .limit(1);
5919 const [repoRow] = ownerRow
5920 ? await db
5921 .select({ id: repositories.id })
5922 .from(repositories)
5923 .where(
5924 and(
5925 eq(repositories.ownerId, ownerRow.id),
5926 eq(repositories.name, repo)
5927 )
5928 )
5929 .limit(1)
5930 : [];
5931
5932 const rateLimitKey = user
5933 ? `user:${user.id}`
5934 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5935
5936 const searchResult = await claudeSemanticSearch(
5937 owner,
5938 repo,
5939 repoRow?.id ?? `${owner}/${repo}`,
5940 q.trim(),
5941 { branch: defaultBranch, rateLimitKey }
5942 );
5943 semanticHits = searchResult.results;
5944 semanticMode = searchResult.mode;
5945 quotaExceeded = searchResult.quotaExceeded;
5946 } else {
5947 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5948 }
79136bbClaude5949 }
5950
53c9249Claude5951 const aiSearchCss = `
5952 /* ─── Semantic search mode toggle ─── */
5953 .search-mode-bar {
5954 display: flex;
5955 align-items: center;
5956 gap: 8px;
5957 margin-bottom: 14px;
5958 flex-wrap: wrap;
5959 }
5960 .search-mode-label {
5961 font-size: 12.5px;
5962 color: var(--text-muted);
5963 font-weight: 500;
5964 }
5965 .search-mode-toggle {
5966 display: inline-flex;
5967 background: var(--bg-elevated);
5968 border: 1px solid var(--border);
5969 border-radius: 9999px;
5970 padding: 3px;
5971 gap: 2px;
5972 }
5973 .search-mode-btn {
5974 display: inline-flex;
5975 align-items: center;
5976 gap: 5px;
5977 padding: 5px 12px;
5978 border-radius: 9999px;
5979 font-size: 12.5px;
5980 font-weight: 500;
5981 color: var(--text-muted);
5982 text-decoration: none;
5983 transition: color 120ms ease, background 120ms ease;
5984 cursor: pointer;
5985 border: none;
5986 background: none;
5987 font: inherit;
5988 }
5989 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5990 .search-mode-btn.active {
6fd5915Claude5991 background: rgba(91,110,232,0.15);
53c9249Claude5992 color: var(--text-strong);
5993 }
5994 .search-mode-ai-badge {
5995 display: inline-flex;
5996 align-items: center;
5997 gap: 4px;
5998 padding: 2px 8px;
5999 border-radius: 9999px;
6000 font-size: 11px;
6001 font-weight: 600;
6fd5915Claude6002 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude6003 color: #c4b5fd;
6fd5915Claude6004 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude6005 margin-left: 2px;
6006 }
6007 .search-quota-warn {
6008 font-size: 12.5px;
6009 color: var(--text-muted);
6010 padding: 6px 12px;
6011 background: rgba(255,200,0,0.06);
6012 border: 1px solid rgba(255,200,0,0.20);
6013 border-radius: 8px;
6014 }
6015
6016 /* ─── Semantic result cards ─── */
6017 .sem-results { display: flex; flex-direction: column; gap: 10px; }
6018 .sem-result {
6019 padding: 14px 16px;
6020 background: var(--bg-elevated);
6021 border: 1px solid var(--border);
6022 border-radius: 12px;
6023 transition: border-color 120ms ease;
6024 }
6025 .sem-result:hover { border-color: var(--border-strong); }
6026 .sem-result-head {
6027 display: flex;
6028 align-items: flex-start;
6029 justify-content: space-between;
6030 gap: 10px;
6031 flex-wrap: wrap;
6032 margin-bottom: 6px;
6033 }
6034 .sem-result-path {
6035 font-family: var(--font-mono);
6036 font-size: 13px;
6037 font-weight: 600;
6038 color: var(--text-strong);
6039 text-decoration: none;
6040 word-break: break-all;
6041 }
6042 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6043 .sem-result-conf {
6044 display: inline-flex;
6045 align-items: center;
6046 gap: 4px;
6047 padding: 2px 9px;
6048 border-radius: 9999px;
6049 font-size: 11.5px;
6050 font-weight: 600;
6051 white-space: nowrap;
6052 flex-shrink: 0;
6053 }
e589f77ccantynz-alt6054 .sem-conf-strong { background: rgba(52,211,153,0.12); color: var(--green); border: 1px solid rgba(52,211,153,0.30); }
6055 .sem-conf-possible { background: rgba(91,110,232,0.12); color: var(--accent); border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6056 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6057 .sem-result-reason {
6058 font-size: 12.5px;
6059 color: var(--text-muted);
6060 margin-bottom: 8px;
6061 line-height: 1.5;
6062 }
6063 .sem-result-snippet {
6064 margin: 0;
6065 padding: 10px 12px;
6066 background: rgba(0,0,0,0.22);
6067 border: 1px solid var(--border);
6068 border-radius: 8px;
6069 font-family: var(--font-mono);
6070 font-size: 12px;
6071 line-height: 1.55;
6072 color: var(--text);
6073 overflow-x: auto;
6074 white-space: pre-wrap;
6075 word-break: break-word;
6076 max-height: 240px;
6077 overflow-y: auto;
6078 }
6079 `;
6080
6081 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6082 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6083
79136bbClaude6084 return c.html(
6085 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6086 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6087 <RepoHeader owner={owner} repo={repo} />
6088 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6089 <div class="search-hero">
6090 <div class="search-eyebrow">
6091 <strong>Search</strong> · {owner}/{repo}
6092 </div>
6093 <h1 class="search-title">
53c9249Claude6094 {isSemantic ? (
6095 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6096 ) : (
6097 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6098 )}
efb11c5Claude6099 </h1>
6100 <form
6101 method="get"
6102 action={`/${owner}/${repo}/search`}
6103 class="search-form"
6104 role="search"
6105 >
53c9249Claude6106 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6107 <div class="search-input-wrap">
6108 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6109 <input
6110 type="text"
6111 name="q"
6112 value={q}
53c9249Claude6113 placeholder={
6114 isSemantic
6115 ? "Ask a question or describe what you're looking for…"
6116 : "Search code on the default branch…"
6117 }
efb11c5Claude6118 aria-label="Search code"
6119 class="search-input"
6120 autocomplete="off"
6121 autofocus
6122 />
6123 </div>
6124 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6125 Search
6126 </button>
efb11c5Claude6127 </form>
6128 </div>
53c9249Claude6129
6130 {/* Mode toggle */}
6131 <div class="search-mode-bar">
6132 <span class="search-mode-label">Mode:</span>
6133 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6134 {aiAvailable ? (
6135 <a
6136 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6137 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6138 aria-pressed={isSemantic ? "true" : "false"}
6139 >
6140 {"✨"} Semantic AI
6141 <span class="search-mode-ai-badge">AI-powered</span>
6142 </a>
6143 ) : null}
6144 <a
6145 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6146 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6147 aria-pressed={!isSemantic ? "true" : "false"}
6148 >
6149 {"⌕"} Keyword
6150 </a>
6151 </div>
6152 {isSemantic && aiAvailable && (
6153 <span style="font-size:12px;color:var(--text-muted)">
6154 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6155 </span>
53c9249Claude6156 )}
6157 {quotaExceeded && (
6158 <span class="search-quota-warn">
6159 Semantic search quota reached — showing keyword results
efb11c5Claude6160 </span>
53c9249Claude6161 )}
6162 </div>
6163
6164 {/* ─── Semantic results ─── */}
6165 {isSemantic && q && (
6166 <>
6167 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6168 <div class="search-quota-warn" style="margin-bottom:10px">
6169 Falling back to keyword search — Claude found no relevant files.
6170 </div>
6171 )}
6172 {semanticHits.length === 0 ? (
6173 <div class="search-empty">
6174 <p>
6175 No matches for <strong>"{q}"</strong>.{" "}
6176 Try a different phrasing or{" "}
6177 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6178 switch to keyword search
6179 </a>.
6180 </p>
6181 </div>
6182 ) : (
6183 <>
6184 <div class="search-results-head">
6185 <span class="search-results-count">
6186 <strong>{semanticHits.length}</strong> result
6187 {semanticHits.length !== 1 ? "s" : ""}
6188 </span>
6189 <span class="search-results-query">
6190 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6191 <span class="search-results-q">"{q}"</span>
6192 </span>
6193 </div>
6194 <div class="sem-results">
6195 {semanticHits.map((h) => {
6196 const confClass =
6197 h.confidence > 0.7
6198 ? "sem-conf-strong"
6199 : h.confidence > 0.4
6200 ? "sem-conf-possible"
6201 : "sem-conf-weak";
6202 const confLabel =
6203 h.confidence > 0.7
6204 ? "Strong match"
6205 : h.confidence > 0.4
6206 ? "Possible match"
6207 : "Weak match";
6208 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6209 const preview =
6210 h.snippet.length > 800
6211 ? h.snippet.slice(0, 800) + "\n…"
6212 : h.snippet;
6213 return (
6214 <div class="sem-result">
6215 <div class="sem-result-head">
6216 <a href={href} class="sem-result-path">
6217 {h.file}
6218 {h.lineNumber ? (
6219 <span style="color:var(--text-muted);font-weight:500">
6220 :{h.lineNumber}
6221 </span>
6222 ) : null}
6223 </a>
6224 <span class={`sem-result-conf ${confClass}`}>
6225 {confLabel}
6226 </span>
6227 </div>
6228 {h.reason && (
6229 <p class="sem-result-reason">{h.reason}</p>
6230 )}
6231 {preview && (
6232 <pre class="sem-result-snippet">{preview}</pre>
6233 )}
6234 </div>
6235 );
6236 })}
6237 </div>
6238 </>
6239 )}
6240 </>
79136bbClaude6241 )}
53c9249Claude6242
6243 {/* ─── Keyword results ─── */}
6244 {!isSemantic && q && (
6245 <>
6246 <div class="search-results-head">
6247 <span class="search-results-count">
6248 <strong>{keywordResults.length}</strong> result
6249 {keywordResults.length !== 1 ? "s" : ""}
6250 </span>
6251 <span class="search-results-query">
6252 for <span class="search-results-q">"{q}"</span> on{" "}
6253 <code>{defaultBranch}</code>
6254 </span>
6255 </div>
6256 {keywordResults.length === 0 ? (
6257 <div class="search-empty">
6258 <p>
6259 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6260 {aiAvailable && (
6261 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6262 try AI semantic search
79136bbClaude6263 </a>
53c9249Claude6264 )}.
6265 </p>
6266 </div>
6267 ) : (
6268 <div class="search-results">
6269 {(() => {
6270 const grouped: Record<
6271 string,
6272 Array<{ lineNum: number; line: string }>
6273 > = {};
6274 for (const r of keywordResults) {
6275 if (!grouped[r.file]) grouped[r.file] = [];
6276 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6277 }
6278 return Object.entries(grouped).map(([file, matches]) => (
6279 <div class="search-file diff-file">
6280 <div class="search-file-head diff-file-header">
6281 <a
6282 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6283 class="search-file-link"
6284 >
6285 {file}
6286 </a>
6287 <span class="search-file-count">
6288 {matches.length} match{matches.length === 1 ? "" : "es"}
6289 </span>
6290 </div>
6291 <div class="blob-code">
6292 <table>
6293 <tbody>
6294 {matches.map((m) => (
6295 <tr>
6296 <td class="line-num">{m.lineNum}</td>
6297 <td class="line-content">{m.line}</td>
6298 </tr>
6299 ))}
6300 </tbody>
6301 </table>
6302 </div>
6303 </div>
6304 ));
6305 })()}
6306 </div>
6307 )}
6308 </>
6309 )}
79136bbClaude6310 </Layout>
6311 );
6312});
6313
fc1817aClaude6314export default web;