Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

web.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

web.tsxBlame6313 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,
8c790e0Claude36 type RecentPush,
fc1817aClaude37} from "../views/components";
ea9ed4cClaude38import { DiffView } from "../views/diff-view";
fc1817aClaude39import {
40 getTree,
41 getBlob,
42 listCommits,
43 getCommit,
44 getCommitFullMessage,
45 getDiff,
46 getReadme,
47 getDefaultBranch,
48 listBranches,
398a10cClaude49 listTags,
fc1817aClaude50 repoExists,
06d5ffeClaude51 initBareRepo,
79136bbClaude52 getBlame,
53 getRawBlob,
54 searchCode,
398a10cClaude55 getRepoPath,
fc1817aClaude56} from "../git/repository";
79136bbClaude57import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude58import { highlightCode } from "../lib/highlight";
91a0204Claude59import { computeHealthScore } from "../lib/health-score";
60import type { HealthScore } from "../lib/health-score";
06d5ffeClaude61import { softAuth, requireAuth } from "../middleware/auth";
62import type { AuthEnv } from "../middleware/auth";
5bb52faccanty labs63import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
53c9249Claude64import { claudeSemanticSearch } from "../lib/claude-semantic-search";
65import { isAiAvailable } from "../lib/ai-client";
8f50ed0Claude66import { trackByName } from "../lib/traffic";
8ed88f2ccantynz-alt67import { LandingPage } from "../views/landing";
29924bcClaude68import { Landing2030Page } from "../views/landing-2030";
8ed88f2ccantynz-alt69import { LandingProPage, type LandingProLiveFeed } from "../views/landing-pro";
52ad8b1Claude70import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude71import {
72 listQueuedAiBuildIssues,
73 listRecentAutoMerges,
74 listRecentAiReviews,
75 countAiReviewsSince,
76 listDemoActivityFeed,
77} from "../lib/demo-activity";
11c3ab6ccanty labs78import { AgentWorkspace } from "../views/agent-workspace";
79import { DailyBrief } from "../views/daily-brief";
80import { TrustReport } from "../views/trust-report";
81import { ProductionLayers } from "../views/production-layers";
82import { DistributionView } from "../views/distribution";
83import { OrgMemory } from "../views/org-memory";
fc1817aClaude84
06d5ffeClaude85const web = new Hono<AuthEnv>();
86
87// Soft auth on all web routes — c.get("user") available but may be null
88web.use("*", softAuth);
fc1817aClaude89
5bb52faccanty labs90// ---------------------------------------------------------------------------
91// SECURITY: repo-content access gate.
92//
93// Every route that serves repository CONTENT (file tree, blobs, raw files,
94// blame, commits, diffs, branches, tags, code search, repo overview) MUST call
95// this before touching git-on-disk. Public repos resolve to "read" for
96// anonymous viewers (so public browsing stays fully anonymous); private repos
97// require a logged-in viewer with at least read access.
98//
99// Returns a 404 Response when the repo is missing OR the viewer lacks read
100// access — deliberately 404 (not 403) so we never leak the existence of a
101// private repo. Returns `null` when access is granted; callers proceed as
102// normal. The owner-username → user → repositories lookup mirrors the pattern
103// used by `requireRepoAccess` in ../middleware/repo-access.
104// ---------------------------------------------------------------------------
105async function assertRepoReadable(
106 c: any,
107 owner: string,
108 repo: string
109): Promise<Response | null> {
110 const user = c.get("user") ?? null;
111
112 const notFound = () =>
113 c.html(
114 <Layout title="Not Found" user={user}>
115 <div class="empty-state">
116 <h2>Repository not found</h2>
117 <p>
118 {owner}/{repo} does not exist.
119 </p>
120 </div>
121 </Layout>,
122 404
123 );
124
6b75ac1ccanty labs125 // Explicit dev/test bypass: when no database is configured at all there are
126 // no persisted repositories to protect (private repos only exist as DB rows),
127 // and the browse routes render purely from git-on-disk. This is a positively
128 // established condition (DATABASE_URL unset), NOT an error inference — so the
129 // access gate below can safely fail CLOSED on any runtime error. In
130 // production DATABASE_URL is always set, so this branch never runs there.
131 if (!config.databaseUrl) return null;
132
5bb52faccanty labs133 try {
134 const [ownerRow] = await db
135 .select({ id: users.id })
136 .from(users)
137 .where(eq(users.username, owner))
138 .limit(1);
139 if (!ownerRow) return notFound();
140
141 const [repoRow] = await db
142 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
143 .from(repositories)
144 .where(
145 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
146 )
147 .limit(1);
148 if (!repoRow) return notFound();
149
150 const access = await resolveRepoAccess({
151 repoId: repoRow.id,
152 userId: user?.id ?? null,
153 isPublic: !repoRow.isPrivate,
154 });
155 // Positive denial: the repo exists and the viewer lacks read access.
156 // Return 404 (not 403) so we never confirm a private repo's existence.
157 if (!satisfiesAccess(access, "read")) return notFound();
158
159 return null;
160 } catch (err) {
6b75ac1ccanty labs161 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
162 // lookup errored, so we cannot positively establish that this repo is
163 // public. Denying is the only safe choice for an access-control gate —
164 // failing open here would re-expose private repos during a DB hiccup,
165 // which is the exact vulnerability this function exists to close.
5bb52faccanty labs166 console.warn(
6b75ac1ccanty labs167 `[web] assertRepoReadable: access check failed for ${owner}/${repo} — denying:`,
5bb52faccanty labs168 err instanceof Error ? err.message : err
169 );
6b75ac1ccanty labs170 return notFound();
5bb52faccanty labs171 }
172}
173
ebaae0fClaude174// ---------------------------------------------------------------------------
175// Repo AI stats — computed once per repo home page load, best-effort.
176// Fast because every WHERE clause is bounded to a 7-day window.
177// ---------------------------------------------------------------------------
178interface RepoAiStats {
179 mergedCount: number;
180 reviewCount: number;
181 securityAlertCount: number;
182 hoursSaved: string;
183}
184
185async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
186 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
187
188 // 1. AI-merged PRs this week: activity_feed entries with action =
189 // 'auto_merge.merged' in the last 7 days for this repo.
190 // This is cheaper than a JOIN on pull_requests and covers both the
191 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
192 const [mergedRow] = await db
193 .select({ n: count() })
194 .from(activityFeed)
195 .where(
196 and(
197 eq(activityFeed.repositoryId, repoId),
198 eq(activityFeed.action, "auto_merge.merged"),
199 gte(activityFeed.createdAt, since)
200 )
201 );
202 const mergedCount = Number(mergedRow?.n ?? 0);
203
204 // 2. AI reviews this week: pr_comments where is_ai_review = true in
205 // the last 7 days, joined through pull_requests to scope to this repo.
206 const [reviewRow] = await db
207 .select({ n: count() })
208 .from(prComments)
209 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
210 .where(
211 and(
212 eq(pullRequests.repositoryId, repoId),
213 eq(prComments.isAiReview, true),
214 gte(prComments.createdAt, since)
215 )
216 );
217 const reviewCount = Number(reviewRow?.n ?? 0);
218
219 // 3. Open security alerts: open issues that carry a label whose name
220 // contains 'security' (case-insensitive) for this repo.
221 const [secRow] = await db
222 .select({ n: count() })
223 .from(issues)
224 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
225 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
226 .where(
227 and(
228 eq(issues.repositoryId, repoId),
229 eq(issues.state, "open"),
230 sql`lower(${labels.name}) like '%security%'`
231 )
232 );
233 const securityAlertCount = Number(secRow?.n ?? 0);
234
235 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
236 const hours = mergedCount * 1.5 + reviewCount * 0.5;
237 const hoursSaved = hours.toFixed(1);
238
239 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
240}
241
8c790e0Claude242/**
243 * Query the most recent push to a repo from the activity_feed table.
244 * Returns a RecentPush if there's been a push in the last 24 hours,
245 * or null otherwise. Used by the RepoHeader live/watch indicator.
246 *
247 * Queries activity_feed where action='push' and targetId holds the commit SHA.
248 */
249async function getRecentPush(repoId: string): Promise<RecentPush | null> {
250 try {
251 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
252 const [row] = await db
253 .select({
254 targetId: activityFeed.targetId,
255 createdAt: activityFeed.createdAt,
256 })
257 .from(activityFeed)
258 .where(
259 and(
260 eq(activityFeed.repositoryId, repoId),
261 eq(activityFeed.action, "push"),
262 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
263 )
264 )
265 .orderBy(desc(activityFeed.createdAt))
266 .limit(1);
267 if (!row || !row.targetId) return null;
268 return {
269 sha: row.targetId,
270 ageMs: Date.now() - new Date(row.createdAt).getTime(),
271 };
272 } catch {
273 // DB unavailable or schema mismatch — degrade gracefully.
274 return null;
275 }
276}
277
efb11c5Claude278/**
279 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
280 *
281 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
282 * scope is route-local and `layout.tsx` is locked. Each polished handler
283 * injects this via a `<style>` tag; the rules are namespaced by surface
284 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
285 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
286 * `.repo-home-*` styling Agent A already shipped.
287 */
288const codeBrowseCss = `
289 /* ───────── shared primitives ───────── */
290 .cb-hairline::before,
291 .new-repo-hero::before,
292 .profile-hero::before,
293 .commits-hero::before,
294 .commit-detail-card::before,
295 .search-hero::before {
296 content: '';
297 position: absolute;
298 top: 0; left: 0; right: 0;
299 height: 2px;
6fd5915Claude300 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude301 opacity: 0.7;
302 pointer-events: none;
303 }
304 @keyframes cbHeroOrb {
305 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
306 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
307 }
308 @media (prefers-reduced-motion: reduce) {
309 .new-repo-hero-orb,
310 .profile-hero-orb,
311 .commits-hero-orb { animation: none; }
312 }
313
314 /* ───────── new-repo ───────── */
315 .new-repo-hero {
316 position: relative;
317 margin-bottom: var(--space-5);
318 padding: var(--space-5) var(--space-6);
319 background: var(--bg-elevated);
320 border: 1px solid var(--border);
321 border-radius: 16px;
322 overflow: hidden;
323 }
324 .new-repo-hero-orb-wrap {
325 position: absolute;
326 inset: -25% -10% auto auto;
327 width: 360px;
328 height: 360px;
329 pointer-events: none;
330 z-index: 0;
331 }
332 .new-repo-hero-orb {
333 position: absolute;
334 inset: 0;
6fd5915Claude335 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude336 filter: blur(80px);
337 opacity: 0.7;
338 animation: cbHeroOrb 14s ease-in-out infinite;
339 }
340 .new-repo-hero-inner { position: relative; z-index: 1; }
341 .new-repo-eyebrow {
342 font-size: 12px;
343 font-family: var(--font-mono);
344 color: var(--text-muted);
345 letter-spacing: 0.1em;
346 text-transform: uppercase;
347 margin-bottom: var(--space-2);
348 }
349 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
350 .new-repo-title {
351 font-family: var(--font-display);
352 font-weight: 800;
353 letter-spacing: -0.028em;
354 font-size: clamp(28px, 4vw, 40px);
355 line-height: 1.05;
356 margin: 0 0 var(--space-2);
357 color: var(--text-strong);
358 }
359 .new-repo-sub {
360 font-size: 15px;
361 color: var(--text-muted);
362 margin: 0;
363 line-height: 1.55;
364 max-width: 620px;
365 }
366 .new-repo-form {
367 max-width: 680px;
368 }
369 .new-repo-error {
370 background: rgba(218, 54, 51, 0.12);
371 border: 1px solid rgba(218, 54, 51, 0.35);
372 color: #ffb3b3;
373 padding: 10px 14px;
374 border-radius: 10px;
375 margin-bottom: var(--space-4);
376 font-size: 14px;
377 }
378 .new-repo-form-grid {
379 display: flex;
380 flex-direction: column;
381 gap: var(--space-4);
382 }
383 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
384 .new-repo-label {
385 font-size: 13px;
386 color: var(--text-strong);
387 font-weight: 600;
388 }
389 .new-repo-label-optional {
390 color: var(--text-muted);
391 font-weight: 400;
392 font-size: 12px;
393 }
394 .new-repo-input {
395 appearance: none;
396 width: 100%;
397 background: var(--bg);
398 border: 1px solid var(--border);
399 color: var(--text-strong);
400 border-radius: 10px;
401 padding: 10px 12px;
402 font-size: 14px;
403 font-family: inherit;
404 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
405 }
406 .new-repo-input:focus {
407 outline: none;
408 border-color: var(--accent);
6fd5915Claude409 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude410 }
411 .new-repo-input-disabled {
412 color: var(--text-muted);
413 background: var(--bg-secondary);
414 cursor: not-allowed;
415 }
416 .new-repo-hint {
417 font-size: 12px;
418 color: var(--text-muted);
419 margin: 4px 0 0;
420 }
421 .new-repo-hint code {
422 font-family: var(--font-mono);
423 font-size: 11.5px;
424 background: var(--bg-secondary);
425 border: 1px solid var(--border);
426 border-radius: 5px;
427 padding: 1px 5px;
428 color: var(--text-strong);
429 }
430 .new-repo-visibility {
431 display: grid;
432 grid-template-columns: 1fr 1fr;
433 gap: var(--space-2);
434 }
435 @media (max-width: 600px) {
436 .new-repo-visibility { grid-template-columns: 1fr; }
437 }
438 .new-repo-vis-card {
439 display: flex;
440 gap: 10px;
441 padding: 14px;
442 background: var(--bg);
443 border: 1px solid var(--border);
444 border-radius: 12px;
445 cursor: pointer;
446 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
447 }
448 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
449 .new-repo-vis-card:has(input:checked) {
6fd5915Claude450 border-color: rgba(91,110,232,0.55);
451 background: rgba(91,110,232,0.06);
452 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude453 }
454 .new-repo-vis-radio {
455 margin-top: 3px;
456 accent-color: var(--accent);
457 }
458 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
459 .new-repo-vis-label {
460 font-size: 14px;
461 font-weight: 600;
462 color: var(--text-strong);
463 }
464 .new-repo-vis-desc {
465 font-size: 12.5px;
466 color: var(--text-muted);
467 line-height: 1.45;
468 }
469 .new-repo-callout {
470 margin-top: var(--space-2);
471 padding: var(--space-3) var(--space-4);
6fd5915Claude472 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
473 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude474 border-radius: 12px;
475 }
476 .new-repo-callout-eyebrow {
477 font-size: 11px;
478 font-family: var(--font-mono);
479 text-transform: uppercase;
480 letter-spacing: 0.12em;
481 color: var(--accent);
482 font-weight: 700;
483 margin-bottom: 4px;
484 }
485 .new-repo-callout-body {
486 font-size: 13px;
487 color: var(--text);
488 line-height: 1.5;
489 margin: 0;
490 }
491 .new-repo-callout-body code {
492 font-family: var(--font-mono);
493 font-size: 12px;
494 color: var(--accent);
6fd5915Claude495 background: rgba(91,110,232,0.1);
efb11c5Claude496 border-radius: 4px;
497 padding: 1px 5px;
498 }
398a10cClaude499 .new-repo-templates {
500 display: flex;
501 flex-wrap: wrap;
502 gap: 8px;
503 margin-top: 4px;
504 }
505 .new-repo-template-chip {
506 position: relative;
507 display: inline-flex;
508 align-items: center;
509 gap: 6px;
510 padding: 8px 14px;
511 background: var(--bg);
512 border: 1px solid var(--border);
513 border-radius: 999px;
514 font-size: 13px;
515 color: var(--text);
516 cursor: pointer;
517 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
518 }
519 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
520 .new-repo-template-chip:hover {
521 border-color: var(--border-strong, var(--border));
522 color: var(--text-strong);
523 }
524 .new-repo-template-chip:has(input:checked) {
6fd5915Claude525 border-color: rgba(91,110,232,0.55);
526 background: rgba(91,110,232,0.10);
398a10cClaude527 color: var(--text-strong);
6fd5915Claude528 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude529 }
530 .new-repo-template-chip-dot {
531 width: 8px; height: 8px;
532 border-radius: 999px;
533 background: var(--text-faint, var(--text-muted));
534 transition: background 140ms ease, box-shadow 140ms ease;
535 }
536 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude537 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
538 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude539 }
efb11c5Claude540 .new-repo-actions {
541 display: flex;
542 gap: var(--space-2);
543 margin-top: var(--space-2);
398a10cClaude544 align-items: center;
545 }
546 .new-repo-submit {
547 min-width: 180px;
6fd5915Claude548 border: 1px solid rgba(91,110,232,0.45);
549 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude550 color: #fff;
551 font-weight: 700;
6fd5915Claude552 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude553 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
554 }
555 .new-repo-submit:hover {
556 transform: translateY(-1px);
6fd5915Claude557 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude558 filter: brightness(1.06);
559 }
560 .new-repo-submit:focus-visible {
6fd5915Claude561 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude562 outline-offset: 2px;
efb11c5Claude563 }
564
565 /* ───────── profile ───────── */
566 .profile-hero {
567 position: relative;
568 margin-bottom: var(--space-5);
569 padding: var(--space-5) var(--space-6);
570 background: var(--bg-elevated);
571 border: 1px solid var(--border);
572 border-radius: 16px;
573 overflow: hidden;
574 }
575 .profile-hero-orb-wrap {
576 position: absolute;
577 inset: -25% -10% auto auto;
578 width: 360px;
579 height: 360px;
580 pointer-events: none;
581 z-index: 0;
582 }
583 .profile-hero-orb {
584 position: absolute;
585 inset: 0;
6fd5915Claude586 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude587 filter: blur(80px);
588 opacity: 0.7;
589 animation: cbHeroOrb 14s ease-in-out infinite;
590 }
591 .profile-hero-inner {
592 position: relative;
593 z-index: 1;
594 display: flex;
595 align-items: flex-start;
596 gap: var(--space-5);
597 }
598 .profile-hero-avatar {
599 flex: 0 0 auto;
600 width: 88px;
601 height: 88px;
602 border-radius: 50%;
6fd5915Claude603 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude604 color: #fff;
605 display: flex;
606 align-items: center;
607 justify-content: center;
608 font-size: 38px;
609 font-weight: 700;
610 font-family: var(--font-display);
6fd5915Claude611 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude612 }
613 .profile-hero-text { flex: 1; min-width: 0; }
614 .profile-eyebrow {
615 font-size: 12px;
616 font-family: var(--font-mono);
617 color: var(--text-muted);
618 letter-spacing: 0.1em;
619 text-transform: uppercase;
620 margin-bottom: var(--space-2);
621 }
622 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
623 .profile-name {
624 font-family: var(--font-display);
625 font-weight: 800;
626 letter-spacing: -0.028em;
627 font-size: clamp(28px, 3.6vw, 36px);
628 line-height: 1.05;
629 margin: 0 0 4px;
630 color: var(--text-strong);
631 }
632 .profile-handle {
633 font-family: var(--font-mono);
634 font-size: 13px;
635 color: var(--text-muted);
636 margin-bottom: var(--space-2);
637 }
638 .profile-bio {
639 font-size: 14.5px;
640 color: var(--text);
641 line-height: 1.55;
642 margin: 0 0 var(--space-3);
643 max-width: 640px;
644 }
645 .profile-meta {
646 display: flex;
647 align-items: center;
648 gap: var(--space-4);
649 flex-wrap: wrap;
650 font-size: 13px;
651 }
652 .profile-meta-link {
653 color: var(--text-muted);
654 transition: color var(--t-fast, 0.15s) ease;
655 }
656 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
657 .profile-meta-link strong {
658 color: var(--text-strong);
659 font-weight: 600;
660 font-variant-numeric: tabular-nums;
661 }
662 .profile-follow-form { margin: 0; }
663 @media (max-width: 600px) {
664 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
665 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
666 }
667 .profile-readme {
668 margin-bottom: var(--space-6);
669 background: var(--bg-elevated);
670 border: 1px solid var(--border);
671 border-radius: 12px;
672 overflow: hidden;
673 }
674 .profile-readme-head {
675 display: flex;
676 align-items: center;
677 gap: 8px;
678 padding: 10px 16px;
679 background: var(--bg-secondary);
680 border-bottom: 1px solid var(--border);
681 font-size: 13px;
682 color: var(--text-muted);
683 }
684 .profile-readme-icon { color: var(--accent); font-size: 14px; }
685 .profile-readme-body { padding: var(--space-5) var(--space-6); }
686 .profile-section-head {
687 display: flex;
688 align-items: baseline;
689 gap: var(--space-2);
690 margin-bottom: var(--space-3);
691 }
692 .profile-section-title {
693 font-family: var(--font-display);
694 font-weight: 700;
695 font-size: 20px;
696 letter-spacing: -0.015em;
697 margin: 0;
698 color: var(--text-strong);
699 }
700 .profile-section-count {
701 font-family: var(--font-mono);
702 font-size: 12px;
703 color: var(--text-muted);
704 background: var(--bg-secondary);
705 border: 1px solid var(--border);
706 border-radius: 999px;
707 padding: 2px 10px;
708 font-variant-numeric: tabular-nums;
709 }
710 .profile-empty {
711 display: flex;
712 align-items: center;
713 justify-content: space-between;
714 gap: var(--space-3);
715 padding: var(--space-4) var(--space-5);
716 background: var(--bg-elevated);
717 border: 1px dashed var(--border);
718 border-radius: 12px;
719 }
720 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
721
722 /* ───────── tree (file browser) ───────── */
723 .tree-header {
724 margin-bottom: var(--space-3);
725 display: flex;
726 flex-direction: column;
727 gap: var(--space-2);
728 }
729 .tree-header-row {
730 display: flex;
731 align-items: center;
732 justify-content: space-between;
733 gap: var(--space-3);
734 flex-wrap: wrap;
735 }
736 .tree-header-stats {
737 display: flex;
738 align-items: center;
739 gap: var(--space-3);
740 font-size: 12px;
741 color: var(--text-muted);
742 }
743 .tree-stat strong {
744 color: var(--text-strong);
745 font-weight: 600;
746 font-variant-numeric: tabular-nums;
747 }
748 .tree-stat-link {
749 color: var(--text-muted);
750 padding: 4px 10px;
751 border-radius: 999px;
752 border: 1px solid var(--border);
753 background: var(--bg-elevated);
754 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
755 }
756 .tree-stat-link:hover {
757 color: var(--accent);
6fd5915Claude758 border-color: rgba(91,110,232,0.45);
efb11c5Claude759 text-decoration: none;
760 }
761 .tree-breadcrumb-row {
762 font-size: 13px;
763 }
764
765 /* ───────── blob (file viewer) ───────── */
766 .blob-toolbar {
767 margin-bottom: var(--space-3);
768 display: flex;
769 flex-direction: column;
770 gap: var(--space-2);
771 }
772 .blob-breadcrumb { font-size: 13px; }
773 .blob-card {
774 background: var(--bg-elevated);
775 border: 1px solid var(--border);
776 border-radius: 12px;
777 overflow: hidden;
778 }
779 .blob-header-polished {
780 display: flex;
781 align-items: center;
782 justify-content: space-between;
783 gap: var(--space-3);
784 padding: 10px 14px;
785 background: var(--bg-secondary);
786 border-bottom: 1px solid var(--border);
787 }
788 .blob-header-meta {
789 display: flex;
790 align-items: center;
791 gap: var(--space-2);
792 min-width: 0;
793 flex: 1;
794 }
795 .blob-header-icon { font-size: 14px; opacity: 0.85; }
796 .blob-header-name {
797 font-family: var(--font-mono);
798 font-size: 13px;
799 color: var(--text-strong);
800 font-weight: 600;
801 overflow: hidden;
802 text-overflow: ellipsis;
803 white-space: nowrap;
804 }
805 .blob-header-size {
806 font-family: var(--font-mono);
807 font-size: 11.5px;
808 color: var(--text-muted);
809 font-variant-numeric: tabular-nums;
810 border-left: 1px solid var(--border);
811 padding-left: var(--space-2);
812 margin-left: 2px;
813 }
814 .blob-header-actions {
815 display: flex;
816 gap: 6px;
817 flex-shrink: 0;
818 }
819 .blob-pill {
820 display: inline-flex;
821 align-items: center;
822 padding: 4px 12px;
823 font-size: 12px;
824 font-weight: 500;
825 color: var(--text);
826 background: var(--bg-elevated);
827 border: 1px solid var(--border);
828 border-radius: 999px;
829 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
830 }
831 .blob-pill:hover {
832 color: var(--accent);
6fd5915Claude833 border-color: rgba(91,110,232,0.45);
efb11c5Claude834 text-decoration: none;
6fd5915Claude835 background: rgba(91,110,232,0.06);
efb11c5Claude836 }
837 .blob-pill-accent {
838 color: var(--accent);
6fd5915Claude839 border-color: rgba(91,110,232,0.35);
840 background: rgba(91,110,232,0.08);
efb11c5Claude841 }
842 .blob-pill-accent:hover {
6fd5915Claude843 background: rgba(91,110,232,0.14);
efb11c5Claude844 }
845 .blob-binary {
846 padding: var(--space-5);
847 color: var(--text-muted);
848 text-align: center;
849 font-size: 13px;
850 background: var(--bg);
851 }
852
853 /* ───────── commits list ───────── */
854 .commits-hero {
855 position: relative;
856 margin-bottom: var(--space-4);
857 padding: var(--space-5) var(--space-6);
858 background: var(--bg-elevated);
859 border: 1px solid var(--border);
860 border-radius: 16px;
861 overflow: hidden;
862 }
863 .commits-hero-orb-wrap {
864 position: absolute;
865 inset: -25% -10% auto auto;
866 width: 320px;
867 height: 320px;
868 pointer-events: none;
869 z-index: 0;
870 }
871 .commits-hero-orb {
872 position: absolute;
873 inset: 0;
6fd5915Claude874 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude875 filter: blur(80px);
876 opacity: 0.7;
877 animation: cbHeroOrb 14s ease-in-out infinite;
878 }
879 .commits-hero-inner { position: relative; z-index: 1; }
880 .commits-eyebrow {
881 font-size: 12px;
882 font-family: var(--font-mono);
883 color: var(--text-muted);
884 letter-spacing: 0.1em;
885 text-transform: uppercase;
886 margin-bottom: var(--space-2);
887 }
888 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
889 .commits-title {
890 font-family: var(--font-display);
891 font-weight: 800;
892 letter-spacing: -0.025em;
893 font-size: clamp(22px, 3vw, 30px);
894 line-height: 1.15;
895 margin: 0 0 var(--space-2);
896 color: var(--text-strong);
897 }
898 .commits-branch {
899 font-family: var(--font-mono);
900 font-size: 0.7em;
901 color: var(--text);
902 background: var(--bg-secondary);
903 border: 1px solid var(--border);
904 border-radius: 6px;
905 padding: 2px 8px;
906 font-weight: 500;
907 vertical-align: middle;
908 }
909 .commits-sub {
910 font-size: 14px;
911 color: var(--text-muted);
912 margin: 0;
913 line-height: 1.55;
914 max-width: 640px;
915 }
398a10cClaude916 .commits-toolbar {
917 display: flex;
918 justify-content: space-between;
919 align-items: center;
920 flex-wrap: wrap;
921 gap: var(--space-2);
922 margin-bottom: var(--space-3);
923 }
924 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
925 .commits-toolbar-link {
926 display: inline-flex;
927 align-items: center;
928 gap: 6px;
929 padding: 6px 12px;
930 font-size: 12.5px;
931 font-weight: 500;
932 color: var(--text-muted);
933 background: rgba(255,255,255,0.025);
934 border: 1px solid var(--border);
935 border-radius: 8px;
936 text-decoration: none;
937 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
938 }
939 .commits-toolbar-link:hover {
940 border-color: var(--border-strong);
941 color: var(--text-strong);
942 background: rgba(255,255,255,0.04);
943 text-decoration: none;
944 }
efb11c5Claude945 .commits-list-wrap {
946 background: var(--bg-elevated);
947 border: 1px solid var(--border);
398a10cClaude948 border-radius: 14px;
efb11c5Claude949 overflow: hidden;
398a10cClaude950 position: relative;
951 }
952 .commits-list-wrap::before {
953 content: '';
954 position: absolute;
955 top: 0; left: 0; right: 0;
956 height: 2px;
6fd5915Claude957 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude958 opacity: 0.55;
959 pointer-events: none;
960 }
961 .commits-day-head {
962 display: flex;
963 align-items: center;
964 gap: 10px;
965 padding: 10px 18px;
966 font-size: 11.5px;
967 font-family: var(--font-mono);
968 text-transform: uppercase;
969 letter-spacing: 0.08em;
970 color: var(--text-muted);
971 background: var(--bg-secondary);
972 border-bottom: 1px solid var(--border);
973 }
974 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
975 .commits-day-head-dot {
976 width: 6px; height: 6px;
977 border-radius: 9999px;
6fd5915Claude978 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude979 }
980 .commits-row {
981 display: flex;
982 align-items: flex-start;
983 gap: 14px;
984 padding: 14px 18px;
985 border-bottom: 1px solid var(--border-subtle);
986 transition: background 120ms ease;
987 }
988 .commits-row:last-child { border-bottom: none; }
989 .commits-row:hover { background: rgba(255,255,255,0.022); }
990 .commits-avatar {
991 width: 34px; height: 34px;
992 border-radius: 9999px;
6fd5915Claude993 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude994 color: #fff;
995 display: inline-flex;
996 align-items: center;
997 justify-content: center;
998 font-family: var(--font-display);
999 font-weight: 700;
1000 font-size: 13.5px;
1001 flex-shrink: 0;
1002 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
1003 }
1004 .commits-row-body { flex: 1; min-width: 0; }
1005 .commits-row-msg {
1006 display: flex;
1007 align-items: center;
1008 gap: 8px;
1009 flex-wrap: wrap;
1010 }
1011 .commits-row-msg a {
1012 font-family: var(--font-display);
1013 font-weight: 600;
1014 font-size: 14px;
1015 color: var(--text-strong);
1016 text-decoration: none;
1017 letter-spacing: -0.005em;
1018 }
1019 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1020 .commits-row-verified {
1021 font-size: 9.5px;
1022 padding: 1px 7px;
1023 border-radius: 9999px;
1024 background: rgba(52,211,153,0.16);
1025 color: #6ee7b7;
1026 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1027 text-transform: uppercase;
1028 letter-spacing: 0.06em;
1029 font-weight: 700;
1030 }
1031 .commits-row-meta {
1032 margin-top: 4px;
1033 display: flex;
1034 align-items: center;
1035 gap: 8px;
1036 flex-wrap: wrap;
1037 font-size: 12.5px;
1038 color: var(--text-muted);
1039 }
1040 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1041 .commits-row-meta .sep { opacity: 0.4; }
1042 .commits-row-time {
1043 font-variant-numeric: tabular-nums;
1044 }
1045 .commits-row-side {
1046 display: flex;
1047 align-items: center;
1048 gap: 8px;
1049 flex-shrink: 0;
1050 }
1051 .commits-row-sha {
1052 display: inline-flex;
1053 align-items: center;
1054 padding: 4px 10px;
1055 border-radius: 9999px;
1056 font-family: var(--font-mono);
1057 font-size: 11.5px;
1058 font-weight: 600;
1059 color: var(--text-strong);
1060 background: rgba(255,255,255,0.04);
1061 border: 1px solid var(--border);
1062 text-decoration: none;
1063 letter-spacing: 0.04em;
1064 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1065 }
1066 .commits-row-sha:hover {
6fd5915Claude1067 border-color: rgba(91,110,232,0.55);
398a10cClaude1068 color: var(--accent);
6fd5915Claude1069 background: rgba(91,110,232,0.08);
398a10cClaude1070 text-decoration: none;
1071 }
1072 .commits-row-copy {
1073 display: inline-flex;
1074 align-items: center;
1075 justify-content: center;
1076 width: 28px; height: 28px;
1077 padding: 0;
1078 border-radius: 8px;
1079 background: transparent;
1080 border: 1px solid var(--border);
1081 color: var(--text-muted);
1082 cursor: pointer;
1083 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1084 }
398a10cClaude1085 .commits-row-copy:hover {
1086 border-color: var(--border-strong);
1087 color: var(--text);
1088 background: rgba(255,255,255,0.04);
1089 }
1090 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude1091 /* Push Watch link — eye icon next to the SHA chip */
1092 .commits-row-watch {
1093 display: inline-flex;
1094 align-items: center;
1095 justify-content: center;
1096 width: 28px;
1097 height: 28px;
1098 border-radius: 6px;
1099 border: 1px solid var(--border);
1100 color: var(--text-muted);
1101 background: transparent;
1102 cursor: pointer;
1103 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1104 text-decoration: none !important;
1105 }
1106 .commits-row-watch:hover {
6fd5915Claude1107 border-color: rgba(91,110,232,0.45);
8c790e0Claude1108 color: var(--accent);
6fd5915Claude1109 background: rgba(91,110,232,0.08);
8c790e0Claude1110 text-decoration: none !important;
1111 }
efb11c5Claude1112 .commits-empty {
398a10cClaude1113 position: relative;
1114 overflow: hidden;
1115 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1116 text-align: center;
398a10cClaude1117 background: var(--bg-elevated);
1118 border: 1px dashed var(--border-strong);
1119 border-radius: 16px;
1120 }
1121 .commits-empty-orb {
1122 position: absolute;
1123 inset: -40% 30% auto 30%;
1124 height: 280px;
6fd5915Claude1125 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1126 filter: blur(70px);
1127 opacity: 0.7;
1128 pointer-events: none;
1129 z-index: 0;
1130 }
1131 .commits-empty-inner { position: relative; z-index: 1; }
1132 .commits-empty-icon {
1133 width: 56px; height: 56px;
1134 border-radius: 9999px;
6fd5915Claude1135 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1136 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1137 display: inline-flex;
1138 align-items: center;
1139 justify-content: center;
1140 color: #c4b5fd;
1141 margin: 0 auto 14px;
1142 }
1143 .commits-empty-title {
1144 font-family: var(--font-display);
1145 font-size: 18px;
1146 font-weight: 700;
1147 margin: 0 0 6px;
1148 color: var(--text-strong);
1149 }
1150 .commits-empty-sub {
1151 margin: 0 auto 0;
1152 font-size: 13.5px;
efb11c5Claude1153 color: var(--text-muted);
398a10cClaude1154 max-width: 420px;
1155 line-height: 1.5;
1156 }
1157
1158 /* ───────── branches list ───────── */
1159 .branches-list {
efb11c5Claude1160 background: var(--bg-elevated);
398a10cClaude1161 border: 1px solid var(--border);
1162 border-radius: 14px;
1163 overflow: hidden;
1164 position: relative;
1165 }
1166 .branches-list::before {
1167 content: '';
1168 position: absolute;
1169 top: 0; left: 0; right: 0;
1170 height: 2px;
6fd5915Claude1171 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1172 opacity: 0.55;
1173 pointer-events: none;
1174 }
1175 .branches-row {
1176 display: flex;
1177 align-items: center;
1178 gap: var(--space-3);
1179 padding: 14px 18px;
1180 border-bottom: 1px solid var(--border-subtle);
1181 transition: background 120ms ease;
1182 flex-wrap: wrap;
1183 }
1184 .branches-row:last-child { border-bottom: none; }
1185 .branches-row:hover { background: rgba(255,255,255,0.022); }
1186 .branches-row-icon {
1187 width: 32px; height: 32px;
1188 border-radius: 8px;
6fd5915Claude1189 background: rgba(91,110,232,0.10);
398a10cClaude1190 color: #c4b5fd;
1191 display: inline-flex;
1192 align-items: center;
1193 justify-content: center;
1194 flex-shrink: 0;
6fd5915Claude1195 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1196 }
1197 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1198 .branches-row-name {
1199 display: inline-flex;
1200 align-items: center;
1201 gap: 8px;
1202 flex-wrap: wrap;
1203 }
1204 .branches-row-name a {
1205 font-family: var(--font-mono);
1206 font-size: 13.5px;
1207 color: var(--text-strong);
1208 font-weight: 600;
1209 text-decoration: none;
1210 }
1211 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1212 .branches-row-default {
1213 font-size: 10px;
1214 padding: 2px 8px;
1215 border-radius: 9999px;
6fd5915Claude1216 background: rgba(91,110,232,0.14);
398a10cClaude1217 color: #c4b5fd;
6fd5915Claude1218 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1219 text-transform: uppercase;
1220 letter-spacing: 0.08em;
1221 font-weight: 700;
1222 font-family: var(--font-mono);
1223 }
1224 .branches-row-meta {
1225 display: flex;
1226 align-items: center;
1227 gap: 8px;
1228 flex-wrap: wrap;
1229 font-size: 12.5px;
1230 color: var(--text-muted);
1231 font-variant-numeric: tabular-nums;
1232 }
1233 .branches-row-meta .sep { opacity: 0.4; }
1234 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1235 .branches-row-side {
1236 display: flex;
1237 align-items: center;
1238 gap: 10px;
1239 flex-shrink: 0;
1240 flex-wrap: wrap;
1241 }
1242 .branches-row-divergence {
1243 display: inline-flex;
1244 align-items: center;
1245 gap: 8px;
1246 font-family: var(--font-mono);
1247 font-size: 11.5px;
1248 color: var(--text-muted);
1249 padding: 4px 10px;
1250 border-radius: 9999px;
1251 background: rgba(255,255,255,0.035);
1252 border: 1px solid var(--border);
1253 font-variant-numeric: tabular-nums;
1254 }
1255 .branches-row-divergence .ahead { color: #6ee7b7; }
1256 .branches-row-divergence .behind { color: #fca5a5; }
1257 .branches-row-actions {
1258 display: flex;
1259 align-items: center;
1260 gap: 6px;
1261 }
1262 .branches-btn {
1263 display: inline-flex;
1264 align-items: center;
1265 gap: 5px;
1266 padding: 6px 12px;
1267 border-radius: 9999px;
1268 font-size: 12px;
1269 font-weight: 600;
1270 text-decoration: none;
1271 border: 1px solid var(--border);
1272 background: transparent;
1273 color: var(--text-muted);
1274 cursor: pointer;
1275 font: inherit;
1276 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1277 }
1278 .branches-btn:hover {
1279 border-color: var(--border-strong);
1280 color: var(--text);
1281 background: rgba(255,255,255,0.04);
1282 text-decoration: none;
1283 }
1284 .branches-btn-danger {
1285 color: #fca5a5;
1286 border-color: rgba(248,113,113,0.30);
1287 }
1288 .branches-btn-danger:hover {
1289 border-style: dashed;
1290 border-color: rgba(248,113,113,0.65);
1291 background: rgba(248,113,113,0.06);
1292 color: #fecaca;
1293 }
1294
1295 /* ───────── tags list ───────── */
1296 .tags-list {
1297 background: var(--bg-elevated);
1298 border: 1px solid var(--border);
1299 border-radius: 14px;
1300 overflow: hidden;
1301 position: relative;
1302 }
1303 .tags-list::before {
1304 content: '';
1305 position: absolute;
1306 top: 0; left: 0; right: 0;
1307 height: 2px;
6fd5915Claude1308 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1309 opacity: 0.55;
1310 pointer-events: none;
1311 }
1312 .tags-row {
1313 display: flex;
1314 align-items: center;
1315 gap: var(--space-3);
1316 padding: 14px 18px;
1317 border-bottom: 1px solid var(--border-subtle);
1318 transition: background 120ms ease;
1319 flex-wrap: wrap;
1320 }
1321 .tags-row:last-child { border-bottom: none; }
1322 .tags-row:hover { background: rgba(255,255,255,0.022); }
1323 .tags-row-icon {
1324 width: 32px; height: 32px;
1325 border-radius: 8px;
6fd5915Claude1326 background: rgba(95,143,160,0.10);
398a10cClaude1327 color: #67e8f9;
1328 display: inline-flex;
1329 align-items: center;
1330 justify-content: center;
1331 flex-shrink: 0;
6fd5915Claude1332 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1333 }
1334 .tags-row-main { flex: 1; min-width: 240px; }
1335 .tags-row-name {
1336 display: inline-flex;
1337 align-items: center;
1338 gap: 8px;
1339 flex-wrap: wrap;
1340 }
1341 .tags-row-version {
1342 display: inline-flex;
1343 align-items: center;
1344 padding: 3px 11px;
1345 border-radius: 9999px;
1346 font-family: var(--font-mono);
1347 font-size: 13px;
1348 font-weight: 700;
1349 color: #67e8f9;
6fd5915Claude1350 background: rgba(95,143,160,0.12);
1351 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1352 letter-spacing: 0.01em;
1353 }
1354 .tags-row-meta {
1355 margin-top: 4px;
1356 display: flex;
1357 align-items: center;
1358 gap: 8px;
1359 flex-wrap: wrap;
1360 font-size: 12.5px;
1361 color: var(--text-muted);
1362 font-variant-numeric: tabular-nums;
1363 }
1364 .tags-row-meta .sep { opacity: 0.4; }
1365 .tags-row-sha {
1366 font-family: var(--font-mono);
1367 font-size: 11.5px;
1368 color: var(--text-strong);
1369 padding: 2px 8px;
1370 border-radius: 6px;
1371 background: rgba(255,255,255,0.04);
1372 border: 1px solid var(--border);
1373 text-decoration: none;
1374 letter-spacing: 0.04em;
1375 }
1376 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1377 .tags-row-side {
1378 display: flex;
1379 align-items: center;
1380 gap: 6px;
1381 flex-shrink: 0;
1382 flex-wrap: wrap;
1383 }
1384 .tags-row-link {
1385 display: inline-flex;
1386 align-items: center;
1387 gap: 5px;
1388 padding: 6px 12px;
1389 border-radius: 9999px;
1390 font-size: 12px;
1391 font-weight: 600;
1392 text-decoration: none;
1393 color: var(--text-muted);
1394 background: rgba(255,255,255,0.025);
1395 border: 1px solid var(--border);
1396 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1397 }
1398 .tags-row-link:hover {
6fd5915Claude1399 border-color: rgba(91,110,232,0.45);
398a10cClaude1400 color: var(--text-strong);
6fd5915Claude1401 background: rgba(91,110,232,0.06);
398a10cClaude1402 text-decoration: none;
efb11c5Claude1403 }
1404
1405 /* ───────── commit detail ───────── */
1406 .commit-detail-card {
1407 position: relative;
1408 margin-bottom: var(--space-5);
1409 padding: var(--space-5) var(--space-5);
1410 background: var(--bg-elevated);
1411 border: 1px solid var(--border);
1412 border-radius: 14px;
1413 overflow: hidden;
1414 }
1415 .commit-detail-eyebrow {
1416 display: flex;
1417 align-items: center;
1418 gap: var(--space-2);
1419 font-size: 12px;
1420 font-family: var(--font-mono);
1421 text-transform: uppercase;
1422 letter-spacing: 0.1em;
1423 color: var(--text-muted);
1424 margin-bottom: var(--space-2);
1425 }
1426 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1427 .commit-detail-sha-pill {
1428 font-family: var(--font-mono);
1429 font-size: 11.5px;
1430 color: var(--text-strong);
1431 background: var(--bg-secondary);
1432 border: 1px solid var(--border);
1433 border-radius: 999px;
1434 padding: 2px 10px;
1435 letter-spacing: 0.04em;
1436 }
1437 .commit-detail-verify {
1438 font-size: 10px;
1439 padding: 2px 8px;
1440 border-radius: 999px;
1441 text-transform: uppercase;
1442 letter-spacing: 0.06em;
1443 font-weight: 700;
1444 color: #fff;
1445 }
1446 .commit-detail-verify-ok {
1447 background: linear-gradient(135deg, #2ea043, #34d399);
1448 }
1449 .commit-detail-verify-warn {
1450 background: linear-gradient(135deg, #d29922, #f59e0b);
1451 }
1452 .commit-detail-title {
1453 font-family: var(--font-display);
1454 font-weight: 700;
1455 letter-spacing: -0.018em;
1456 font-size: clamp(20px, 2.5vw, 26px);
1457 line-height: 1.25;
1458 margin: 0 0 var(--space-2);
1459 color: var(--text-strong);
1460 }
1461 .commit-detail-body {
1462 white-space: pre-wrap;
1463 color: var(--text-muted);
1464 font-size: 14px;
1465 line-height: 1.55;
1466 margin: 0 0 var(--space-3);
1467 font-family: var(--font-mono);
1468 background: var(--bg);
1469 border: 1px solid var(--border);
1470 border-radius: 8px;
1471 padding: var(--space-3) var(--space-4);
1472 max-height: 280px;
1473 overflow: auto;
1474 }
1475 .commit-detail-meta {
1476 display: flex;
1477 flex-wrap: wrap;
1478 gap: var(--space-3);
1479 font-size: 13px;
1480 color: var(--text-muted);
1481 margin-bottom: var(--space-3);
1482 }
1483 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1484 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1485 .commit-detail-sha-link {
1486 font-family: var(--font-mono);
1487 font-size: 12.5px;
1488 color: var(--accent);
6fd5915Claude1489 background: rgba(91,110,232,0.08);
efb11c5Claude1490 border-radius: 6px;
1491 padding: 1px 6px;
1492 margin-left: 2px;
1493 }
6fd5915Claude1494 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1495 .commit-detail-stats {
1496 display: flex;
1497 flex-wrap: wrap;
1498 align-items: center;
1499 gap: var(--space-3);
1500 padding-top: var(--space-3);
1501 border-top: 1px solid var(--border);
1502 font-size: 13px;
1503 color: var(--text-muted);
1504 }
1505 .commit-detail-stat {
1506 display: inline-flex;
1507 align-items: center;
1508 gap: 4px;
1509 font-variant-numeric: tabular-nums;
1510 }
1511 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1512 .commit-detail-stat-add strong { color: #34d399; }
1513 .commit-detail-stat-del strong { color: #f87171; }
1514 .commit-detail-stat-mark {
1515 font-family: var(--font-mono);
1516 font-weight: 700;
1517 font-size: 14px;
1518 }
1519 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1520 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1521 .commit-detail-sha-full {
1522 margin-left: auto;
1523 font-family: var(--font-mono);
1524 font-size: 11.5px;
1525 color: var(--text-faint);
1526 letter-spacing: 0.02em;
1527 overflow: hidden;
1528 text-overflow: ellipsis;
1529 white-space: nowrap;
1530 max-width: 100%;
1531 }
1532 .commit-detail-checks {
1533 margin-top: var(--space-3);
1534 padding-top: var(--space-3);
1535 border-top: 1px solid var(--border);
1536 }
1537 .commit-detail-checks-head {
1538 display: flex;
1539 align-items: center;
1540 gap: var(--space-2);
1541 font-size: 13px;
1542 color: var(--text-muted);
1543 margin-bottom: 8px;
1544 }
1545 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1546 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1547 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1548 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1549 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1550 .commit-detail-check {
1551 font-size: 11px;
1552 padding: 2px 8px;
1553 border-radius: 999px;
1554 color: #fff;
1555 font-weight: 500;
1556 }
398a10cClaude1557 .commit-detail-check a { color: inherit; text-decoration: none; }
1558 .commit-detail-check-success { background: #2ea043; }
1559 .commit-detail-check-pending { background: #d29922; }
1560 .commit-detail-check-failure { background: #da3633; }
1561
1562 /* ───────── blame ───────── */
1563 .blame-head { margin-bottom: var(--space-5); }
1564 .blame-eyebrow {
1565 display: inline-flex;
1566 align-items: center;
1567 gap: 8px;
1568 text-transform: uppercase;
1569 font-family: var(--font-mono);
1570 font-size: 11px;
1571 letter-spacing: 0.16em;
1572 color: var(--text-muted);
1573 font-weight: 600;
1574 margin-bottom: 10px;
1575 }
1576 .blame-eyebrow-dot {
1577 width: 8px; height: 8px;
1578 border-radius: 9999px;
6fd5915Claude1579 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1580 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1581 }
1582 .blame-title {
1583 font-family: var(--font-display);
1584 font-size: clamp(22px, 3vw, 30px);
1585 font-weight: 800;
1586 letter-spacing: -0.025em;
1587 line-height: 1.15;
1588 margin: 0 0 6px;
1589 color: var(--text-strong);
1590 }
1591 .blame-title code {
1592 font-family: var(--font-mono);
1593 font-size: 0.78em;
1594 color: var(--text-strong);
1595 font-weight: 700;
1596 }
1597 .blame-sub {
1598 margin: 0;
1599 font-size: 14px;
1600 color: var(--text-muted);
1601 line-height: 1.5;
1602 max-width: 700px;
1603 }
efb11c5Claude1604 .blame-toolbar {
398a10cClaude1605 display: flex;
1606 justify-content: space-between;
1607 align-items: center;
1608 gap: var(--space-3);
efb11c5Claude1609 margin-bottom: var(--space-3);
398a10cClaude1610 flex-wrap: wrap;
efb11c5Claude1611 font-size: 13px;
1612 }
398a10cClaude1613 .blame-toolbar-actions { display: flex; gap: 6px; }
1614 .blame-card {
1615 background: var(--bg-elevated);
1616 border: 1px solid var(--border);
1617 border-radius: 14px;
1618 overflow: hidden;
1619 position: relative;
1620 }
1621 .blame-card::before {
1622 content: '';
1623 position: absolute;
1624 top: 0; left: 0; right: 0;
1625 height: 2px;
6fd5915Claude1626 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1627 opacity: 0.55;
1628 pointer-events: none;
1629 }
efb11c5Claude1630 .blame-header {
1631 display: flex;
1632 align-items: center;
1633 justify-content: space-between;
1634 gap: var(--space-3);
1635 padding: 10px 14px;
1636 background: var(--bg-secondary);
1637 border-bottom: 1px solid var(--border);
1638 }
1639 .blame-header-meta {
1640 display: flex;
1641 align-items: center;
1642 gap: var(--space-2);
1643 min-width: 0;
1644 flex-wrap: wrap;
1645 }
1646 .blame-header-icon { color: var(--accent); font-size: 14px; }
1647 .blame-header-name {
1648 font-family: var(--font-mono);
1649 font-size: 13px;
1650 color: var(--text-strong);
1651 font-weight: 600;
1652 }
1653 .blame-header-tag {
1654 font-size: 10.5px;
1655 text-transform: uppercase;
1656 letter-spacing: 0.08em;
1657 font-family: var(--font-mono);
6fd5915Claude1658 background: rgba(91,110,232,0.12);
efb11c5Claude1659 color: var(--accent);
1660 border-radius: 999px;
1661 padding: 2px 8px;
1662 font-weight: 600;
1663 }
1664 .blame-header-stats {
1665 font-family: var(--font-mono);
1666 font-size: 11.5px;
1667 color: var(--text-muted);
1668 font-variant-numeric: tabular-nums;
1669 border-left: 1px solid var(--border);
1670 padding-left: var(--space-2);
1671 }
1672 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1673 .blame-table {
1674 width: 100%;
1675 border-collapse: collapse;
1676 font-family: var(--font-mono);
1677 font-size: 12.5px;
1678 line-height: 1.6;
1679 }
1680 .blame-table tr { border-bottom: 1px solid transparent; }
1681 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1682 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1683 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1684 .blame-gutter {
1685 width: 220px;
1686 min-width: 220px;
1687 padding: 0 12px;
1688 vertical-align: top;
1689 background: rgba(255,255,255,0.012);
1690 border-right: 1px solid var(--border-subtle);
1691 font-variant-numeric: tabular-nums;
1692 color: var(--text-muted);
1693 font-size: 11px;
1694 white-space: nowrap;
1695 overflow: hidden;
1696 text-overflow: ellipsis;
1697 padding-top: 2px;
1698 padding-bottom: 2px;
1699 }
1700 .blame-gutter-inner {
1701 display: inline-flex;
1702 align-items: center;
1703 gap: 7px;
1704 max-width: 100%;
1705 overflow: hidden;
1706 }
1707 .blame-gutter-sha {
1708 font-family: var(--font-mono);
1709 font-size: 10.5px;
1710 font-weight: 600;
1711 color: #c4b5fd;
6fd5915Claude1712 background: rgba(91,110,232,0.10);
398a10cClaude1713 padding: 1px 7px;
1714 border-radius: 9999px;
6fd5915Claude1715 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1716 text-decoration: none;
1717 letter-spacing: 0.04em;
1718 flex-shrink: 0;
1719 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1720 }
1721 .blame-gutter-sha:hover {
6fd5915Claude1722 background: rgba(91,110,232,0.22);
398a10cClaude1723 color: #ddd6fe;
6fd5915Claude1724 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1725 text-decoration: none;
1726 }
1727 .blame-gutter-author {
1728 color: var(--text);
1729 overflow: hidden;
1730 text-overflow: ellipsis;
1731 white-space: nowrap;
1732 font-size: 11px;
1733 font-family: var(--font-sans, inherit);
1734 }
1735 .blame-line-num {
1736 width: 1%;
1737 min-width: 50px;
1738 padding: 0 12px;
1739 text-align: right;
1740 color: var(--text-faint);
1741 user-select: none;
1742 border-right: 1px solid var(--border-subtle);
1743 font-variant-numeric: tabular-nums;
1744 }
1745 .blame-line-content {
1746 padding: 0 14px;
1747 white-space: pre;
1748 color: var(--text);
1749 transition: background 120ms ease;
1750 }
efb11c5Claude1751
1752 /* ───────── search ───────── */
1753 .search-hero {
1754 position: relative;
1755 margin-bottom: var(--space-4);
1756 padding: var(--space-5) var(--space-6);
1757 background: var(--bg-elevated);
1758 border: 1px solid var(--border);
1759 border-radius: 16px;
1760 overflow: hidden;
1761 }
1762 .search-eyebrow {
1763 font-size: 12px;
1764 font-family: var(--font-mono);
1765 color: var(--text-muted);
1766 letter-spacing: 0.1em;
1767 text-transform: uppercase;
1768 margin-bottom: var(--space-2);
1769 }
1770 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1771 .search-title {
1772 font-family: var(--font-display);
1773 font-weight: 800;
1774 letter-spacing: -0.025em;
1775 font-size: clamp(22px, 3vw, 30px);
1776 line-height: 1.15;
1777 margin: 0 0 var(--space-3);
1778 color: var(--text-strong);
1779 }
1780 .search-form {
1781 display: flex;
1782 gap: var(--space-2);
1783 align-items: stretch;
1784 }
1785 .search-input-wrap {
1786 position: relative;
1787 flex: 1;
1788 display: flex;
1789 align-items: center;
1790 }
1791 .search-input-icon {
1792 position: absolute;
1793 left: 12px;
1794 color: var(--text-muted);
1795 font-size: 15px;
1796 pointer-events: none;
1797 }
1798 .search-input {
1799 appearance: none;
1800 width: 100%;
1801 padding: 10px 12px 10px 34px;
1802 background: var(--bg);
1803 border: 1px solid var(--border);
1804 border-radius: 10px;
1805 color: var(--text-strong);
1806 font-size: 14px;
1807 font-family: inherit;
1808 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1809 }
1810 .search-input:focus {
1811 outline: none;
1812 border-color: var(--accent);
6fd5915Claude1813 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1814 }
1815 .search-submit { min-width: 96px; }
1816 .search-results-head {
1817 display: flex;
1818 align-items: baseline;
1819 gap: var(--space-2);
1820 margin-bottom: var(--space-3);
1821 font-size: 13px;
1822 color: var(--text-muted);
1823 }
1824 .search-results-count strong {
1825 color: var(--text-strong);
1826 font-weight: 600;
1827 font-variant-numeric: tabular-nums;
1828 }
1829 .search-results-q { color: var(--text-strong); font-weight: 600; }
1830 .search-results-head code {
1831 font-family: var(--font-mono);
1832 font-size: 12px;
1833 background: var(--bg-secondary);
1834 border: 1px solid var(--border);
1835 border-radius: 5px;
1836 padding: 1px 6px;
1837 color: var(--text);
1838 }
1839 .search-empty {
1840 padding: var(--space-5) var(--space-6);
1841 background: var(--bg-elevated);
1842 border: 1px dashed var(--border);
1843 border-radius: 12px;
1844 color: var(--text-muted);
1845 font-size: 14px;
1846 }
1847 .search-empty strong { color: var(--text-strong); }
1848 .search-results {
1849 display: flex;
1850 flex-direction: column;
1851 gap: var(--space-3);
1852 }
1853 .search-file-head {
1854 display: flex;
1855 align-items: center;
1856 justify-content: space-between;
1857 gap: var(--space-2);
1858 }
1859 .search-file-link {
1860 font-family: var(--font-mono);
1861 font-size: 13px;
1862 color: var(--text-strong);
1863 font-weight: 600;
1864 }
1865 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1866 .search-file-count {
1867 font-family: var(--font-mono);
1868 font-size: 11.5px;
1869 color: var(--text-muted);
1870 font-variant-numeric: tabular-nums;
1871 }
1872`;
1873
fc1817aClaude1874// Home page
06d5ffeClaude1875web.get("/", async (c) => {
1876 const user = c.get("user");
1877
1878 if (user) {
0316dbbClaude1879 return c.redirect("/dashboard");
06d5ffeClaude1880 }
1881
8e9f1d9Claude1882 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1883 let publicStats: PublicStats | null = null;
8e9f1d9Claude1884 try {
1885 const [repoRow] = await db
1886 .select({ n: sql<number>`count(*)::int` })
1887 .from(repositories)
1888 .where(eq(repositories.isPrivate, false));
1889 const [userRow] = await db
1890 .select({ n: sql<number>`count(*)::int` })
1891 .from(users);
1892 stats = {
1893 publicRepos: Number(repoRow?.n ?? 0),
1894 users: Number(userRow?.n ?? 0),
1895 };
1896 } catch {
1897 stats = undefined;
1898 }
1899
52ad8b1Claude1900 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1901 try {
1902 publicStats = await computePublicStats();
1903 } catch {
1904 publicStats = null;
1905 }
1906
534f04aClaude1907 // Block M1 — initial SSR snapshot for the live-now demo feed.
1908 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1909 // in try/catch so a freak module-level explosion can't take down /.
8ed88f2ccantynz-alt1910 let liveFeed: LandingProLiveFeed | null = null;
534f04aClaude1911 try {
1912 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1913 listQueuedAiBuildIssues(3),
1914 listRecentAutoMerges(3, 24),
1915 listRecentAiReviews(3, 24),
1916 countAiReviewsSince(24),
1917 listDemoActivityFeed(10),
1918 ]);
1919 liveFeed = {
1920 queued: queued.map((i) => ({
1921 repo: i.repo,
1922 number: i.number,
1923 title: i.title,
8ed88f2ccantynz-alt1924 createdAt: new Date(i.createdAt),
534f04aClaude1925 })),
1926 merges: merges.map((m) => ({
1927 repo: m.repo,
1928 number: m.number,
1929 title: m.title,
1930 mergedAt: m.mergedAt,
1931 })),
1932 reviews: reviewList.map((r) => ({
1933 repo: r.repo,
1934 prNumber: r.prNumber,
1935 commentSnippet: r.commentSnippet,
1936 createdAt: r.createdAt,
1937 })),
1938 reviewCount,
1939 feed: feed.map((e) => ({
1940 kind: e.kind,
1941 repo: e.repo,
1942 ref: e.ref,
1943 at: e.at,
1944 })),
1945 };
1946 } catch {
1947 liveFeed = null;
1948 }
1949
29924bcClaude1950 void LandingPage;
f8e5feaccanty labs1951 void Landing2030Page;
1952 return c.html(
1953 "<!DOCTYPE html>" +
1954 String(
1955 <LandingProPage
1956 stats={stats}
1957 publicStats={publicStats}
1958 liveFeed={liveFeed}
1959 />
1960 )
1961 );
fc1817aClaude1962});
1963
06d5ffeClaude1964// New repository form
1965web.get("/new", requireAuth, (c) => {
1966 const user = c.get("user")!;
1967 const error = c.req.query("error");
1968
1969 return c.html(
1970 <Layout title="New repository" user={user}>
efb11c5Claude1971 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1972 <div class="new-repo-hero">
1973 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1974 <div class="new-repo-hero-orb" />
1975 </div>
1976 <div class="new-repo-hero-inner">
1977 <div class="new-repo-eyebrow">
1978 <strong>Create</strong> · {user.username}
1979 </div>
1980 <h1 class="new-repo-title">
1981 Spin up a <span class="gradient-text">repository</span>.
1982 </h1>
1983 <p class="new-repo-sub">
1984 Push your first commit, and Gluecron wires up gate checks, AI review,
1985 and auto-merge from the moment your branch lands.
1986 </p>
1987 </div>
1988 </div>
06d5ffeClaude1989 <div class="new-repo-form">
efb11c5Claude1990 {error && (
1991 <div class="new-repo-error" role="alert">
1992 {decodeURIComponent(error)}
1993 </div>
1994 )}
1995 <form method="post" action="/new" class="new-repo-form-grid">
1996 <div class="new-repo-row">
1997 <label class="new-repo-label">Owner</label>
1998 <input
1999 type="text"
2000 value={user.username}
2001 disabled
2002 aria-label="Owner"
2003 class="new-repo-input new-repo-input-disabled"
2004 />
06d5ffeClaude2005 </div>
efb11c5Claude2006 <div class="new-repo-row">
2007 <label class="new-repo-label" for="name">
2008 Repository name
2009 </label>
06d5ffeClaude2010 <input
2011 type="text"
2012 id="name"
2013 name="name"
2014 required
2015 pattern="^[a-zA-Z0-9._-]+$"
2016 placeholder="my-project"
2017 autocomplete="off"
efb11c5Claude2018 class="new-repo-input"
06d5ffeClaude2019 />
efb11c5Claude2020 <p class="new-repo-hint">
2021 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2022 <code>{user.username}/&lt;name&gt;</code>.
2023 </p>
06d5ffeClaude2024 </div>
efb11c5Claude2025 <div class="new-repo-row">
2026 <label class="new-repo-label" for="description">
2027 Description{" "}
2028 <span class="new-repo-label-optional">(optional)</span>
2029 </label>
06d5ffeClaude2030 <input
2031 type="text"
2032 id="description"
2033 name="description"
2034 placeholder="A short description of your repository"
efb11c5Claude2035 class="new-repo-input"
06d5ffeClaude2036 />
2037 </div>
efb11c5Claude2038 <div class="new-repo-row">
2039 <span class="new-repo-label">Visibility</span>
2040 <div class="new-repo-visibility">
2041 <label class="new-repo-vis-card">
2042 <input
2043 type="radio"
2044 name="visibility"
2045 value="public"
2046 checked
2047 class="new-repo-vis-radio"
2048 />
2049 <span class="new-repo-vis-body">
2050 <span class="new-repo-vis-label">Public</span>
2051 <span class="new-repo-vis-desc">
2052 Anyone can see this repository. You choose who can commit.
2053 </span>
2054 </span>
2055 </label>
2056 <label class="new-repo-vis-card">
2057 <input
2058 type="radio"
2059 name="visibility"
2060 value="private"
2061 class="new-repo-vis-radio"
2062 />
2063 <span class="new-repo-vis-body">
2064 <span class="new-repo-vis-label">Private</span>
2065 <span class="new-repo-vis-desc">
2066 Only you (and collaborators you invite) can see this
2067 repository.
2068 </span>
2069 </span>
2070 </label>
2071 </div>
2072 </div>
398a10cClaude2073 <div class="new-repo-row">
2074 <span class="new-repo-label">
2075 Starter content{" "}
2076 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2077 </span>
2078 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2079 <label class="new-repo-template-chip">
2080 <input type="radio" name="starter" value="empty" checked />
2081 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2082 Empty
2083 </label>
2084 <label class="new-repo-template-chip">
2085 <input type="radio" name="starter" value="readme" />
2086 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2087 README
2088 </label>
2089 <label class="new-repo-template-chip">
2090 <input type="radio" name="starter" value="readme-mit" />
2091 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2092 README + MIT
2093 </label>
2094 <label class="new-repo-template-chip">
2095 <input type="radio" name="starter" value="node" />
2096 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2097 Node + .gitignore
2098 </label>
2099 </div>
2100 <p class="new-repo-hint">
2101 Just a UI hint — push your own commits to fill the repo.
2102 </p>
2103 </div>
44f1a02Claude2104 <div class="new-repo-row">
2105 <label class="new-repo-label" for="data_region">
2106 Data region
2107 </label>
2108 <select
2109 id="data_region"
2110 name="data_region"
2111 class="new-repo-input"
2112 style="cursor: pointer;"
2113 >
2114 <option value="us" selected>US (default)</option>
2115 <option value="eu">EU (Frankfurt)</option>
2116 </select>
2117 <p class="new-repo-hint">
2118 EU data residency requires a{" "}
2119 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2120 Pro plan or higher
2121 </a>
2122 . Repositories cannot be moved between regions after creation.
2123 </p>
2124 </div>
efb11c5Claude2125 <div class="new-repo-callout">
2126 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2127 <p class="new-repo-callout-body">
2128 Every push is gate-checked and reviewed by Claude automatically.
2129 Label an issue <code>ai-build</code> and Gluecron will open the PR
2130 for you.
2131 </p>
2132 </div>
2133 <div class="new-repo-actions">
398a10cClaude2134 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2135 Create repository
2136 </button>
2137 <a href="/dashboard" class="btn new-repo-cancel">
2138 Cancel
2139 </a>
06d5ffeClaude2140 </div>
2141 </form>
2142 </div>
2143 </Layout>
2144 );
2145});
2146
2147web.post("/new", requireAuth, async (c) => {
2148 const user = c.get("user")!;
2149 const body = await c.req.parseBody();
2150 const name = String(body.name || "").trim();
2151 const description = String(body.description || "").trim();
2152 const isPrivate = body.visibility === "private";
44f1a02Claude2153 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2154
2155 if (!name) {
2156 return c.redirect("/new?error=Repository+name+is+required");
2157 }
2158
c63b860Claude2159 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2160 // outage never blocks repo creation.
2161 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2162 const gate = await checkRepoCreateAllowed(user.id);
2163 if (!gate.ok) {
2164 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2165 }
2166
06d5ffeClaude2167 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2168 return c.redirect("/new?error=Invalid+repository+name");
2169 }
2170
2171 if (await repoExists(user.username, name)) {
2172 return c.redirect("/new?error=Repository+already+exists");
2173 }
2174
2175 const diskPath = await initBareRepo(user.username, name);
2176
3ef4c9dClaude2177 const [newRepo] = await db
2178 .insert(repositories)
2179 .values({
2180 name,
2181 ownerId: user.id,
2182 description: description || null,
2183 isPrivate,
2184 diskPath,
44f1a02Claude2185 dataRegion,
3ef4c9dClaude2186 })
2187 .returning();
2188
2189 if (newRepo) {
2190 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2191 await bootstrapRepository({
2192 repositoryId: newRepo.id,
2193 ownerUserId: user.id,
2194 defaultBranch: "main",
2195 });
2196 }
06d5ffeClaude2197
2198 return c.redirect(`/${user.username}/${name}`);
2199});
2200
11c3ab6ccanty labs2201// Daily brief — GET /brief
2202web.get("/brief", (c) => {
2203 const user = c.get("user");
8ed88f2ccantynz-alt2204 return c.html(
2205 <DailyBrief
2206 user={user ? { name: user.displayName || user.username } : undefined}
2207 />
2208 );
11c3ab6ccanty labs2209});
2210
2211// Trust report — GET /trust (public)
2212web.get("/trust", (c) => {
2213 return c.html(<TrustReport />);
2214});
2215
2216// Production layers — GET /layers
2217web.get("/layers", (c) => {
2218 const user = c.get("user");
2219 return c.html(<ProductionLayers user={user} />);
2220});
2221
2222// Distribution — GET /distribute
2223web.get("/distribute", (c) => {
2224 const user = c.get("user");
2225 return c.html(<DistributionView user={user} />);
2226});
2227
06d5ffeClaude2228// User profile
fc1817aClaude2229web.get("/:owner", async (c) => {
06d5ffeClaude2230 const { owner: ownerName } = c.req.param();
2231 const user = c.get("user");
2232
2233 // Avoid clashing with fixed routes
2234 if (
2235 ["login", "register", "logout", "new", "settings", "api"].includes(
2236 ownerName
2237 )
2238 ) {
2239 return c.notFound();
2240 }
2241
2242 let ownerUser;
2243 try {
2244 const [found] = await db
2245 .select()
2246 .from(users)
2247 .where(eq(users.username, ownerName))
2248 .limit(1);
2249 ownerUser = found;
2250 } catch {
2251 // DB not available — check if repos exist on disk
2252 ownerUser = null;
2253 }
2254
2255 // Even without DB, show repos if they exist on disk
2256 let repos: any[] = [];
2257 if (ownerUser) {
2258 const allRepos = await db
2259 .select()
2260 .from(repositories)
2261 .where(eq(repositories.ownerId, ownerUser.id))
2262 .orderBy(desc(repositories.updatedAt));
2263
2264 // Show public repos to everyone, private only to owner
2265 repos =
2266 user?.id === ownerUser.id
2267 ? allRepos
2268 : allRepos.filter((r) => !r.isPrivate);
2269 }
2270
7aa8b99Claude2271 // Block J4 — follow counts + viewer's follow state
2272 let followState = {
2273 followers: 0,
2274 following: 0,
2275 viewerFollows: false,
2276 };
2277 if (ownerUser) {
2278 try {
2279 const { followCounts, isFollowing } = await import("../lib/follows");
2280 const counts = await followCounts(ownerUser.id);
2281 followState.followers = counts.followers;
2282 followState.following = counts.following;
2283 if (user && user.id !== ownerUser.id) {
2284 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2285 }
2286 } catch {
2287 // DB hiccup — fall back to zeros.
2288 }
2289 }
2290 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2291
d412586Claude2292 // Block J5 — profile README. Render owner/owner repo's README on the
2293 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2294 // back to "<user>/.github" for org-style profile repos.
2295 let profileReadmeHtml: string | null = null;
2296 try {
2297 const candidates = [ownerName, ".github"];
2298 for (const rname of candidates) {
2299 if (await repoExists(ownerName, rname)) {
2300 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2301 const md = await getReadme(ownerName, rname, ref);
2302 if (md) {
2303 profileReadmeHtml = renderMarkdown(md);
2304 break;
2305 }
2306 }
2307 }
2308 } catch {
2309 profileReadmeHtml = null;
2310 }
2311
efb11c5Claude2312 const displayName = ownerUser?.displayName || ownerName;
2313 const memberSince = ownerUser?.createdAt
2314 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2315 : null;
2316
fc1817aClaude2317 return c.html(
06d5ffeClaude2318 <Layout title={ownerName} user={user}>
efb11c5Claude2319 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2320 <div class="profile-hero">
2321 <div class="profile-hero-orb-wrap" aria-hidden="true">
2322 <div class="profile-hero-orb" />
06d5ffeClaude2323 </div>
efb11c5Claude2324 <div class="profile-hero-inner">
2325 <div class="profile-hero-avatar" aria-hidden="true">
2326 {displayName[0].toUpperCase()}
2327 </div>
2328 <div class="profile-hero-text">
2329 <div class="profile-eyebrow">
2330 <strong>Developer</strong>
2331 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2332 <>
2333 {" "}· Joined{" "}
2334 {memberSince.toLocaleDateString("en-US", {
2335 month: "short",
2336 year: "numeric",
2337 })}
2338 </>
2339 )}
2340 </div>
2341 <h1 class="profile-name">
2342 <span class="gradient-text">{displayName}</span>
2343 </h1>
2344 <div class="profile-handle">@{ownerName}</div>
2345 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2346 <div class="profile-meta">
2347 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2348 <strong>{followState.followers}</strong> follower
2349 {followState.followers === 1 ? "" : "s"}
2350 </a>
2351 <a href={`/${ownerName}/following`} class="profile-meta-link">
2352 <strong>{followState.following}</strong> following
2353 </a>
2354 <a href={`/${ownerName}`} class="profile-meta-link">
2355 <strong>{repos.length}</strong> repo
2356 {repos.length === 1 ? "" : "s"}
2357 </a>
2358 {canFollow && (
2359 <form
2360 method="post"
2361 action={`/${ownerName}/${
2362 followState.viewerFollows ? "unfollow" : "follow"
2363 }`}
2364 class="profile-follow-form"
7aa8b99Claude2365 >
efb11c5Claude2366 <button
2367 type="submit"
2368 class={`btn ${
2369 followState.viewerFollows ? "" : "btn-primary"
2370 } btn-sm`}
2371 >
2372 {followState.viewerFollows ? "Unfollow" : "Follow"}
2373 </button>
2374 </form>
2375 )}
2376 </div>
7aa8b99Claude2377 </div>
06d5ffeClaude2378 </div>
2379 </div>
d412586Claude2380 {profileReadmeHtml && (
efb11c5Claude2381 <div class="profile-readme">
2382 <div class="profile-readme-head">
2383 <span class="profile-readme-icon">{"☰"}</span>
2384 <span>{ownerName}/{ownerName} README.md</span>
2385 </div>
2386 <div
2387 class="markdown-body profile-readme-body"
2388 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2389 />
2390 </div>
d412586Claude2391 )}
efb11c5Claude2392 <div class="profile-section-head">
2393 <h2 class="profile-section-title">Repositories</h2>
2394 <span class="profile-section-count">{repos.length}</span>
2395 </div>
06d5ffeClaude2396 {repos.length === 0 ? (
efb11c5Claude2397 <div class="profile-empty">
2398 <p class="profile-empty-text">
2399 No repositories yet
2400 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2401 </p>
2402 {user?.id === ownerUser?.id && (
2403 <a href="/new" class="btn btn-primary btn-sm">
2404 + Create your first
2405 </a>
2406 )}
2407 </div>
06d5ffeClaude2408 ) : (
2409 <div class="card-grid">
2410 {repos.map((repo) => (
2411 <RepoCard repo={repo} ownerName={ownerName} />
2412 ))}
2413 </div>
2414 )}
fc1817aClaude2415 </Layout>
2416 );
2417});
2418
06d5ffeClaude2419// Star/unstar a repo
2420web.post("/:owner/:repo/star", requireAuth, async (c) => {
2421 const { owner: ownerName, repo: repoName } = c.req.param();
2422 const user = c.get("user")!;
2423
2424 try {
2425 const [ownerUser] = await db
2426 .select()
2427 .from(users)
2428 .where(eq(users.username, ownerName))
2429 .limit(1);
2430 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2431
2432 const [repo] = await db
2433 .select()
2434 .from(repositories)
2435 .where(
2436 and(
2437 eq(repositories.ownerId, ownerUser.id),
2438 eq(repositories.name, repoName)
2439 )
2440 )
2441 .limit(1);
2442 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2443
2444 // Toggle star
2445 const [existing] = await db
2446 .select()
2447 .from(stars)
2448 .where(
2449 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2450 )
2451 .limit(1);
2452
2453 if (existing) {
2454 await db.delete(stars).where(eq(stars.id, existing.id));
2455 await db
2456 .update(repositories)
2457 .set({ starCount: Math.max(0, repo.starCount - 1) })
2458 .where(eq(repositories.id, repo.id));
2459 } else {
2460 await db.insert(stars).values({
2461 userId: user.id,
2462 repositoryId: repo.id,
2463 });
2464 await db
2465 .update(repositories)
2466 .set({ starCount: repo.starCount + 1 })
2467 .where(eq(repositories.id, repo.id));
a74f4edccanty labs2468 void fireWebhooks(repo.id, "star", { action: "created" });
06d5ffeClaude2469 }
2470 } catch {
2471 // DB error — ignore
2472 }
2473
2474 return c.redirect(`/${ownerName}/${repoName}`);
2475});
2476
641aa42Claude2477// ---------------------------------------------------------------------------
2478// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2479// ---------------------------------------------------------------------------
2480const obCss = `
2481 .ob-card {
2482 position: relative;
2483 margin: 14px 0 18px;
6fd5915Claude2484 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2485 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2486 border-radius: 14px;
2487 overflow: hidden;
2488 }
2489 .ob-card::before {
2490 content: '';
2491 position: absolute;
2492 top: 0; left: 0; right: 0;
2493 height: 2px;
6fd5915Claude2494 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2495 pointer-events: none;
2496 }
2497 .ob-header {
2498 padding: 16px 20px 10px;
2499 border-bottom: 1px solid var(--border);
2500 }
2501 .ob-header h3 {
2502 font-size: 16px;
2503 font-weight: 700;
2504 margin: 0 0 4px;
2505 color: var(--text-strong);
2506 }
2507 .ob-header p {
2508 font-size: 13px;
2509 color: var(--text-muted);
2510 margin: 0;
2511 }
2512 .ob-sections {
2513 display: grid;
2514 grid-template-columns: repeat(3, 1fr);
2515 gap: 0;
2516 }
2517 @media (max-width: 760px) {
2518 .ob-sections { grid-template-columns: 1fr; }
2519 }
2520 .ob-section {
2521 padding: 14px 20px;
2522 border-right: 1px solid var(--border);
2523 }
2524 .ob-section:last-child { border-right: none; }
2525 .ob-section h4 {
2526 font-size: 12px;
2527 font-weight: 700;
2528 text-transform: uppercase;
2529 letter-spacing: 0.08em;
2530 color: var(--accent);
2531 margin: 0 0 8px;
2532 }
2533 .ob-preview {
2534 font-family: var(--font-mono);
2535 font-size: 11.5px;
2536 background: var(--bg);
2537 border: 1px solid var(--border);
2538 border-radius: 6px;
2539 padding: 8px 10px;
2540 color: var(--text-muted);
2541 line-height: 1.55;
2542 margin-bottom: 8px;
2543 white-space: pre-wrap;
2544 overflow: hidden;
2545 max-height: 80px;
2546 position: relative;
2547 }
2548 .ob-preview::after {
2549 content: '';
2550 position: absolute;
2551 bottom: 0; left: 0; right: 0;
2552 height: 24px;
2553 background: linear-gradient(transparent, var(--bg));
2554 pointer-events: none;
2555 }
2556 .ob-labels {
2557 display: flex;
2558 flex-wrap: wrap;
2559 gap: 5px;
2560 margin-bottom: 8px;
2561 }
2562 .ob-label-chip {
2563 display: inline-flex;
2564 align-items: center;
2565 padding: 2px 8px;
2566 border-radius: 9999px;
2567 font-size: 11.5px;
2568 font-weight: 600;
2569 line-height: 1.5;
2570 border: 1px solid transparent;
2571 }
2572 .ob-section ul {
2573 margin: 0;
2574 padding: 0 0 0 16px;
2575 font-size: 13px;
2576 color: var(--text-muted);
2577 line-height: 1.7;
2578 }
2579 .ob-section ul li { margin-bottom: 2px; }
2580 .ob-footer {
2581 display: flex;
2582 align-items: center;
2583 justify-content: flex-end;
2584 padding: 10px 20px;
2585 border-top: 1px solid var(--border);
2586 gap: 10px;
2587 }
2588 .ob-dismiss {
2589 appearance: none;
2590 background: transparent;
2591 border: 1px solid var(--border);
2592 color: var(--text-muted);
2593 border-radius: 6px;
2594 padding: 5px 12px;
2595 font-size: 12.5px;
2596 font-family: inherit;
2597 cursor: pointer;
2598 transition: background var(--t-fast), color var(--t-fast);
2599 }
2600 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2601 .btn-sm {
2602 appearance: none;
2603 background: var(--bg-elevated);
2604 border: 1px solid var(--border);
2605 color: var(--text-strong);
2606 border-radius: 6px;
2607 padding: 5px 12px;
2608 font-size: 12.5px;
2609 font-weight: 600;
2610 font-family: inherit;
2611 cursor: pointer;
2612 text-decoration: none;
2613 display: inline-flex;
2614 align-items: center;
2615 transition: background var(--t-fast);
2616 }
2617 .btn-sm:hover { background: var(--bg-hover); }
2618 .btn-sm.btn-primary {
2619 background: var(--accent);
2620 color: #fff;
2621 border-color: var(--accent);
2622 }
2623 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2624`;
2625
2626// Onboarding card component — shown to repo owner until dismissed
2627function RepoOnboardingCard({
2628 owner,
2629 repo,
2630 data,
2631}: {
2632 owner: string;
2633 repo: string;
2634 data: typeof repoOnboardingData.$inferSelect;
2635}) {
2636 const labels = (data.suggestedLabels ?? []) as Array<{
2637 name: string;
2638 color: string;
2639 description: string;
2640 }>;
2641 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2642 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2643
2644 return (
2645 <>
2646 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2647 <div class="ob-card" id="repo-onboarding">
2648 <div class="ob-header">
2649 <h3>Get started with {owner}/{repo}</h3>
2650 <p>
2651 Detected: {data.detectedLanguage ?? "Unknown"}
2652 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2653 Here&rsquo;s what we suggest to hit the ground running.
2654 </p>
2655 </div>
2656 <div class="ob-sections">
2657 <div class="ob-section">
2658 <h4>Suggested README</h4>
2659 <div class="ob-preview">{readmePreview}</div>
2660 <button
2661 class="btn-sm"
2662 type="button"
2663 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(_){};})()`}
2664 aria-label="Copy suggested README to clipboard"
2665 >
2666 Copy to clipboard
2667 </button>
2668 </div>
2669 <div class="ob-section">
2670 <h4>Suggested labels ({labels.length})</h4>
2671 <div class="ob-labels">
2672 {labels.slice(0, 6).map((l) => (
2673 <span
2674 class="ob-label-chip"
2675 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2676 title={l.description}
2677 >
2678 {l.name}
2679 </span>
2680 ))}
2681 </div>
2682 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2683 <button class="btn-sm btn-primary" type="submit">
2684 Create all labels
2685 </button>
2686 </form>
2687 </div>
2688 <div class="ob-section">
2689 <h4>First steps</h4>
2690 <ul>
2691 {suggestions.map((s) => (
2692 <li>{s}</li>
2693 ))}
2694 </ul>
2695 </div>
2696 </div>
2697 <div class="ob-footer">
2698 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2699 <button class="ob-dismiss" type="submit">
2700 Dismiss
2701 </button>
2702 </form>
2703 </div>
2704 <script
2705 dangerouslySetInnerHTML={{
2706 __html: `
2707 (function(){
2708 var card = document.getElementById('repo-onboarding');
2709 if (!card) return;
2710 document.addEventListener('keydown', function(e){
2711 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2712 card.style.display = 'none';
2713 }
2714 });
2715 })();
2716 `,
2717 }}
2718 />
2719 </div>
2720 </>
2721 );
2722}
2723
2724// ---------------------------------------------------------------------------
2725// Setup routes — create suggested labels + dismiss onboarding
2726// ---------------------------------------------------------------------------
2727
2728// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2729// requireAuth + write access. Idempotent (label UPSERT by name).
2730web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2731 const { owner, repo } = c.req.param();
2732 const user = c.get("user")!;
2733
2734 // Resolve repo + verify write access
2735 let repoRow: { id: string; ownerId: string } | null = null;
2736 try {
2737 const [ownerUser] = await db
2738 .select({ id: users.id })
2739 .from(users)
2740 .where(eq(users.username, owner))
2741 .limit(1);
2742 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2743 const [r] = await db
2744 .select({ id: repositories.id, ownerId: repositories.ownerId })
2745 .from(repositories)
2746 .where(
2747 and(
2748 eq(repositories.ownerId, ownerUser.id),
2749 eq(repositories.name, repo)
2750 )
2751 )
2752 .limit(1);
2753 repoRow = r ?? null;
2754 } catch {
2755 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2756 }
2757 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2758 if (repoRow.ownerId !== user.id) {
2759 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2760 }
2761
2762 // Fetch the onboarding data
2763 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2764 try {
2765 const [r] = await db
2766 .select()
2767 .from(repoOnboardingData)
2768 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2769 .limit(1);
2770 obRow = r ?? null;
2771 } catch {
2772 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2773 }
2774 if (!obRow) {
2775 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2776 }
2777
2778 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2779 name: string;
2780 color: string;
2781 description: string;
2782 }>;
2783
2784 // Create labels — import the labels table which was already imported
2785 let created = 0;
2786 for (const l of suggestedLabels) {
2787 try {
2788 await db
2789 .insert(labels)
2790 .values({
2791 repositoryId: repoRow.id,
2792 name: l.name,
2793 color: l.color,
2794 description: l.description ?? "",
2795 })
2796 .onConflictDoNothing();
2797 created++;
2798 } catch {
2799 /* skip duplicates */
2800 }
2801 }
2802
2803 // Mark onboarding dismissed
2804 try {
2805 await db
2806 .update(repositories)
2807 .set({ onboardingShown: true })
2808 .where(eq(repositories.id, repoRow.id));
2809 } catch {
2810 /* ignore */
2811 }
2812
2813 return c.redirect(
2814 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2815 );
2816});
2817
2818// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2819web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2820 const { owner, repo } = c.req.param();
2821 const user = c.get("user")!;
2822
2823 try {
2824 const [ownerUser] = await db
2825 .select({ id: users.id })
2826 .from(users)
2827 .where(eq(users.username, owner))
2828 .limit(1);
2829 if (ownerUser) {
2830 const [r] = await db
2831 .select({ id: repositories.id, ownerId: repositories.ownerId })
2832 .from(repositories)
2833 .where(
2834 and(
2835 eq(repositories.ownerId, ownerUser.id),
2836 eq(repositories.name, repo)
2837 )
2838 )
2839 .limit(1);
2840 if (r && r.ownerId === user.id) {
2841 await db
2842 .update(repositories)
2843 .set({ onboardingShown: true })
2844 .where(eq(repositories.id, r.id));
2845 }
2846 }
2847 } catch {
2848 /* swallow — dismiss should never fail visibly */
2849 }
2850
2851 return c.redirect(`/${owner}/${repo}`);
2852});
2853
11c3ab6ccanty labs2854// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2855web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2856 const { owner, repo } = c.req.param();
2857 const user = c.get("user");
5bb52faccanty labs2858 const gate = await assertRepoReadable(c, owner, repo);
2859 if (gate) return gate;
11c3ab6ccanty labs2860 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2861});
2862
2863// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2864web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2865 const { owner, repo } = c.req.param();
2866 const user = c.get("user");
5bb52faccanty labs2867 const gate = await assertRepoReadable(c, owner, repo);
2868 if (gate) return gate;
11c3ab6ccanty labs2869 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2870});
2871
fc1817aClaude2872// Repository overview — file tree at HEAD
2873web.get("/:owner/:repo", async (c) => {
2874 const { owner, repo } = c.req.param();
06d5ffeClaude2875 const user = c.get("user");
fc1817aClaude2876
5bb52faccanty labs2877 // SECURITY: gate private-repo content before any render (incl. skeleton).
2878 const gate = await assertRepoReadable(c, owner, repo);
2879 if (gate) return gate;
2880
f1dc7c7Claude2881 // ── Loading skeleton (flag-gated) ──
2882 // Renders an SSR'd shell with file-tree + README placeholders when
2883 // `?skeleton=1` is set. Lets the user see the page structure before
2884 // git ops finish. Behind a flag for now so we never flash before the
2885 // real content lands.
2886 if (c.req.query("skeleton") === "1") {
2887 return c.html(
2888 <Layout title={`${owner}/${repo}`} user={user}>
2889 <style
2890 dangerouslySetInnerHTML={{
2891 __html: `
2892 .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; }
2893 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2894 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2895 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2896 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2897 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2898 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2899 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2900 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2901 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2902 .repo-skel-readme { height: 320px; border-radius: 12px; }
2903 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2904 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2905 `,
2906 }}
2907 />
2908 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2909 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2910 <div class="repo-skel-grid" aria-hidden="true">
2911 <div>
2912 <div class="repo-skel repo-skel-branch" />
2913 <div class="repo-skel-tree">
2914 {Array.from({ length: 8 }).map(() => (
2915 <div class="repo-skel repo-skel-tree-row" />
2916 ))}
2917 </div>
2918 <div class="repo-skel repo-skel-readme" />
2919 </div>
2920 <aside class="repo-skel-side">
2921 <div class="repo-skel repo-skel-side-card" />
2922 <div class="repo-skel repo-skel-side-card" />
2923 </aside>
2924 </div>
2925 <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">
2926 Loading {owner}/{repo}…
2927 </span>
2928 </Layout>
2929 );
2930 }
2931
8f50ed0Claude2932 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2933 trackByName(owner, repo, "view", {
2934 userId: user?.id || null,
2935 path: `/${owner}/${repo}`,
2936 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2937 userAgent: c.req.header("user-agent") || null,
2938 referer: c.req.header("referer") || null,
a28cedeClaude2939 }).catch((err) => {
2940 console.warn(
2941 `[web] view tracking failed for ${owner}/${repo}:`,
2942 err instanceof Error ? err.message : err
2943 );
2944 });
8f50ed0Claude2945
fc1817aClaude2946 if (!(await repoExists(owner, repo))) {
2947 return c.html(
06d5ffeClaude2948 <Layout title="Not Found" user={user}>
fc1817aClaude2949 <div class="empty-state">
2950 <h2>Repository not found</h2>
2951 <p>
2952 {owner}/{repo} does not exist.
2953 </p>
2954 </div>
2955 </Layout>,
2956 404
2957 );
2958 }
2959
05b973eClaude2960 // Parallelize all independent operations
2961 const [defaultBranch, branches] = await Promise.all([
2962 getDefaultBranch(owner, repo).then((b) => b || "main"),
2963 listBranches(owner, repo),
2964 ]);
2965 const [tree, starInfo] = await Promise.all([
2966 getTree(owner, repo, defaultBranch),
2967 // Star info fetched in parallel with tree
2968 (async () => {
2969 try {
2970 const [ownerUser] = await db
2971 .select()
2972 .from(users)
2973 .where(eq(users.username, owner))
2974 .limit(1);
71cd5ecClaude2975 if (!ownerUser)
2976 return {
2977 starCount: 0,
2978 starred: false,
2979 archived: false,
2980 isTemplate: false,
544d842Claude2981 forkCount: 0,
2982 description: null as string | null,
2983 pushedAt: null as Date | null,
2984 createdAt: null as Date | null,
cb5a796Claude2985 repoId: null as string | null,
2986 repoOwnerId: null as string | null,
71cd5ecClaude2987 };
05b973eClaude2988 const [repoRow] = await db
2989 .select()
2990 .from(repositories)
2991 .where(
2992 and(
2993 eq(repositories.ownerId, ownerUser.id),
2994 eq(repositories.name, repo)
2995 )
06d5ffeClaude2996 )
05b973eClaude2997 .limit(1);
71cd5ecClaude2998 if (!repoRow)
2999 return {
3000 starCount: 0,
3001 starred: false,
3002 archived: false,
3003 isTemplate: false,
544d842Claude3004 forkCount: 0,
3005 description: null as string | null,
3006 pushedAt: null as Date | null,
3007 createdAt: null as Date | null,
cb5a796Claude3008 repoId: null as string | null,
3009 repoOwnerId: null as string | null,
71cd5ecClaude3010 };
05b973eClaude3011 let starred = false;
06d5ffeClaude3012 if (user) {
3013 const [star] = await db
3014 .select()
3015 .from(stars)
3016 .where(
3017 and(
3018 eq(stars.userId, user.id),
3019 eq(stars.repositoryId, repoRow.id)
3020 )
3021 )
3022 .limit(1);
3023 starred = !!star;
3024 }
71cd5ecClaude3025 return {
3026 starCount: repoRow.starCount,
3027 starred,
3028 archived: repoRow.isArchived,
3029 isTemplate: repoRow.isTemplate,
544d842Claude3030 forkCount: repoRow.forkCount,
3031 description: repoRow.description as string | null,
3032 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3033 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3034 repoId: repoRow.id as string,
3035 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3036 };
05b973eClaude3037 } catch {
71cd5ecClaude3038 return {
3039 starCount: 0,
3040 starred: false,
3041 archived: false,
3042 isTemplate: false,
544d842Claude3043 forkCount: 0,
3044 description: null as string | null,
3045 pushedAt: null as Date | null,
3046 createdAt: null as Date | null,
cb5a796Claude3047 repoId: null as string | null,
3048 repoOwnerId: null as string | null,
71cd5ecClaude3049 };
06d5ffeClaude3050 }
05b973eClaude3051 })(),
3052 ]);
544d842Claude3053 const {
3054 starCount,
3055 starred,
3056 archived,
3057 isTemplate,
3058 forkCount,
3059 description,
3060 pushedAt,
3061 createdAt,
cb5a796Claude3062 repoId,
3063 repoOwnerId,
544d842Claude3064 } = starInfo;
3065
91a0204Claude3066 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3067 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3068 // and the badge simply doesn't render.
3069 let healthScore: HealthScore | null = null;
3070 if (repoId) {
3071 try {
3072 healthScore = await computeHealthScore(repoId);
3073 } catch {
3074 // swallow — badge is optional
3075 }
3076 }
3077
cb5a796Claude3078 // Pending-comments banner data (lazy + best-effort). Only the repo
3079 // owner sees the banner, so non-owner views skip the DB hit entirely.
3080 let repoHomePendingCount = 0;
3081 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3082 try {
3083 const { countPendingForRepo } = await import(
3084 "../lib/comment-moderation"
3085 );
3086 repoHomePendingCount = await countPendingForRepo(repoId);
3087 } catch {
3088 /* swallow */
3089 }
3090 }
3091
8c790e0Claude3092 // Push Watch discoverability — fetch the most recent push within 24 h.
3093 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3094 const recentPush: RecentPush | null = repoId
3095 ? await getRecentPush(repoId)
3096 : null;
3097
ebaae0fClaude3098 // AI stats strip — best-effort, fail-open to zero values.
3099 let aiStats: RepoAiStats = {
3100 mergedCount: 0,
3101 reviewCount: 0,
3102 securityAlertCount: 0,
3103 hoursSaved: "0.0",
3104 };
3105 if (repoId) {
3106 try {
3107 aiStats = await getRepoAiStats(repoId);
3108 } catch {
3109 /* swallow — show zero values */
3110 }
3111 }
3112
641aa42Claude3113 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3114 // if the DB is down the card simply won't appear. Only the owner sees it.
3115 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3116 let showOnboarding = false;
3117 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3118 try {
3119 // Check if onboarding_shown is still false (not yet dismissed)
3120 const [repoRow2] = await db
3121 .select({ onboardingShown: repositories.onboardingShown })
3122 .from(repositories)
3123 .where(eq(repositories.id, repoId))
3124 .limit(1);
3125 if (repoRow2 && !repoRow2.onboardingShown) {
3126 const [obRow] = await db
3127 .select()
3128 .from(repoOnboardingData)
3129 .where(eq(repoOnboardingData.repositoryId, repoId))
3130 .limit(1);
3131 onboardingRow = obRow ?? null;
3132 showOnboarding = !!onboardingRow;
3133 }
3134 } catch {
3135 /* swallow — onboarding is optional */
3136 }
3137 }
3138
544d842Claude3139 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3140 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3141 const repoHomeCss = `
3142 .repo-home-hero {
3143 position: relative;
3144 margin-bottom: var(--space-5);
3145 padding: var(--space-5) var(--space-6);
3146 background: var(--bg-elevated);
3147 border: 1px solid var(--border);
3148 border-radius: 16px;
3149 overflow: hidden;
3150 }
3151 .repo-home-hero::before {
3152 content: '';
3153 position: absolute;
3154 top: 0; left: 0; right: 0;
3155 height: 2px;
6fd5915Claude3156 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3157 opacity: 0.7;
3158 pointer-events: none;
3159 }
3160 .repo-home-hero-orb-wrap {
3161 position: absolute;
3162 inset: -25% -10% auto auto;
3163 width: 360px;
3164 height: 360px;
3165 pointer-events: none;
3166 z-index: 0;
3167 }
3168 .repo-home-hero-orb {
3169 position: absolute;
3170 inset: 0;
6fd5915Claude3171 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3172 filter: blur(80px);
3173 opacity: 0.7;
3174 animation: repoHomeOrb 14s ease-in-out infinite;
3175 }
3176 @keyframes repoHomeOrb {
3177 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3178 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3179 }
3180 @media (prefers-reduced-motion: reduce) {
3181 .repo-home-hero-orb { animation: none; }
3182 }
3183 .repo-home-hero-inner {
3184 position: relative;
3185 z-index: 1;
3186 }
3187 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3188 .repo-home-eyebrow {
3189 font-size: 12px;
3190 font-family: var(--font-mono);
3191 color: var(--text-muted);
3192 letter-spacing: 0.1em;
3193 text-transform: uppercase;
3194 margin-bottom: var(--space-2);
3195 }
3196 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3197 .repo-home-description {
3198 font-size: 15px;
3199 line-height: 1.55;
3200 color: var(--text);
3201 margin: 0;
3202 max-width: 720px;
3203 }
3204 .repo-home-description-empty {
3205 font-size: 14px;
3206 color: var(--text-muted);
3207 font-style: italic;
3208 margin: 0;
3209 }
3210 .repo-home-stat-row {
3211 display: flex;
3212 flex-wrap: wrap;
3213 gap: var(--space-4);
3214 margin-top: var(--space-3);
3215 font-size: 13px;
3216 color: var(--text-muted);
3217 }
3218 .repo-home-stat {
3219 display: inline-flex;
3220 align-items: center;
3221 gap: 6px;
3222 }
3223 .repo-home-stat strong {
3224 color: var(--text-strong);
3225 font-weight: 600;
3226 font-variant-numeric: tabular-nums;
3227 }
3228 .repo-home-stat .repo-home-stat-icon {
3229 color: var(--text-faint);
3230 font-size: 14px;
3231 line-height: 1;
3232 }
3233 .repo-home-stat a {
3234 color: var(--text-muted);
3235 transition: color var(--t-fast) var(--ease);
3236 }
3237 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3238
3239 /* Two-column layout: file tree + sidebar */
3240 .repo-home-grid {
3241 display: grid;
3242 grid-template-columns: minmax(0, 1fr) 280px;
3243 gap: var(--space-5);
3244 align-items: start;
3245 }
3246 @media (max-width: 960px) {
3247 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3248 }
3249 .repo-home-main { min-width: 0; }
3250
3251 /* Sidebar card */
3252 .repo-home-side {
3253 display: flex;
3254 flex-direction: column;
3255 gap: var(--space-4);
3256 }
3257 .repo-home-side-card {
3258 background: var(--bg-elevated);
3259 border: 1px solid var(--border);
3260 border-radius: 12px;
3261 padding: var(--space-4);
3262 }
3263 .repo-home-side-title {
3264 font-size: 11px;
3265 font-family: var(--font-mono);
3266 letter-spacing: 0.12em;
3267 text-transform: uppercase;
3268 color: var(--text-muted);
3269 margin: 0 0 var(--space-3);
3270 font-weight: 600;
3271 }
3272 .repo-home-side-row {
3273 display: flex;
3274 justify-content: space-between;
3275 align-items: center;
3276 gap: var(--space-2);
3277 font-size: 13px;
3278 padding: 6px 0;
3279 border-top: 1px solid var(--border);
3280 }
3281 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3282 .repo-home-side-key {
3283 color: var(--text-muted);
3284 display: inline-flex;
3285 align-items: center;
3286 gap: 6px;
3287 }
3288 .repo-home-side-val {
3289 color: var(--text-strong);
3290 font-weight: 500;
3291 font-variant-numeric: tabular-nums;
3292 max-width: 60%;
3293 text-align: right;
3294 overflow: hidden;
3295 text-overflow: ellipsis;
3296 white-space: nowrap;
3297 }
3298 .repo-home-side-val a { color: var(--text-strong); }
3299 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3300
3301 /* Clone / Code tabs */
3302 .repo-home-clone {
3303 background: var(--bg-elevated);
3304 border: 1px solid var(--border);
3305 border-radius: 12px;
3306 overflow: hidden;
3307 }
3308 .repo-home-clone-tabs {
3309 display: flex;
3310 gap: 0;
3311 background: var(--bg-secondary);
3312 border-bottom: 1px solid var(--border);
3313 padding: 0 var(--space-2);
3314 }
3315 .repo-home-clone-tab {
3316 appearance: none;
3317 background: transparent;
3318 border: 0;
3319 border-bottom: 2px solid transparent;
3320 padding: 9px 12px;
3321 font-size: 12px;
3322 font-weight: 500;
3323 color: var(--text-muted);
3324 cursor: pointer;
3325 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3326 font-family: inherit;
3327 margin-bottom: -1px;
3328 }
3329 .repo-home-clone-tab:hover { color: var(--text-strong); }
3330 .repo-home-clone-tab[aria-selected="true"] {
3331 color: var(--text-strong);
3332 border-bottom-color: var(--accent);
3333 }
3334 .repo-home-clone-body {
3335 padding: var(--space-3);
3336 display: flex;
3337 align-items: center;
3338 gap: var(--space-2);
3339 }
3340 .repo-home-clone-input {
3341 flex: 1;
3342 min-width: 0;
3343 font-family: var(--font-mono);
3344 font-size: 12px;
3345 background: var(--bg);
3346 border: 1px solid var(--border);
3347 border-radius: 8px;
3348 padding: 8px 10px;
3349 color: var(--text-strong);
3350 overflow: hidden;
3351 text-overflow: ellipsis;
3352 white-space: nowrap;
3353 }
3354 .repo-home-clone-copy {
3355 appearance: none;
3356 background: var(--bg-secondary);
3357 border: 1px solid var(--border);
3358 color: var(--text-strong);
3359 border-radius: 8px;
3360 padding: 8px 12px;
3361 font-size: 12px;
3362 font-weight: 600;
3363 cursor: pointer;
3364 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3365 font-family: inherit;
3366 }
3367 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3368 .repo-home-clone-pane { display: none; }
3369 .repo-home-clone-pane[data-active="true"] { display: flex; }
3370
3371 /* README card */
3372 .repo-home-readme {
3373 margin-top: var(--space-5);
3374 background: var(--bg-elevated);
3375 border: 1px solid var(--border);
3376 border-radius: 12px;
3377 overflow: hidden;
3378 }
3379 .repo-home-readme-head {
3380 display: flex;
3381 align-items: center;
3382 gap: 8px;
3383 padding: 10px 16px;
3384 background: var(--bg-secondary);
3385 border-bottom: 1px solid var(--border);
3386 font-size: 13px;
3387 color: var(--text-muted);
3388 }
3389 .repo-home-readme-head .repo-home-readme-icon {
3390 color: var(--accent);
3391 font-size: 14px;
3392 }
3393 .repo-home-readme-body {
3394 padding: var(--space-5) var(--space-6);
3395 }
3396
3397 /* Empty-state CTA */
3398 .repo-home-empty {
3399 position: relative;
3400 margin-top: var(--space-4);
3401 background: var(--bg-elevated);
3402 border: 1px solid var(--border);
3403 border-radius: 16px;
3404 padding: var(--space-6);
3405 overflow: hidden;
3406 }
3407 .repo-home-empty::before {
3408 content: '';
3409 position: absolute;
3410 top: 0; left: 0; right: 0;
3411 height: 2px;
6fd5915Claude3412 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3413 opacity: 0.7;
3414 pointer-events: none;
3415 }
3416 .repo-home-empty-eyebrow {
3417 font-size: 12px;
3418 font-family: var(--font-mono);
3419 color: var(--accent);
3420 letter-spacing: 0.12em;
3421 text-transform: uppercase;
3422 margin-bottom: var(--space-2);
3423 font-weight: 600;
3424 }
3425 .repo-home-empty-title {
3426 font-family: var(--font-display);
3427 font-weight: 800;
3428 letter-spacing: -0.025em;
3429 font-size: clamp(22px, 3vw, 30px);
3430 line-height: 1.1;
3431 margin: 0 0 var(--space-2);
3432 color: var(--text-strong);
3433 }
3434 .repo-home-empty-sub {
3435 color: var(--text-muted);
3436 font-size: 14px;
3437 line-height: 1.55;
3438 max-width: 640px;
3439 margin: 0 0 var(--space-4);
3440 }
3441 .repo-home-empty-snippet {
3442 background: var(--bg);
3443 border: 1px solid var(--border);
3444 border-radius: 10px;
3445 padding: var(--space-3) var(--space-4);
3446 font-family: var(--font-mono);
3447 font-size: 12.5px;
3448 line-height: 1.7;
3449 color: var(--text-strong);
3450 overflow-x: auto;
3451 white-space: pre;
3452 margin: 0;
3453 }
3454 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3455 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3456
91a0204Claude3457 /* Health score badge */
3458 .repo-health-badge {
3459 display: inline-flex;
3460 align-items: center;
3461 gap: 5px;
3462 padding: 3px 10px;
3463 border-radius: 999px;
3464 font-size: 11.5px;
3465 font-weight: 700;
3466 font-family: var(--font-mono);
3467 text-decoration: none;
3468 border: 1px solid transparent;
3469 transition: filter 120ms ease, opacity 120ms ease;
3470 vertical-align: middle;
3471 }
3472 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3473 .repo-health-badge-elite {
3474 background: rgba(52,211,153,0.15);
3475 color: #6ee7b7;
3476 border-color: rgba(52,211,153,0.30);
3477 }
3478 .repo-health-badge-strong {
3479 background: rgba(96,165,250,0.15);
3480 color: #93c5fd;
3481 border-color: rgba(96,165,250,0.30);
3482 }
3483 .repo-health-badge-improving {
3484 background: rgba(251,191,36,0.15);
3485 color: #fde68a;
3486 border-color: rgba(251,191,36,0.30);
3487 }
3488 .repo-health-badge-needs-attention {
3489 background: rgba(248,113,113,0.15);
3490 color: #fca5a5;
3491 border-color: rgba(248,113,113,0.30);
3492 }
3493
3494 /* Three-option empty-state panel */
3495 .repo-empty-options {
3496 display: grid;
3497 grid-template-columns: repeat(3, 1fr);
3498 gap: var(--space-3);
3499 margin-top: var(--space-5);
3500 }
3501 @media (max-width: 760px) {
3502 .repo-empty-options { grid-template-columns: 1fr; }
3503 }
3504 .repo-empty-option {
3505 position: relative;
3506 background: var(--bg-elevated);
3507 border: 1px solid var(--border);
3508 border-radius: 14px;
3509 padding: var(--space-4) var(--space-4);
3510 display: flex;
3511 flex-direction: column;
3512 gap: var(--space-2);
3513 overflow: hidden;
3514 transition: border-color 140ms ease, background 140ms ease;
3515 }
3516 .repo-empty-option:hover {
6fd5915Claude3517 border-color: rgba(91,110,232,0.45);
3518 background: rgba(91,110,232,0.04);
91a0204Claude3519 }
3520 .repo-empty-option::before {
3521 content: '';
3522 position: absolute;
3523 top: 0; left: 0; right: 0;
3524 height: 2px;
6fd5915Claude3525 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3526 opacity: 0.55;
3527 pointer-events: none;
3528 }
3529 .repo-empty-option-label {
3530 font-size: 10px;
3531 font-family: var(--font-mono);
3532 font-weight: 700;
3533 letter-spacing: 0.12em;
3534 text-transform: uppercase;
3535 color: var(--accent);
3536 margin-bottom: 2px;
3537 }
3538 .repo-empty-option-title {
3539 font-family: var(--font-display);
3540 font-weight: 700;
3541 font-size: 16px;
3542 letter-spacing: -0.01em;
3543 color: var(--text-strong);
3544 margin: 0;
3545 }
3546 .repo-empty-option-sub {
3547 font-size: 13px;
3548 color: var(--text-muted);
3549 line-height: 1.5;
3550 margin: 0;
3551 flex: 1;
3552 }
3553 .repo-empty-option-snippet {
3554 background: var(--bg);
3555 border: 1px solid var(--border);
3556 border-radius: 8px;
3557 padding: var(--space-2) var(--space-3);
3558 font-family: var(--font-mono);
3559 font-size: 11.5px;
3560 line-height: 1.75;
3561 color: var(--text-strong);
3562 overflow-x: auto;
3563 white-space: pre;
3564 margin: var(--space-1) 0 0;
3565 }
3566 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3567 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3568 .repo-empty-option-cta {
3569 display: inline-flex;
3570 align-items: center;
3571 gap: 6px;
3572 margin-top: auto;
3573 padding-top: var(--space-2);
3574 font-size: 13px;
3575 font-weight: 600;
3576 color: var(--accent);
3577 text-decoration: none;
3578 transition: color 120ms ease;
3579 }
3580 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3581
544d842Claude3582 @media (max-width: 720px) {
3583 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3584 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3585 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3586 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3587 .repo-home-side-val { max-width: 55%; }
3588 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3589 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3590 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3591 }
ebaae0fClaude3592
3593 /* AI stats strip */
3594 .repo-ai-stats-strip {
3595 display: flex;
3596 align-items: center;
3597 gap: 6px;
3598 margin-top: 12px;
3599 margin-bottom: 4px;
3600 font-size: 12px;
3601 color: var(--text-muted);
3602 flex-wrap: wrap;
3603 }
3604 .repo-ai-stats-strip a {
3605 color: var(--text-muted);
3606 text-decoration: underline;
3607 text-decoration-color: transparent;
3608 transition: color 120ms ease, text-decoration-color 120ms ease;
3609 }
3610 .repo-ai-stats-strip a:hover {
3611 color: var(--accent);
3612 text-decoration-color: currentColor;
3613 }
3614 .repo-ai-stats-sep {
3615 opacity: 0.4;
3616 user-select: none;
3617 }
544d842Claude3618 `;
3619 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3620 // SSH URL — port-aware:
3621 // Standard port 22 → git@host:owner/repo.git
3622 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3623 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3624 try {
3625 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3626 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3627 ? host
3628 : "localhost";
3629 const sshPort = config.sshPort;
3630 if (sshPort === 22) {
3631 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3632 } else {
3633 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3634 }
3635 } catch {
3636 // Fall through to default.
3637 }
3638 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3639 const formatRelative = (date: Date | null): string => {
3640 if (!date) return "never";
3641 const ms = Date.now() - date.getTime();
3642 const s = Math.max(0, Math.round(ms / 1000));
3643 if (s < 60) return "just now";
3644 const m = Math.round(s / 60);
3645 if (m < 60) return `${m} min ago`;
3646 const h = Math.round(m / 60);
3647 if (h < 24) return `${h}h ago`;
3648 const d = Math.round(h / 24);
3649 if (d < 30) return `${d}d ago`;
3650 const mo = Math.round(d / 30);
3651 if (mo < 12) return `${mo}mo ago`;
3652 const y = Math.round(d / 365);
3653 return `${y}y ago`;
3654 };
06d5ffeClaude3655
fc1817aClaude3656 if (tree.length === 0) {
5acce80Claude3657 const repoOgDesc = description
3658 ? `${owner}/${repo} on Gluecron — ${description}`
3659 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3660 return c.html(
5acce80Claude3661 <Layout
3662 title={`${owner}/${repo}`}
3663 user={user}
3664 description={repoOgDesc}
3665 ogTitle={`${owner}/${repo} — Gluecron`}
3666 ogDescription={repoOgDesc}
3667 twitterCard="summary"
3668 >
544d842Claude3669 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3670 <style dangerouslySetInnerHTML={{ __html: `
3671 .empty-options-grid {
3672 display: grid;
3673 grid-template-columns: repeat(3, 1fr);
3674 gap: var(--space-4);
3675 margin-top: var(--space-4);
3676 }
3677 @media (max-width: 800px) {
3678 .empty-options-grid { grid-template-columns: 1fr; }
3679 }
3680 .empty-option-card {
3681 position: relative;
3682 background: var(--bg-elevated);
3683 border: 1px solid var(--border);
3684 border-radius: 14px;
3685 padding: var(--space-5);
3686 display: flex;
3687 flex-direction: column;
3688 gap: var(--space-3);
3689 overflow: hidden;
3690 transition: border-color 140ms ease, box-shadow 140ms ease;
3691 }
3692 .empty-option-card:hover {
6fd5915Claude3693 border-color: rgba(91,110,232,0.45);
3694 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3695 }
3696 .empty-option-card::before {
3697 content: '';
3698 position: absolute;
3699 top: 0; left: 0; right: 0;
3700 height: 2px;
6fd5915Claude3701 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3702 opacity: 0.55;
3703 pointer-events: none;
3704 }
3705 .empty-option-label {
3706 font-size: 10.5px;
3707 font-family: var(--font-mono);
3708 text-transform: uppercase;
3709 letter-spacing: 0.14em;
3710 color: var(--accent);
3711 font-weight: 700;
3712 }
3713 .empty-option-title {
3714 font-family: var(--font-display);
3715 font-weight: 700;
3716 font-size: 17px;
3717 letter-spacing: -0.01em;
3718 color: var(--text-strong);
3719 margin: 0;
3720 line-height: 1.25;
3721 }
3722 .empty-option-body {
3723 font-size: 13px;
3724 color: var(--text-muted);
3725 line-height: 1.55;
3726 margin: 0;
3727 flex: 1;
3728 }
3729 .empty-option-snippet {
3730 background: var(--bg);
3731 border: 1px solid var(--border);
3732 border-radius: 8px;
3733 padding: var(--space-3) var(--space-4);
3734 font-family: var(--font-mono);
3735 font-size: 11.5px;
3736 line-height: 1.75;
3737 color: var(--text-strong);
3738 overflow-x: auto;
3739 white-space: pre;
3740 margin: 0;
3741 }
3742 .empty-option-snippet .ec { color: var(--text-faint); }
3743 .empty-option-snippet .em { color: var(--accent); }
3744 .empty-option-cta {
3745 display: inline-flex;
3746 align-items: center;
3747 gap: 6px;
3748 padding: 8px 16px;
3749 font-size: 13px;
3750 font-weight: 600;
3751 color: var(--text-strong);
3752 background: var(--bg-secondary);
3753 border: 1px solid var(--border);
3754 border-radius: 9999px;
3755 text-decoration: none;
3756 align-self: flex-start;
3757 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3758 }
3759 .empty-option-cta:hover {
6fd5915Claude3760 border-color: rgba(91,110,232,0.55);
3761 background: rgba(91,110,232,0.08);
91a0204Claude3762 color: var(--accent);
3763 text-decoration: none;
3764 }
3765 .empty-option-cta-accent {
6fd5915Claude3766 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
91a0204Claude3767 border-color: transparent;
3768 color: #fff;
3769 }
3770 .empty-option-cta-accent:hover {
3771 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3772 border-color: transparent;
3773 color: #fff;
6fd5915Claude3774 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.55);
91a0204Claude3775 }
3776 ` }} />
544d842Claude3777 <div class="repo-home-hero">
3778 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3779 <div class="repo-home-hero-orb" />
3780 </div>
3781 <div class="repo-home-hero-inner">
3782 <div class="repo-home-eyebrow">
3783 <strong>Repository</strong> · {owner}
3784 </div>
91a0204Claude3785 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3786 <RepoHeader
3787 owner={owner}
3788 repo={repo}
3789 starCount={starCount}
3790 starred={starred}
3791 forkCount={forkCount}
3792 currentUser={user?.username}
3793 archived={archived}
3794 isTemplate={isTemplate}
8c790e0Claude3795 recentPush={recentPush}
91a0204Claude3796 />
3797 {healthScore && (() => {
3798 const gradeLabel: Record<string, string> = {
3799 elite: "Elite", strong: "Strong",
3800 improving: "Improving", "needs-attention": "Needs Attention",
3801 };
3802 return (
3803 <a
3804 href={`/${owner}/${repo}/insights/health`}
3805 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3806 title={`Health score: ${healthScore!.total}/100`}
3807 >
3808 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3809 </a>
3810 );
3811 })()}
3812 </div>
544d842Claude3813 {description ? (
3814 <p class="repo-home-description">{description}</p>
3815 ) : (
3816 <p class="repo-home-description-empty">
3817 No description yet — push a README to tell the world what this
3818 ships.
3819 </p>
3820 )}
3821 </div>
3822 </div>
fc1817aClaude3823 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3824 <div style="margin-top:var(--space-4)">
3825 <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)">
3826 Getting started
3827 </div>
544d842Claude3828 <h2 class="repo-home-empty-title">
91a0204Claude3829 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3830 </h2>
3831 <p class="repo-home-empty-sub">
91a0204Claude3832 Choose how you want to kick things off. Every push wires up gate
3833 checks and AI review automatically.
544d842Claude3834 </p>
91a0204Claude3835 <div class="empty-options-grid">
3836 <div class="empty-option-card">
3837 <div class="empty-option-label">Option A</div>
3838 <h3 class="empty-option-title">Push your first commit</h3>
3839 <p class="empty-option-body">
3840 Wire up an existing project in seconds. Run these commands from
3841 your project directory.
3842 </p>
3843 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3844<span class="em">git remote add</span> origin {cloneHttpsUrl}
3845<span class="em">git branch</span> -M main
3846<span class="em">git push</span> -u origin main</pre>
3847 </div>
3848 <div class="empty-option-card">
3849 <div class="empty-option-label">Option B</div>
3850 <h3 class="empty-option-title">Import from GitHub</h3>
3851 <p class="empty-option-body">
3852 Mirror an existing GitHub repository here in one click. Gluecron
3853 syncs the full history and branches.
3854 </p>
3855 <a href="/import" class="empty-option-cta">
3856 Import repository
3857 </a>
3858 </div>
3859 <div class="empty-option-card">
3860 <div class="empty-option-label">Option C</div>
3861 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3862 <p class="empty-option-body">
3863 Let AI write your first feature. Describe what you want to build
3864 and Gluecron opens a pull request with the code.
3865 </p>
3866 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3867 Let AI write your first feature
3868 </a>
3869 </div>
3870 </div>
fc1817aClaude3871 </div>
3872 </Layout>
3873 );
3874 }
3875
3876 const readme = await getReadme(owner, repo, defaultBranch);
3877
544d842Claude3878 // Sidebar facts — derived from data we already have.
3879 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3880 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3881
5acce80Claude3882 const repoOgDesc = description
3883 ? `${owner}/${repo} on Gluecron — ${description}`
3884 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3885
fc1817aClaude3886 return c.html(
5acce80Claude3887 <Layout
3888 title={`${owner}/${repo}`}
3889 user={user}
3890 description={repoOgDesc}
3891 ogTitle={`${owner}/${repo} — Gluecron`}
3892 ogDescription={repoOgDesc}
3893 twitterCard="summary"
3894 >
544d842Claude3895 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3896 {/* Repo-context commands for the command palette (Feature 2) */}
3897 <script
3898 id="cmdk-repo-context"
3899 dangerouslySetInnerHTML={{
3900 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3901 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3902 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3903 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3904 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3905 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3906 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3907 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3908 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3909 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3910 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3911 ])};`,
3912 }}
3913 />
544d842Claude3914 <div class="repo-home-hero">
3915 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3916 <div class="repo-home-hero-orb" />
3917 </div>
3918 <div class="repo-home-hero-inner">
3919 <div class="repo-home-eyebrow">
3920 <strong>Repository</strong> · {owner}
3921 </div>
91a0204Claude3922 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3923 <RepoHeader
3924 owner={owner}
3925 repo={repo}
3926 starCount={starCount}
3927 starred={starred}
3928 forkCount={forkCount}
3929 currentUser={user?.username}
3930 archived={archived}
3931 isTemplate={isTemplate}
8c790e0Claude3932 recentPush={recentPush}
91a0204Claude3933 />
3934 {healthScore && (() => {
3935 const gradeLabel: Record<string, string> = {
3936 elite: "Elite", strong: "Strong",
3937 improving: "Improving", "needs-attention": "Needs Attention",
3938 };
3939 return (
3940 <a
3941 href={`/${owner}/${repo}/insights/health`}
3942 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3943 title={`Health score: ${healthScore!.total}/100`}
3944 >
3945 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3946 </a>
3947 );
3948 })()}
3949 </div>
544d842Claude3950 {description ? (
3951 <p class="repo-home-description">{description}</p>
3952 ) : (
3953 <p class="repo-home-description-empty">
3954 No description yet.
3955 </p>
3956 )}
3957 <div class="repo-home-stat-row" aria-label="Repository stats">
3958 <span class="repo-home-stat" title="Default branch">
3959 <span class="repo-home-stat-icon">{"⎇"}</span>
3960 <strong>{defaultBranch}</strong>
3961 </span>
3962 <a
3963 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3964 class="repo-home-stat"
3965 title="Browse all branches"
3966 >
3967 <span class="repo-home-stat-icon">{"⊢"}</span>
3968 <strong>{branches.length}</strong>{" "}
3969 branch{branches.length === 1 ? "" : "es"}
3970 </a>
3971 <span class="repo-home-stat" title="Top-level entries">
3972 <span class="repo-home-stat-icon">{"■"}</span>
3973 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3974 {dirCount > 0 && (
3975 <>
3976 {" · "}
3977 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3978 </>
3979 )}
3980 </span>
3981 {pushedAt && (
3982 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3983 <span class="repo-home-stat-icon">{"↻"}</span>
3984 Updated <strong>{formatRelative(pushedAt)}</strong>
3985 </span>
3986 )}
3987 </div>
3988 </div>
3989 </div>
71cd5ecClaude3990 {isTemplate && user && user.username !== owner && (
3991 <div
3992 class="panel"
dc26881CC LABS App3993 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3994 >
3995 <div style="font-size:13px">
3996 <strong>Template repository.</strong> Create a new repository from
3997 this template's files.
3998 </div>
3999 <form
001af43Claude4000 method="post"
71cd5ecClaude4001 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App4002 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude4003 >
4004 <input
4005 type="text"
4006 name="name"
4007 placeholder="new-repo-name"
4008 required
2c3ba6ecopilot-swe-agent[bot]4009 aria-label="New repository name"
71cd5ecClaude4010 style="width:200px"
4011 />
4012 <button type="submit" class="btn btn-primary">
4013 Use this template
4014 </button>
4015 </form>
4016 </div>
4017 )}
fc1817aClaude4018 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4019 <RepoHomePendingBanner
4020 owner={owner}
4021 repo={repo}
4022 count={repoHomePendingCount}
4023 />
641aa42Claude4024 {showOnboarding && onboardingRow && (
4025 <RepoOnboardingCard
4026 owner={owner}
4027 repo={repo}
4028 data={onboardingRow}
4029 />
4030 )}
c6018a5Claude4031 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4032 row sits just below the nav as a slim CTA strip. Scoped under
4033 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4034 <style
4035 dangerouslySetInnerHTML={{
4036 __html: `
4037 .repo-ai-cta-row {
4038 display: flex;
4039 flex-wrap: wrap;
4040 gap: 8px;
4041 margin: 12px 0 18px;
4042 padding: 10px 14px;
6fd5915Claude4043 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4044 border: 1px solid var(--border);
4045 border-radius: 10px;
4046 position: relative;
4047 overflow: hidden;
4048 }
4049 .repo-ai-cta-row::before {
4050 content: '';
4051 position: absolute;
4052 top: 0; left: 0; right: 0;
4053 height: 1px;
6fd5915Claude4054 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4055 opacity: 0.7;
4056 pointer-events: none;
4057 }
4058 .repo-ai-cta-label {
4059 font-size: 11px;
4060 font-weight: 600;
4061 letter-spacing: 0.06em;
4062 text-transform: uppercase;
4063 color: var(--accent);
4064 align-self: center;
4065 padding-right: 8px;
4066 border-right: 1px solid var(--border);
4067 margin-right: 4px;
4068 }
4069 .repo-ai-cta {
4070 display: inline-flex;
4071 align-items: center;
4072 gap: 6px;
4073 padding: 5px 10px;
4074 font-size: 12.5px;
4075 font-weight: 500;
4076 color: var(--text);
4077 background: var(--bg);
4078 border: 1px solid var(--border);
4079 border-radius: 7px;
4080 text-decoration: none;
4081 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4082 }
4083 .repo-ai-cta:hover {
6fd5915Claude4084 border-color: rgba(91,110,232,0.45);
c6018a5Claude4085 color: var(--text-strong);
6fd5915Claude4086 background: rgba(91,110,232,0.06);
c6018a5Claude4087 text-decoration: none;
4088 }
4089 .repo-ai-cta-icon {
4090 opacity: 0.75;
4091 font-size: 12px;
4092 }
4093 @media (max-width: 640px) {
4094 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4095 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4096 }
4097 `,
4098 }}
4099 />
4100 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4101 <span class="repo-ai-cta-label">AI surfaces</span>
4102 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4103 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4104 Chat
4105 </a>
4106 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4107 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4108 Previews
4109 </a>
4110 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4111 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4112 Migrations
4113 </a>
53c9249Claude4114 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4115 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4116 AI Search
c6018a5Claude4117 </a>
4118 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4119 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4120 AI release notes
4121 </a>
3646bfeClaude4122 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4123 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4124 Dev environment
4125 </a>
c6018a5Claude4126 </nav>
544d842Claude4127 <div class="repo-home-grid">
4128 <div class="repo-home-main">
4129 <BranchSwitcher
4130 owner={owner}
4131 repo={repo}
4132 currentRef={defaultBranch}
4133 branches={branches}
4134 pathType="tree"
4135 />
4136 <FileTable
4137 entries={tree}
4138 owner={owner}
4139 repo={repo}
4140 ref={defaultBranch}
4141 path=""
4142 />
ebaae0fClaude4143 {/* AI stats strip — one-line summary of AI value for this repo */}
4144 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4145 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4146 <span>{"⚡"}</span>
4147 {aiStats.mergedCount > 0 ? (
4148 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4149 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4150 </a>
4151 ) : (
4152 <span>0 AI merges this week</span>
4153 )}
4154 <span class="repo-ai-stats-sep">{"·"}</span>
4155 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4156 <span class="repo-ai-stats-sep">{"·"}</span>
4157 {aiStats.securityAlertCount > 0 ? (
4158 <a href={`/${owner}/${repo}/issues?label=security`}>
4159 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4160 </a>
4161 ) : (
4162 <span>0 open security alerts</span>
4163 )}
4164 </div>
4165 ) : (
4166 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4167 <span>{"⚡"}</span>
4168 <span>AI is watching — no activity this week</span>
4169 </div>
4170 )}
544d842Claude4171 {readme && (() => {
4172 const readmeHtml = renderMarkdown(readme);
4173 return (
4174 <div class="repo-home-readme">
4175 <div class="repo-home-readme-head">
4176 <span class="repo-home-readme-icon">{"☰"}</span>
4177 <span>README.md</span>
4178 </div>
4179 <style>{markdownCss}</style>
4180 <div class="markdown-body repo-home-readme-body">
4181 {html([readmeHtml] as unknown as TemplateStringsArray)}
4182 </div>
4183 </div>
4184 );
4185 })()}
4186 </div>
4187 <aside class="repo-home-side" aria-label="Repository details">
4188 <div class="repo-home-clone">
4189 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4190 <button
4191 type="button"
4192 class="repo-home-clone-tab"
4193 role="tab"
4194 aria-selected="true"
4195 data-pane="https"
4196 data-repo-home-clone-tab
4197 >
4198 HTTPS
4199 </button>
4200 <button
4201 type="button"
4202 class="repo-home-clone-tab"
4203 role="tab"
4204 aria-selected="false"
4205 data-pane="ssh"
4206 data-repo-home-clone-tab
4207 >
4208 SSH
4209 </button>
4210 <button
4211 type="button"
4212 class="repo-home-clone-tab"
4213 role="tab"
4214 aria-selected="false"
4215 data-pane="cli"
4216 data-repo-home-clone-tab
4217 >
4218 CLI
4219 </button>
4220 </div>
4221 <div
4222 class="repo-home-clone-pane"
4223 data-pane="https"
4224 data-active="true"
4225 role="tabpanel"
4226 >
4227 <div class="repo-home-clone-body">
4228 <input
4229 class="repo-home-clone-input"
4230 type="text"
4231 value={cloneHttpsUrl}
4232 readonly
4233 aria-label="HTTPS clone URL"
4234 data-repo-home-clone-input
4235 />
4236 <button
4237 type="button"
4238 class="repo-home-clone-copy"
4239 data-repo-home-copy={cloneHttpsUrl}
4240 >
4241 Copy
4242 </button>
4243 </div>
4244 </div>
4245 <div
4246 class="repo-home-clone-pane"
4247 data-pane="ssh"
4248 data-active="false"
4249 role="tabpanel"
4250 >
4251 <div class="repo-home-clone-body">
4252 <input
4253 class="repo-home-clone-input"
4254 type="text"
4255 value={cloneSshUrl}
4256 readonly
4257 aria-label="SSH clone URL"
4258 data-repo-home-clone-input
4259 />
4260 <button
4261 type="button"
4262 class="repo-home-clone-copy"
4263 data-repo-home-copy={cloneSshUrl}
4264 >
4265 Copy
4266 </button>
4267 </div>
4268 </div>
4269 <div
4270 class="repo-home-clone-pane"
4271 data-pane="cli"
4272 data-active="false"
4273 role="tabpanel"
4274 >
4275 <div class="repo-home-clone-body">
4276 <input
4277 class="repo-home-clone-input"
4278 type="text"
4279 value={cloneCliCmd}
4280 readonly
4281 aria-label="Gluecron CLI clone command"
4282 data-repo-home-clone-input
4283 />
4284 <button
4285 type="button"
4286 class="repo-home-clone-copy"
4287 data-repo-home-copy={cloneCliCmd}
4288 >
4289 Copy
4290 </button>
4291 </div>
79136bbClaude4292 </div>
fc1817aClaude4293 </div>
544d842Claude4294 <div class="repo-home-side-card">
4295 <h3 class="repo-home-side-title">About</h3>
4296 <div class="repo-home-side-row">
4297 <span class="repo-home-side-key">Default branch</span>
4298 <span class="repo-home-side-val">{defaultBranch}</span>
4299 </div>
4300 <div class="repo-home-side-row">
4301 <span class="repo-home-side-key">Branches</span>
4302 <span class="repo-home-side-val">
4303 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4304 {branches.length}
4305 </a>
4306 </span>
4307 </div>
4308 <div class="repo-home-side-row">
4309 <span class="repo-home-side-key">Stars</span>
4310 <span class="repo-home-side-val">{starCount}</span>
4311 </div>
4312 <div class="repo-home-side-row">
4313 <span class="repo-home-side-key">Forks</span>
4314 <span class="repo-home-side-val">{forkCount}</span>
4315 </div>
4316 {pushedAt && (
4317 <div class="repo-home-side-row">
4318 <span class="repo-home-side-key">Last push</span>
4319 <span class="repo-home-side-val">
4320 {formatRelative(pushedAt)}
4321 </span>
4322 </div>
4323 )}
4324 {createdAt && (
4325 <div class="repo-home-side-row">
4326 <span class="repo-home-side-key">Created</span>
4327 <span class="repo-home-side-val">
4328 {formatRelative(createdAt)}
4329 </span>
4330 </div>
4331 )}
4332 {(archived || isTemplate) && (
4333 <div class="repo-home-side-row">
4334 <span class="repo-home-side-key">State</span>
4335 <span class="repo-home-side-val">
4336 {archived ? "Archived" : "Template"}
4337 </span>
4338 </div>
4339 )}
4340 </div>
f1dc38bClaude4341 {/* Claude AI — quick-access card for authenticated users */}
4342 {user && (
4343 <div class="repo-home-side-card" style="margin-top:12px">
4344 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4345 ✨ Claude AI
4346 </h3>
4347 <a
4348 href={`/${owner}/${repo}/claude`}
4349 style="display:block;background:#1f6feb;color:#fff;text-align:center;padding:8px 14px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-bottom:8px"
4350 >
4351 Claude Code sessions
4352 </a>
4353 <a
4354 href={`/${owner}/${repo}/ask`}
4355 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4356 >
4357 Ask AI about this repo
4358 </a>
4359 </div>
4360 )}
544d842Claude4361 </aside>
4362 </div>
4363 <script
4364 dangerouslySetInnerHTML={{
4365 __html: `
4366 (function(){
4367 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4368 tabs.forEach(function(tab){
4369 tab.addEventListener('click', function(){
4370 var target = tab.getAttribute('data-pane');
4371 tabs.forEach(function(t){
4372 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4373 });
4374 var panes = document.querySelectorAll('.repo-home-clone-pane');
4375 panes.forEach(function(p){
4376 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4377 });
4378 });
4379 });
4380 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4381 copyBtns.forEach(function(btn){
4382 btn.addEventListener('click', function(){
4383 var text = btn.getAttribute('data-repo-home-copy') || '';
4384 var done = function(){
4385 var prev = btn.textContent;
4386 btn.textContent = 'Copied';
4387 setTimeout(function(){ btn.textContent = prev; }, 1200);
4388 };
4389 if (navigator.clipboard && navigator.clipboard.writeText) {
4390 navigator.clipboard.writeText(text).then(done, done);
4391 } else {
4392 var ta = document.createElement('textarea');
4393 ta.value = text;
4394 document.body.appendChild(ta);
4395 ta.select();
4396 try { document.execCommand('copy'); } catch (e) {}
4397 document.body.removeChild(ta);
4398 done();
4399 }
4400 });
4401 });
4402 })();
4403 `,
4404 }}
4405 />
fc1817aClaude4406 </Layout>
4407 );
4408});
4409
4410// Browse tree at ref/path
4411web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4412 const { owner, repo } = c.req.param();
06d5ffeClaude4413 const user = c.get("user");
5bb52faccanty labs4414 const gate = await assertRepoReadable(c, owner, repo);
4415 if (gate) return gate;
fc1817aClaude4416 const refAndPath = c.req.param("ref");
4417
4418 const branches = await listBranches(owner, repo);
4419 let ref = "";
4420 let treePath = "";
4421
4422 for (const branch of branches) {
4423 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4424 ref = branch;
4425 treePath = refAndPath.slice(branch.length + 1);
4426 break;
4427 }
4428 }
4429
4430 if (!ref) {
4431 const slashIdx = refAndPath.indexOf("/");
4432 if (slashIdx === -1) {
4433 ref = refAndPath;
4434 } else {
4435 ref = refAndPath.slice(0, slashIdx);
4436 treePath = refAndPath.slice(slashIdx + 1);
4437 }
4438 }
4439
4440 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4441 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4442 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4443
4444 return c.html(
06d5ffeClaude4445 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4446 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4447 <RepoHeader owner={owner} repo={repo} />
4448 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4449 <div class="tree-header">
4450 <div class="tree-header-row">
4451 <BranchSwitcher
4452 owner={owner}
4453 repo={repo}
4454 currentRef={ref}
4455 branches={branches}
4456 pathType="tree"
4457 subPath={treePath}
4458 />
4459 <div class="tree-header-stats">
4460 <span class="tree-stat" title="Entries in this directory">
4461 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4462 {dirCount > 0 && (
4463 <>
4464 {" · "}
4465 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4466 </>
4467 )}
4468 </span>
4469 <a
4470 href={`/${owner}/${repo}/search`}
4471 class="tree-stat tree-stat-link"
4472 title="Search code in this repository"
4473 >
4474 {"⌕"} Search
4475 </a>
4476 </div>
4477 </div>
4478 <div class="tree-breadcrumb-row">
4479 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4480 </div>
4481 </div>
fc1817aClaude4482 <FileTable
4483 entries={tree}
4484 owner={owner}
4485 repo={repo}
4486 ref={ref}
4487 path={treePath}
4488 />
4489 </Layout>
4490 );
4491});
4492
06d5ffeClaude4493// View file blob with syntax highlighting
fc1817aClaude4494web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4495 const { owner, repo } = c.req.param();
06d5ffeClaude4496 const user = c.get("user");
5bb52faccanty labs4497 const gate = await assertRepoReadable(c, owner, repo);
4498 if (gate) return gate;
fc1817aClaude4499 const refAndPath = c.req.param("ref");
4500
4501 const branches = await listBranches(owner, repo);
4502 let ref = "";
4503 let filePath = "";
4504
4505 for (const branch of branches) {
4506 if (refAndPath.startsWith(branch + "/")) {
4507 ref = branch;
4508 filePath = refAndPath.slice(branch.length + 1);
4509 break;
4510 }
4511 }
4512
4513 if (!ref) {
4514 const slashIdx = refAndPath.indexOf("/");
4515 if (slashIdx === -1) return c.text("Not found", 404);
4516 ref = refAndPath.slice(0, slashIdx);
4517 filePath = refAndPath.slice(slashIdx + 1);
4518 }
4519
4520 const blob = await getBlob(owner, repo, ref, filePath);
4521 if (!blob) {
4522 return c.html(
06d5ffeClaude4523 <Layout title="Not Found" user={user}>
fc1817aClaude4524 <div class="empty-state">
4525 <h2>File not found</h2>
4526 </div>
4527 </Layout>,
4528 404
4529 );
4530 }
4531
06d5ffeClaude4532 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4533 const lineCount = blob.isBinary
4534 ? 0
4535 : (blob.content.endsWith("\n")
4536 ? blob.content.split("\n").length - 1
4537 : blob.content.split("\n").length);
4538 const formatBytes = (n: number): string => {
4539 if (n < 1024) return `${n} B`;
4540 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4541 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4542 };
fc1817aClaude4543
4544 return c.html(
06d5ffeClaude4545 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4546 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4547 <RepoHeader owner={owner} repo={repo} />
4548 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4549 <div class="blob-toolbar">
4550 <BranchSwitcher
4551 owner={owner}
4552 repo={repo}
4553 currentRef={ref}
4554 branches={branches}
4555 pathType="blob"
4556 subPath={filePath}
4557 />
4558 <div class="blob-breadcrumb">
4559 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4560 </div>
4561 </div>
4562 <div class="blob-view blob-card">
4563 <div class="blob-header blob-header-polished">
4564 <div class="blob-header-meta">
4565 <span class="blob-header-icon" aria-hidden="true">
4566 {"📄"}
4567 </span>
4568 <span class="blob-header-name">{fileName}</span>
4569 <span class="blob-header-size">
4570 {formatBytes(blob.size)}
4571 {!blob.isBinary && (
4572 <>
4573 {" · "}
4574 {lineCount} line{lineCount === 1 ? "" : "s"}
4575 </>
4576 )}
4577 </span>
4578 </div>
4579 <div class="blob-header-actions">
4580 <a
4581 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4582 class="blob-pill"
4583 >
79136bbClaude4584 Raw
4585 </a>
efb11c5Claude4586 <a
4587 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4588 class="blob-pill"
4589 >
79136bbClaude4590 Blame
4591 </a>
efb11c5Claude4592 <a
4593 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4594 class="blob-pill"
4595 >
16b325cClaude4596 History
4597 </a>
0074234Claude4598 {user && (
efb11c5Claude4599 <a
4600 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4601 class="blob-pill blob-pill-accent"
4602 >
0074234Claude4603 Edit
4604 </a>
4605 )}
efb11c5Claude4606 </div>
fc1817aClaude4607 </div>
4608 {blob.isBinary ? (
efb11c5Claude4609 <div class="blob-binary">
fc1817aClaude4610 Binary file not shown.
4611 </div>
06d5ffeClaude4612 ) : (() => {
4613 const { html: highlighted, language } = highlightCode(
4614 blob.content,
4615 fileName
4616 );
4617 if (language) {
4618 return (
4619 <HighlightedCode
4620 highlightedHtml={highlighted}
efb11c5Claude4621 lineCount={lineCount}
06d5ffeClaude4622 />
4623 );
4624 }
4625 const lines = blob.content.split("\n");
4626 if (lines[lines.length - 1] === "") lines.pop();
4627 return <PlainCode lines={lines} />;
4628 })()}
fc1817aClaude4629 </div>
4630 </Layout>
4631 );
4632});
4633
398a10cClaude4634// ─── Branches list ────────────────────────────────────────────────────────
4635// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4636// author + relative time + ahead/behind vs the default branch. No DB. All
4637// data comes from git plumbing; failures degrade gracefully (counts omitted).
4638web.get("/:owner/:repo/branches", async (c) => {
4639 const { owner, repo } = c.req.param();
4640 const user = c.get("user");
5bb52faccanty labs4641 const gate = await assertRepoReadable(c, owner, repo);
4642 if (gate) return gate;
398a10cClaude4643
4644 if (!(await repoExists(owner, repo))) return c.notFound();
4645
4646 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4647 const branches = await listBranches(owner, repo);
4648 const repoDir = getRepoPath(owner, repo);
4649
4650 type BranchRow = {
4651 name: string;
4652 isDefault: boolean;
4653 sha: string;
4654 subject: string;
4655 author: string;
4656 date: string;
4657 ahead: number;
4658 behind: number;
4659 };
4660
4661 const runGit = async (args: string[]): Promise<string> => {
4662 try {
4663 const proc = Bun.spawn(["git", ...args], {
4664 cwd: repoDir,
4665 stdout: "pipe",
4666 stderr: "pipe",
4667 });
4668 const out = await new Response(proc.stdout).text();
4669 await proc.exited;
4670 return out;
4671 } catch {
4672 return "";
4673 }
4674 };
4675
4676 const meta = await runGit([
4677 "for-each-ref",
4678 "--sort=-committerdate",
4679 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4680 "refs/heads/",
4681 ]);
4682 const metaByName: Record<
4683 string,
4684 { sha: string; subject: string; author: string; date: string }
4685 > = {};
4686 for (const line of meta.split("\n").filter(Boolean)) {
4687 const [name, sha, subject, author, date] = line.split("\0");
4688 metaByName[name] = { sha, subject, author, date };
4689 }
4690
4691 const branchOrder = [...branches].sort((a, b) => {
4692 if (a === defaultBranch) return -1;
4693 if (b === defaultBranch) return 1;
4694 const aDate = metaByName[a]?.date || "";
4695 const bDate = metaByName[b]?.date || "";
4696 return bDate.localeCompare(aDate);
4697 });
4698
4699 const rows: BranchRow[] = [];
4700 for (const name of branchOrder) {
4701 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4702 let ahead = 0;
4703 let behind = 0;
4704 if (name !== defaultBranch && metaByName[defaultBranch]) {
4705 const out = await runGit([
4706 "rev-list",
4707 "--left-right",
4708 "--count",
4709 `${defaultBranch}...${name}`,
4710 ]);
4711 const parts = out.trim().split(/\s+/);
4712 if (parts.length === 2) {
4713 behind = parseInt(parts[0], 10) || 0;
4714 ahead = parseInt(parts[1], 10) || 0;
4715 }
4716 }
4717 rows.push({
4718 name,
4719 isDefault: name === defaultBranch,
4720 sha: m.sha,
4721 subject: m.subject,
4722 author: m.author,
4723 date: m.date,
4724 ahead,
4725 behind,
4726 });
4727 }
4728
4729 const relative = (iso: string): string => {
4730 if (!iso) return "—";
4731 const d = new Date(iso);
4732 if (Number.isNaN(d.getTime())) return "—";
4733 const diff = Date.now() - d.getTime();
4734 const m = Math.floor(diff / 60000);
4735 if (m < 1) return "just now";
4736 if (m < 60) return `${m} min ago`;
4737 const h = Math.floor(m / 60);
4738 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4739 const dd = Math.floor(h / 24);
4740 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4741 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4742 };
4743
4744 const success = c.req.query("success");
4745 const error = c.req.query("error");
4746
4747 return c.html(
4748 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4749 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4750 <RepoHeader owner={owner} repo={repo} />
4751 <RepoNav owner={owner} repo={repo} active="code" />
4752 <div
4753 class="branches-wrap"
eed4684Claude4754 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4755 >
4756 <header class="branches-head" style="margin-bottom:var(--space-5)">
4757 <div
4758 class="branches-eyebrow"
4759 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"
4760 >
4761 <span
4762 class="branches-eyebrow-dot"
4763 aria-hidden="true"
6fd5915Claude4764 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)"
398a10cClaude4765 />
4766 Repository · Branches
4767 </div>
4768 <h1
4769 class="branches-title"
4770 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)"
4771 >
4772 <span class="gradient-text">{rows.length}</span>{" "}
4773 branch{rows.length === 1 ? "" : "es"}
4774 </h1>
4775 <p
4776 class="branches-sub"
4777 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4778 >
4779 All work-in-progress lines for{" "}
4780 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4781 counts are relative to{" "}
4782 <code style="font-size:12.5px">{defaultBranch}</code>.
4783 </p>
4784 </header>
4785
4786 {success && (
4787 <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">
4788 {decodeURIComponent(success)}
4789 </div>
4790 )}
4791 {error && (
4792 <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">
4793 {decodeURIComponent(error)}
4794 </div>
4795 )}
4796
4797 {rows.length === 0 ? (
4798 <div class="commits-empty">
4799 <div class="commits-empty-orb" aria-hidden="true" />
4800 <div class="commits-empty-inner">
4801 <div class="commits-empty-icon" aria-hidden="true">
4802 <svg
4803 width="22"
4804 height="22"
4805 viewBox="0 0 24 24"
4806 fill="none"
4807 stroke="currentColor"
4808 stroke-width="2"
4809 stroke-linecap="round"
4810 stroke-linejoin="round"
4811 >
4812 <line x1="6" y1="3" x2="6" y2="15" />
4813 <circle cx="18" cy="6" r="3" />
4814 <circle cx="6" cy="18" r="3" />
4815 <path d="M18 9a9 9 0 0 1-9 9" />
4816 </svg>
4817 </div>
4818 <h3 class="commits-empty-title">No branches yet</h3>
4819 <p class="commits-empty-sub">
4820 Push your first commit to create the default branch.
4821 </p>
4822 </div>
4823 </div>
4824 ) : (
4825 <div class="branches-list">
4826 {rows.map((r) => (
4827 <div class="branches-row">
4828 <div class="branches-row-icon" aria-hidden="true">
4829 <svg
4830 width="14"
4831 height="14"
4832 viewBox="0 0 24 24"
4833 fill="none"
4834 stroke="currentColor"
4835 stroke-width="2"
4836 stroke-linecap="round"
4837 stroke-linejoin="round"
4838 >
4839 <line x1="6" y1="3" x2="6" y2="15" />
4840 <circle cx="18" cy="6" r="3" />
4841 <circle cx="6" cy="18" r="3" />
4842 <path d="M18 9a9 9 0 0 1-9 9" />
4843 </svg>
4844 </div>
4845 <div class="branches-row-main">
4846 <div class="branches-row-name">
4847 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4848 {r.isDefault && (
4849 <span
4850 class="branches-row-default"
4851 title="Default branch"
4852 >
4853 Default
4854 </span>
4855 )}
4856 </div>
4857 <div class="branches-row-meta">
4858 {r.subject ? (
4859 <>
4860 <strong>{r.author || "—"}</strong>
4861 <span class="sep">·</span>
4862 <span
4863 title={r.date ? new Date(r.date).toISOString() : ""}
4864 >
4865 updated {relative(r.date)}
4866 </span>
4867 {r.sha && (
4868 <>
4869 <span class="sep">·</span>
4870 <a
4871 href={`/${owner}/${repo}/commit/${r.sha}`}
4872 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4873 >
4874 {r.sha.slice(0, 7)}
4875 </a>
4876 </>
4877 )}
4878 </>
4879 ) : (
4880 <span>No commit metadata</span>
4881 )}
4882 </div>
4883 </div>
4884 <div class="branches-row-side">
4885 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4886 <span
4887 class="branches-row-divergence"
4888 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4889 >
4890 <span class="ahead">{r.ahead} ahead</span>
4891 <span style="opacity:0.4">|</span>
4892 <span class="behind">{r.behind} behind</span>
4893 </span>
4894 )}
4895 <div class="branches-row-actions">
4896 <a
4897 href={`/${owner}/${repo}/commits/${r.name}`}
4898 class="branches-btn"
4899 title="View commits on this branch"
4900 >
4901 Commits
4902 </a>
4903 {!r.isDefault &&
4904 user &&
4905 user.username === owner && (
4906 <form
4907 method="post"
4908 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4909 style="margin:0"
4910 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4911 >
4912 <button
4913 type="submit"
4914 class="branches-btn branches-btn-danger"
4915 >
4916 Delete
4917 </button>
4918 </form>
4919 )}
4920 </div>
4921 </div>
4922 </div>
4923 ))}
4924 </div>
4925 )}
4926 </div>
4927 </Layout>
4928 );
4929});
4930
4931// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4932// that are not merged into the default branch — matches the explicit
4933// confirmation on the row's delete button.
4934web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4935 const { owner, repo } = c.req.param();
4936 const branchName = decodeURIComponent(c.req.param("name"));
4937 const user = c.get("user")!;
4938
4939 // Owner-only check (mirrors collaborators.tsx pattern).
4940 const [ownerRow] = await db
4941 .select()
4942 .from(users)
4943 .where(eq(users.username, owner))
4944 .limit(1);
4945 if (!ownerRow || ownerRow.id !== user.id) {
4946 return c.redirect(
4947 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4948 );
4949 }
4950
4951 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4952 if (branchName === defaultBranch) {
4953 return c.redirect(
4954 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4955 );
4956 }
4957
4958 const branches = await listBranches(owner, repo);
4959 if (!branches.includes(branchName)) {
4960 return c.redirect(
4961 `/${owner}/${repo}/branches?error=Branch+not+found`
4962 );
4963 }
4964
4965 try {
4966 const repoDir = getRepoPath(owner, repo);
4967 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4968 cwd: repoDir,
4969 stdout: "pipe",
4970 stderr: "pipe",
4971 });
4972 await proc.exited;
4973 if (proc.exitCode !== 0) {
4974 return c.redirect(
4975 `/${owner}/${repo}/branches?error=Delete+failed`
4976 );
4977 }
4978 } catch {
4979 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4980 }
4981
4982 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4983});
4984
4985// ─── Tags list ────────────────────────────────────────────────────────────
4986// Pulls from `listTags` (sorted newest first). For each tag we look up an
4987// associated release row so the "View release" CTA can deep-link directly
4988// without making the user hunt for it.
4989web.get("/:owner/:repo/tags", async (c) => {
4990 const { owner, repo } = c.req.param();
4991 const user = c.get("user");
5bb52faccanty labs4992 const gate = await assertRepoReadable(c, owner, repo);
4993 if (gate) return gate;
398a10cClaude4994
4995 if (!(await repoExists(owner, repo))) return c.notFound();
4996
4997 const tags = await listTags(owner, repo);
4998
4999 // Map tags -> releases. Best-effort; releases table may not exist in
5000 // every test setup, so any error falls through with an empty set.
5001 const tagsWithReleases = new Set<string>();
5002 try {
5003 const [ownerRow] = await db
5004 .select()
5005 .from(users)
5006 .where(eq(users.username, owner))
5007 .limit(1);
5008 if (ownerRow) {
5009 const [repoRow] = await db
5010 .select()
5011 .from(repositories)
5012 .where(
5013 and(
5014 eq(repositories.ownerId, ownerRow.id),
5015 eq(repositories.name, repo)
5016 )
5017 )
5018 .limit(1);
5019 if (repoRow) {
5020 // Raw SQL so we don't need to import the releases schema here.
5021 const result = await db.execute(
5022 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5023 );
5024 const rows: any[] = (result as any).rows || (result as any) || [];
5025 for (const row of rows) {
5026 const tag = row?.tag;
5027 if (typeof tag === "string") tagsWithReleases.add(tag);
5028 }
5029 }
5030 }
5031 } catch {
5032 // No releases table or DB error — leave set empty.
5033 }
5034
5035 const relative = (iso: string): string => {
5036 if (!iso) return "—";
5037 const d = new Date(iso);
5038 if (Number.isNaN(d.getTime())) return "—";
5039 const diff = Date.now() - d.getTime();
5040 const m = Math.floor(diff / 60000);
5041 if (m < 1) return "just now";
5042 if (m < 60) return `${m} min ago`;
5043 const h = Math.floor(m / 60);
5044 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5045 const dd = Math.floor(h / 24);
5046 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5047 return d.toLocaleDateString("en-US", {
5048 month: "short",
5049 day: "numeric",
5050 year: "numeric",
5051 });
5052 };
5053
5054 return c.html(
5055 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5056 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5057 <RepoHeader owner={owner} repo={repo} />
5058 <RepoNav owner={owner} repo={repo} active="code" />
5059 <div
5060 class="tags-wrap"
eed4684Claude5061 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5062 >
5063 <header class="tags-head" style="margin-bottom:var(--space-5)">
5064 <div
5065 class="tags-eyebrow"
5066 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"
5067 >
5068 <span
5069 class="tags-eyebrow-dot"
5070 aria-hidden="true"
6fd5915Claude5071 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)"
398a10cClaude5072 />
5073 Repository · Tags
5074 </div>
5075 <h1
5076 class="tags-title"
5077 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)"
5078 >
5079 <span class="gradient-text">{tags.length}</span>{" "}
5080 tag{tags.length === 1 ? "" : "s"}
5081 </h1>
5082 <p
5083 class="tags-sub"
5084 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5085 >
5086 Named points in the history — typically releases, milestones,
5087 or shipped versions. Click a tag to browse the tree at that
5088 revision.
5089 </p>
5090 </header>
5091
5092 {tags.length === 0 ? (
5093 <div class="commits-empty">
5094 <div class="commits-empty-orb" aria-hidden="true" />
5095 <div class="commits-empty-inner">
5096 <div class="commits-empty-icon" aria-hidden="true">
5097 <svg
5098 width="22"
5099 height="22"
5100 viewBox="0 0 24 24"
5101 fill="none"
5102 stroke="currentColor"
5103 stroke-width="2"
5104 stroke-linecap="round"
5105 stroke-linejoin="round"
5106 >
5107 <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" />
5108 <line x1="7" y1="7" x2="7.01" y2="7" />
5109 </svg>
5110 </div>
5111 <h3 class="commits-empty-title">No tags yet</h3>
5112 <p class="commits-empty-sub">
5113 Tag a commit to mark a release or milestone. From the CLI:{" "}
5114 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5115 </p>
5116 </div>
5117 </div>
5118 ) : (
5119 <div class="tags-list">
5120 {tags.map((t) => {
5121 const hasRelease = tagsWithReleases.has(t.name);
5122 return (
5123 <div class="tags-row">
5124 <div class="tags-row-icon" aria-hidden="true">
5125 <svg
5126 width="14"
5127 height="14"
5128 viewBox="0 0 24 24"
5129 fill="none"
5130 stroke="currentColor"
5131 stroke-width="2"
5132 stroke-linecap="round"
5133 stroke-linejoin="round"
5134 >
5135 <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" />
5136 <line x1="7" y1="7" x2="7.01" y2="7" />
5137 </svg>
5138 </div>
5139 <div class="tags-row-main">
5140 <div class="tags-row-name">
5141 <a
5142 href={`/${owner}/${repo}/tree/${t.name}`}
5143 style="text-decoration:none"
5144 >
5145 <span class="tags-row-version">{t.name}</span>
5146 </a>
5147 </div>
5148 <div class="tags-row-meta">
5149 <span
5150 title={t.date ? new Date(t.date).toISOString() : ""}
5151 >
5152 Tagged {relative(t.date)}
5153 </span>
5154 <span class="sep">·</span>
5155 <a
5156 href={`/${owner}/${repo}/commit/${t.sha}`}
5157 class="tags-row-sha"
5158 title={t.sha}
5159 >
5160 {t.sha.slice(0, 7)}
5161 </a>
5162 </div>
5163 </div>
5164 <div class="tags-row-side">
5165 <a
5166 href={`/${owner}/${repo}/tree/${t.name}`}
5167 class="tags-row-link"
5168 >
5169 Browse files
5170 </a>
5171 {hasRelease && (
5172 <a
5173 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5174 class="tags-row-link"
6fd5915Claude5175 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5176 >
5177 View release
5178 </a>
5179 )}
5180 </div>
5181 </div>
5182 );
5183 })}
5184 </div>
5185 )}
5186 </div>
5187 </Layout>
5188 );
5189});
5190
fc1817aClaude5191// Commit log
5192web.get("/:owner/:repo/commits/:ref?", async (c) => {
5193 const { owner, repo } = c.req.param();
06d5ffeClaude5194 const user = c.get("user");
5bb52faccanty labs5195 const gate = await assertRepoReadable(c, owner, repo);
5196 if (gate) return gate;
fc1817aClaude5197 const ref =
5198 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5199 const branches = await listBranches(owner, repo);
fc1817aClaude5200
5201 const commits = await listCommits(owner, repo, ref, 50);
5202
3951454Claude5203 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5204 // Also resolve repoId here so we can query the recent push for the
5205 // Push Watch live/watch indicator in the header.
3951454Claude5206 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5207 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5208 try {
5209 const [ownerRow] = await db
5210 .select()
5211 .from(users)
5212 .where(eq(users.username, owner))
5213 .limit(1);
5214 if (ownerRow) {
5215 const [repoRow] = await db
5216 .select()
5217 .from(repositories)
5218 .where(
5219 and(
5220 eq(repositories.ownerId, ownerRow.id),
5221 eq(repositories.name, repo)
5222 )
5223 )
5224 .limit(1);
8c790e0Claude5225 if (repoRow) {
5226 // Fetch verifications and recent push in parallel.
5227 const [verRows, rp] = await Promise.all([
5228 commits.length > 0
5229 ? db
5230 .select()
5231 .from(commitVerifications)
5232 .where(
5233 and(
5234 eq(commitVerifications.repositoryId, repoRow.id),
5235 inArray(
5236 commitVerifications.commitSha,
5237 commits.map((c) => c.sha)
5238 )
5239 )
5240 )
5241 : Promise.resolve([]),
5242 getRecentPush(repoRow.id),
5243 ]);
5244 for (const r of verRows) {
3951454Claude5245 verifications[r.commitSha] = {
5246 verified: r.verified,
5247 reason: r.reason,
5248 };
5249 }
8c790e0Claude5250 commitsPageRecentPush = rp;
3951454Claude5251 }
5252 }
5253 } catch {
5254 // DB unavailable — skip the badges gracefully.
5255 }
5256
fc1817aClaude5257 return c.html(
06d5ffeClaude5258 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5259 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5260 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5261 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5262 <div class="commits-hero">
5263 <div class="commits-hero-orb-wrap" aria-hidden="true">
5264 <div class="commits-hero-orb" />
fc1817aClaude5265 </div>
efb11c5Claude5266 <div class="commits-hero-inner">
5267 <div class="commits-eyebrow">
5268 <strong>History</strong> · {owner}/{repo}
5269 </div>
5270 <h1 class="commits-title">
5271 <span class="gradient-text">{commits.length}</span> recent commit
5272 {commits.length === 1 ? "" : "s"} on{" "}
5273 <span class="commits-branch">{ref}</span>
5274 </h1>
5275 <p class="commits-sub">
5276 Browse the project's history. Click any commit to see the full
5277 diff, AI review notes, and signature status.
5278 </p>
5279 </div>
5280 </div>
5281 <div class="commits-toolbar">
5282 <BranchSwitcher
3951454Claude5283 owner={owner}
5284 repo={repo}
efb11c5Claude5285 currentRef={ref}
5286 branches={branches}
5287 pathType="commits"
3951454Claude5288 />
398a10cClaude5289 <div class="commits-toolbar-actions">
5290 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5291 {"⊢"} Branches
5292 </a>
5293 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5294 {"#"} Tags
5295 </a>
5296 </div>
efb11c5Claude5297 </div>
5298 {commits.length === 0 ? (
5299 <div class="commits-empty">
398a10cClaude5300 <div class="commits-empty-orb" aria-hidden="true" />
5301 <div class="commits-empty-inner">
5302 <div class="commits-empty-icon" aria-hidden="true">
5303 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5304 <circle cx="12" cy="12" r="4" />
5305 <line x1="1.05" y1="12" x2="7" y2="12" />
5306 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5307 </svg>
5308 </div>
5309 <h3 class="commits-empty-title">No commits yet</h3>
5310 <p class="commits-empty-sub">
5311 This branch is empty. Push your first commit, or use the
5312 web editor to create a file.
5313 </p>
5314 </div>
efb11c5Claude5315 </div>
5316 ) : (
5317 <div class="commits-list-wrap">
398a10cClaude5318 {(() => {
5319 // Group commits by day for the section headers.
5320 const dayLabel = (iso: string): string => {
5321 const d = new Date(iso);
5322 if (Number.isNaN(d.getTime())) return "Unknown";
5323 const today = new Date();
5324 const yesterday = new Date(today);
5325 yesterday.setDate(today.getDate() - 1);
5326 const sameDay = (a: Date, b: Date) =>
5327 a.getFullYear() === b.getFullYear() &&
5328 a.getMonth() === b.getMonth() &&
5329 a.getDate() === b.getDate();
5330 if (sameDay(d, today)) return "Today";
5331 if (sameDay(d, yesterday)) return "Yesterday";
5332 return d.toLocaleDateString("en-US", {
5333 month: "long",
5334 day: "numeric",
5335 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5336 });
5337 };
5338 const relative = (iso: string): string => {
5339 const d = new Date(iso);
5340 if (Number.isNaN(d.getTime())) return "";
5341 const diff = Date.now() - d.getTime();
5342 const m = Math.floor(diff / 60000);
5343 if (m < 1) return "just now";
5344 if (m < 60) return `${m} min ago`;
5345 const h = Math.floor(m / 60);
5346 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5347 const dd = Math.floor(h / 24);
5348 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5349 return d.toLocaleDateString("en-US", {
5350 month: "short",
5351 day: "numeric",
5352 });
5353 };
5354 const initial = (name: string): string =>
5355 (name || "?").trim().charAt(0).toUpperCase() || "?";
5356 // Build groups preserving order.
5357 const groups: Array<{ label: string; items: typeof commits }> = [];
5358 let lastLabel = "";
5359 for (const cm of commits) {
5360 const label = dayLabel(cm.date);
5361 if (label !== lastLabel) {
5362 groups.push({ label, items: [] });
5363 lastLabel = label;
5364 }
5365 groups[groups.length - 1].items.push(cm);
5366 }
5367 return groups.map((g) => (
5368 <>
5369 <div class="commits-day-head">
5370 <span class="commits-day-head-dot" aria-hidden="true" />
5371 Commits on {g.label}
5372 </div>
5373 {g.items.map((cm) => {
5374 const v = verifications[cm.sha];
5375 return (
5376 <div class="commits-row">
5377 <div class="commits-avatar" aria-hidden="true">
5378 {initial(cm.author)}
5379 </div>
5380 <div class="commits-row-body">
5381 <div class="commits-row-msg">
5382 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5383 {cm.message}
5384 </a>
5385 {v?.verified && (
5386 <span
5387 class="commits-row-verified"
5388 title="Signed with a registered key"
5389 >
5390 Verified
5391 </span>
5392 )}
5393 </div>
5394 <div class="commits-row-meta">
5395 <strong>{cm.author}</strong>
5396 <span class="sep">·</span>
5397 <span
5398 class="commits-row-time"
5399 title={new Date(cm.date).toISOString()}
5400 >
5401 committed {relative(cm.date)}
5402 </span>
5403 {cm.parentShas.length > 1 && (
5404 <>
5405 <span class="sep">·</span>
5406 <span>merge of {cm.parentShas.length} parents</span>
5407 </>
5408 )}
5409 </div>
5410 </div>
5411 <div class="commits-row-side">
5412 <a
5413 href={`/${owner}/${repo}/commit/${cm.sha}`}
5414 class="commits-row-sha"
5415 title={cm.sha}
5416 >
5417 {cm.sha.slice(0, 7)}
5418 </a>
8c790e0Claude5419 <a
5420 href={`/${owner}/${repo}/push/${cm.sha}`}
5421 class="commits-row-watch"
5422 title="Watch gate + deploy results for this push"
5423 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5424 >
5425 <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">
5426 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5427 <circle cx="12" cy="12" r="3" />
5428 </svg>
5429 </a>
398a10cClaude5430 <button
5431 type="button"
5432 class="commits-row-copy"
5433 data-copy-sha={cm.sha}
5434 title="Copy full SHA"
5435 aria-label={`Copy SHA ${cm.sha}`}
5436 >
5437 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5438 <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" />
5439 <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" />
5440 </svg>
5441 </button>
5442 </div>
5443 </div>
5444 );
5445 })}
5446 </>
5447 ));
5448 })()}
efb11c5Claude5449 </div>
fc1817aClaude5450 )}
398a10cClaude5451 <script
5452 dangerouslySetInnerHTML={{
5453 __html: `
5454 (function(){
5455 document.addEventListener('click', function(e){
5456 var t = e.target; if (!t) return;
5457 var btn = t.closest && t.closest('[data-copy-sha]');
5458 if (!btn) return;
5459 e.preventDefault();
5460 var sha = btn.getAttribute('data-copy-sha') || '';
5461 if (!navigator.clipboard) return;
5462 navigator.clipboard.writeText(sha).then(function(){
5463 btn.classList.add('is-copied');
5464 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5465 }).catch(function(){});
5466 });
5467 })();
5468 `,
5469 }}
5470 />
fc1817aClaude5471 </Layout>
5472 );
5473});
5474
5475// Single commit with diff
5476web.get("/:owner/:repo/commit/:sha", async (c) => {
5477 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5478 const user = c.get("user");
5bb52faccanty labs5479 const gate = await assertRepoReadable(c, owner, repo);
5480 if (gate) return gate;
fc1817aClaude5481
05b973eClaude5482 // Fetch commit, full message, and diff in parallel
5483 const [commit, fullMessage, diffResult] = await Promise.all([
5484 getCommit(owner, repo, sha),
5485 getCommitFullMessage(owner, repo, sha),
5486 getDiff(owner, repo, sha),
5487 ]);
fc1817aClaude5488 if (!commit) {
5489 return c.html(
06d5ffeClaude5490 <Layout title="Not Found" user={user}>
fc1817aClaude5491 <div class="empty-state">
5492 <h2>Commit not found</h2>
5493 </div>
5494 </Layout>,
5495 404
5496 );
5497 }
5498
3951454Claude5499 // Block J3 — try to verify this commit's signature.
5500 let verification:
5501 | { verified: boolean; reason: string; signatureType: string | null }
5502 | null = null;
0cdfd89Claude5503 // Block J8 — external CI commit statuses rollup.
5504 let statusCombined:
5505 | {
5506 state: "pending" | "success" | "failure";
5507 total: number;
5508 contexts: Array<{
5509 context: string;
5510 state: string;
5511 description: string | null;
5512 targetUrl: string | null;
5513 }>;
5514 }
5515 | null = null;
3951454Claude5516 try {
5517 const [ownerRow] = await db
5518 .select()
5519 .from(users)
5520 .where(eq(users.username, owner))
5521 .limit(1);
5522 if (ownerRow) {
5523 const [repoRow] = await db
5524 .select()
5525 .from(repositories)
5526 .where(
5527 and(
5528 eq(repositories.ownerId, ownerRow.id),
5529 eq(repositories.name, repo)
5530 )
5531 )
5532 .limit(1);
5533 if (repoRow) {
5534 const { verifyCommit } = await import("../lib/signatures");
5535 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5536 verification = {
5537 verified: v.verified,
5538 reason: v.reason,
5539 signatureType: v.signatureType,
5540 };
0cdfd89Claude5541 try {
5542 const { combinedStatus } = await import("../lib/commit-statuses");
5543 const combined = await combinedStatus(repoRow.id, commit.sha);
5544 if (combined.total > 0) {
5545 statusCombined = {
5546 state: combined.state as any,
5547 total: combined.total,
5548 contexts: combined.contexts.map((c) => ({
5549 context: c.context,
5550 state: c.state,
5551 description: c.description,
5552 targetUrl: c.targetUrl,
5553 })),
5554 };
5555 }
5556 } catch {
5557 statusCombined = null;
5558 }
3951454Claude5559 }
5560 }
5561 } catch {
5562 verification = null;
5563 }
5564
05b973eClaude5565 const { files, raw } = diffResult;
fc1817aClaude5566
efb11c5Claude5567 // Diff stats: count additions / deletions across all files for the
5568 // header summary bar. Computed here from the parsed diff so we don't
5569 // touch the DiffView component.
5570 let additions = 0;
5571 let deletions = 0;
5572 for (const f of files) {
5573 const hunks = (f as any).hunks as Array<any> | undefined;
5574 if (Array.isArray(hunks)) {
5575 for (const h of hunks) {
5576 const lines = (h?.lines || []) as Array<any>;
5577 for (const ln of lines) {
5578 const t = ln?.type || ln?.kind;
5579 if (t === "add" || t === "added" || t === "+") additions += 1;
5580 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5581 deletions += 1;
5582 }
5583 }
5584 }
5585 }
5586 // Fall back: scan raw if file-level counting yielded zero (it's just a
5587 // header polish — never let a parsing miss break the page).
5588 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5589 for (const line of raw.split("\n")) {
5590 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5591 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5592 }
5593 }
5594 const fileCount = files.length;
5595
fc1817aClaude5596 return c.html(
06d5ffeClaude5597 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5598 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5599 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5600 <div class="commit-detail-card">
5601 <div class="commit-detail-eyebrow">
5602 <strong>Commit</strong>
5603 <span class="commit-detail-sha-pill" title={commit.sha}>
5604 {commit.sha.slice(0, 7)}
5605 </span>
3951454Claude5606 {verification && verification.reason !== "unsigned" && (
5607 <span
efb11c5Claude5608 class={`commit-detail-verify ${
3951454Claude5609 verification.verified
efb11c5Claude5610 ? "commit-detail-verify-ok"
5611 : "commit-detail-verify-warn"
3951454Claude5612 }`}
5613 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5614 >
5615 {verification.verified ? "Verified" : verification.reason}
5616 </span>
5617 )}
fc1817aClaude5618 </div>
efb11c5Claude5619 <h1 class="commit-detail-title">{commit.message}</h1>
5620 {fullMessage !== commit.message && (
5621 <pre class="commit-detail-body">{fullMessage}</pre>
5622 )}
5623 <div class="commit-detail-meta">
5624 <span class="commit-detail-author">
5625 <strong>{commit.author}</strong> committed on{" "}
5626 {new Date(commit.date).toLocaleDateString("en-US", {
5627 month: "long",
5628 day: "numeric",
5629 year: "numeric",
5630 })}
5631 </span>
fc1817aClaude5632 {commit.parentShas.length > 0 && (
efb11c5Claude5633 <span class="commit-detail-parents">
5634 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5635 {commit.parentShas.map((p, idx) => (
5636 <>
5637 {idx > 0 && " "}
5638 <a
5639 href={`/${owner}/${repo}/commit/${p}`}
5640 class="commit-detail-sha-link"
5641 >
5642 {p.slice(0, 7)}
5643 </a>
5644 </>
fc1817aClaude5645 ))}
5646 </span>
5647 )}
5648 </div>
efb11c5Claude5649 <div class="commit-detail-stats">
5650 <span class="commit-detail-stat">
5651 <strong>{fileCount}</strong>{" "}
5652 file{fileCount === 1 ? "" : "s"} changed
5653 </span>
5654 <span class="commit-detail-stat commit-detail-stat-add">
5655 <span class="commit-detail-stat-mark">+</span>
5656 <strong>{additions}</strong>
5657 </span>
5658 <span class="commit-detail-stat commit-detail-stat-del">
5659 <span class="commit-detail-stat-mark">−</span>
5660 <strong>{deletions}</strong>
5661 </span>
5662 <span class="commit-detail-sha-full" title="Full SHA">
5663 {commit.sha}
5664 </span>
5665 </div>
0cdfd89Claude5666 {statusCombined && (
efb11c5Claude5667 <div class="commit-detail-checks">
5668 <div class="commit-detail-checks-head">
5669 <strong>Checks</strong>
5670 <span class="commit-detail-checks-summary">
5671 {statusCombined.total} total ·{" "}
5672 <span
5673 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5674 >
5675 {statusCombined.state}
5676 </span>
0cdfd89Claude5677 </span>
efb11c5Claude5678 </div>
5679 <div class="commit-detail-check-row">
0cdfd89Claude5680 {statusCombined.contexts.map((cx) => (
5681 <span
efb11c5Claude5682 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5683 title={cx.description || cx.context}
5684 >
5685 {cx.targetUrl ? (
efb11c5Claude5686 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5687 {cx.context}: {cx.state}
5688 </a>
5689 ) : (
5690 <>
5691 {cx.context}: {cx.state}
5692 </>
5693 )}
5694 </span>
5695 ))}
5696 </div>
5697 </div>
5698 )}
fc1817aClaude5699 </div>
ea9ed4cClaude5700 <DiffView
5701 raw={raw}
5702 files={files}
5703 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5704 />
fc1817aClaude5705 </Layout>
5706 );
5707});
5708
79136bbClaude5709// Raw file download
5710web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5711 const { owner, repo } = c.req.param();
5bb52faccanty labs5712 const gate = await assertRepoReadable(c, owner, repo);
5713 if (gate) return gate;
79136bbClaude5714 const refAndPath = c.req.param("ref");
5715
5716 const branches = await listBranches(owner, repo);
5717 let ref = "";
5718 let filePath = "";
5719
5720 for (const branch of branches) {
5721 if (refAndPath.startsWith(branch + "/")) {
5722 ref = branch;
5723 filePath = refAndPath.slice(branch.length + 1);
5724 break;
5725 }
5726 }
5727
5728 if (!ref) {
5729 const slashIdx = refAndPath.indexOf("/");
5730 if (slashIdx === -1) return c.text("Not found", 404);
5731 ref = refAndPath.slice(0, slashIdx);
5732 filePath = refAndPath.slice(slashIdx + 1);
5733 }
5734
5735 const data = await getRawBlob(owner, repo, ref, filePath);
5736 if (!data) return c.text("Not found", 404);
5737
5738 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5739 return new Response(data as BodyInit, {
79136bbClaude5740 headers: {
5741 "Content-Type": "application/octet-stream",
5742 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5743 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5744 },
5745 });
5746});
5747
5748// Blame view
5749web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5750 const { owner, repo } = c.req.param();
5751 const user = c.get("user");
5bb52faccanty labs5752 const gate = await assertRepoReadable(c, owner, repo);
5753 if (gate) return gate;
79136bbClaude5754 const refAndPath = c.req.param("ref");
5755
5756 const branches = await listBranches(owner, repo);
5757 let ref = "";
5758 let filePath = "";
5759
5760 for (const branch of branches) {
5761 if (refAndPath.startsWith(branch + "/")) {
5762 ref = branch;
5763 filePath = refAndPath.slice(branch.length + 1);
5764 break;
5765 }
5766 }
5767
5768 if (!ref) {
5769 const slashIdx = refAndPath.indexOf("/");
5770 if (slashIdx === -1) return c.text("Not found", 404);
5771 ref = refAndPath.slice(0, slashIdx);
5772 filePath = refAndPath.slice(slashIdx + 1);
5773 }
5774
5775 const blameLines = await getBlame(owner, repo, ref, filePath);
5776 if (blameLines.length === 0) {
5777 return c.html(
5778 <Layout title="Not Found" user={user}>
5779 <div class="empty-state">
5780 <h2>File not found</h2>
5781 </div>
5782 </Layout>,
5783 404
5784 );
5785 }
5786
5787 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5788 // Unique contributors (by author) tracked once for the header chip.
5789 const blameAuthors = new Set<string>();
5790 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5791
5792 return c.html(
5793 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5794 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5795 <RepoHeader owner={owner} repo={repo} />
5796 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5797 <header class="blame-head">
5798 <div class="blame-eyebrow">
5799 <span class="blame-eyebrow-dot" aria-hidden="true" />
5800 Blame · Line-by-line history
5801 </div>
5802 <h1 class="blame-title">
5803 <code>{fileName}</code>
5804 </h1>
5805 <p class="blame-sub">
5806 Each line is annotated with the commit that last touched it.
5807 Click any SHA to jump to that commit and see the surrounding
5808 change.
5809 </p>
5810 </header>
efb11c5Claude5811 <div class="blame-toolbar">
5812 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5813 </div>
398a10cClaude5814 <div class="blame-card">
5815 <div class="blame-header">
efb11c5Claude5816 <div class="blame-header-meta">
5817 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5818 <span class="blame-header-name">{fileName}</span>
5819 <span class="blame-header-tag">Blame</span>
5820 <span class="blame-header-stats">
5821 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5822 {blameAuthors.size} contributor
5823 {blameAuthors.size === 1 ? "" : "s"}
5824 </span>
5825 </div>
5826 <div class="blame-header-actions">
5827 <a
5828 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5829 class="blob-pill"
5830 >
5831 Normal view
5832 </a>
5833 <a
5834 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5835 class="blob-pill"
5836 >
5837 Raw
5838 </a>
5839 </div>
79136bbClaude5840 </div>
398a10cClaude5841 <div style="overflow-x:auto">
5842 <table class="blame-table">
79136bbClaude5843 <tbody>
5844 {blameLines.map((line, i) => {
5845 const showInfo =
5846 i === 0 || blameLines[i - 1].sha !== line.sha;
5847 return (
398a10cClaude5848 <tr class={showInfo ? "blame-row-first" : ""}>
5849 <td class="blame-gutter">
79136bbClaude5850 {showInfo && (
398a10cClaude5851 <span class="blame-gutter-inner">
79136bbClaude5852 <a
5853 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5854 class="blame-gutter-sha"
5855 title={`Commit ${line.sha}`}
79136bbClaude5856 >
5857 {line.sha.slice(0, 7)}
398a10cClaude5858 </a>
5859 <span class="blame-gutter-author" title={line.author}>
5860 {line.author}
5861 </span>
5862 </span>
79136bbClaude5863 )}
5864 </td>
398a10cClaude5865 <td class="blame-line-num">{line.lineNum}</td>
5866 <td class="blame-line-content">{line.content}</td>
79136bbClaude5867 </tr>
5868 );
5869 })}
5870 </tbody>
5871 </table>
5872 </div>
5873 </div>
5874 </Layout>
5875 );
5876});
5877
53c9249Claude5878// Search — keyword + optional Claude semantic mode
79136bbClaude5879web.get("/:owner/:repo/search", async (c) => {
5880 const { owner, repo } = c.req.param();
5881 const user = c.get("user");
5bb52faccanty labs5882 const gate = await assertRepoReadable(c, owner, repo);
5883 if (gate) return gate;
79136bbClaude5884 const q = c.req.query("q") || "";
53c9249Claude5885 const aiAvailable = isAiAvailable();
5886 // Default to semantic when Claude is available and no explicit mode set.
5887 const modeParam = c.req.query("mode");
5888 const mode: "semantic" | "keyword" =
5889 modeParam === "keyword"
5890 ? "keyword"
5891 : modeParam === "semantic"
5892 ? "semantic"
5893 : aiAvailable
5894 ? "semantic"
5895 : "keyword";
5896 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5897
5898 if (!(await repoExists(owner, repo))) return c.notFound();
5899
5900 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5901
5902 // Keyword results (always available as fallback / when in keyword mode)
5903 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5904 // Semantic results (Claude-powered)
5905 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5906 let semanticHits: SemanticHit[] = [];
5907 let semanticMode: "semantic" | "keyword" = "semantic";
5908 let quotaExceeded = false;
79136bbClaude5909
5910 if (q.trim()) {
53c9249Claude5911 if (isSemantic) {
5912 // Resolve repo DB id for caching
5913 const [ownerRow] = await db
5914 .select({ id: users.id })
5915 .from(users)
5916 .where(eq(users.username, owner))
5917 .limit(1);
5918 const [repoRow] = ownerRow
5919 ? await db
5920 .select({ id: repositories.id })
5921 .from(repositories)
5922 .where(
5923 and(
5924 eq(repositories.ownerId, ownerRow.id),
5925 eq(repositories.name, repo)
5926 )
5927 )
5928 .limit(1)
5929 : [];
5930
5931 const rateLimitKey = user
5932 ? `user:${user.id}`
5933 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5934
5935 const searchResult = await claudeSemanticSearch(
5936 owner,
5937 repo,
5938 repoRow?.id ?? `${owner}/${repo}`,
5939 q.trim(),
5940 { branch: defaultBranch, rateLimitKey }
5941 );
5942 semanticHits = searchResult.results;
5943 semanticMode = searchResult.mode;
5944 quotaExceeded = searchResult.quotaExceeded;
5945 } else {
5946 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5947 }
79136bbClaude5948 }
5949
53c9249Claude5950 const aiSearchCss = `
5951 /* ─── Semantic search mode toggle ─── */
5952 .search-mode-bar {
5953 display: flex;
5954 align-items: center;
5955 gap: 8px;
5956 margin-bottom: 14px;
5957 flex-wrap: wrap;
5958 }
5959 .search-mode-label {
5960 font-size: 12.5px;
5961 color: var(--text-muted);
5962 font-weight: 500;
5963 }
5964 .search-mode-toggle {
5965 display: inline-flex;
5966 background: var(--bg-elevated);
5967 border: 1px solid var(--border);
5968 border-radius: 9999px;
5969 padding: 3px;
5970 gap: 2px;
5971 }
5972 .search-mode-btn {
5973 display: inline-flex;
5974 align-items: center;
5975 gap: 5px;
5976 padding: 5px 12px;
5977 border-radius: 9999px;
5978 font-size: 12.5px;
5979 font-weight: 500;
5980 color: var(--text-muted);
5981 text-decoration: none;
5982 transition: color 120ms ease, background 120ms ease;
5983 cursor: pointer;
5984 border: none;
5985 background: none;
5986 font: inherit;
5987 }
5988 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5989 .search-mode-btn.active {
6fd5915Claude5990 background: rgba(91,110,232,0.15);
53c9249Claude5991 color: var(--text-strong);
5992 }
5993 .search-mode-ai-badge {
5994 display: inline-flex;
5995 align-items: center;
5996 gap: 4px;
5997 padding: 2px 8px;
5998 border-radius: 9999px;
5999 font-size: 11px;
6000 font-weight: 600;
6fd5915Claude6001 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude6002 color: #c4b5fd;
6fd5915Claude6003 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude6004 margin-left: 2px;
6005 }
6006 .search-quota-warn {
6007 font-size: 12.5px;
6008 color: var(--text-muted);
6009 padding: 6px 12px;
6010 background: rgba(255,200,0,0.06);
6011 border: 1px solid rgba(255,200,0,0.20);
6012 border-radius: 8px;
6013 }
6014
6015 /* ─── Semantic result cards ─── */
6016 .sem-results { display: flex; flex-direction: column; gap: 10px; }
6017 .sem-result {
6018 padding: 14px 16px;
6019 background: var(--bg-elevated);
6020 border: 1px solid var(--border);
6021 border-radius: 12px;
6022 transition: border-color 120ms ease;
6023 }
6024 .sem-result:hover { border-color: var(--border-strong); }
6025 .sem-result-head {
6026 display: flex;
6027 align-items: flex-start;
6028 justify-content: space-between;
6029 gap: 10px;
6030 flex-wrap: wrap;
6031 margin-bottom: 6px;
6032 }
6033 .sem-result-path {
6034 font-family: var(--font-mono);
6035 font-size: 13px;
6036 font-weight: 600;
6037 color: var(--text-strong);
6038 text-decoration: none;
6039 word-break: break-all;
6040 }
6041 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6042 .sem-result-conf {
6043 display: inline-flex;
6044 align-items: center;
6045 gap: 4px;
6046 padding: 2px 9px;
6047 border-radius: 9999px;
6048 font-size: 11.5px;
6049 font-weight: 600;
6050 white-space: nowrap;
6051 flex-shrink: 0;
6052 }
6053 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
6fd5915Claude6054 .sem-conf-possible { background: rgba(91,110,232,0.12); color: #5b6ee8; border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6055 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6056 .sem-result-reason {
6057 font-size: 12.5px;
6058 color: var(--text-muted);
6059 margin-bottom: 8px;
6060 line-height: 1.5;
6061 }
6062 .sem-result-snippet {
6063 margin: 0;
6064 padding: 10px 12px;
6065 background: rgba(0,0,0,0.22);
6066 border: 1px solid var(--border);
6067 border-radius: 8px;
6068 font-family: var(--font-mono);
6069 font-size: 12px;
6070 line-height: 1.55;
6071 color: var(--text);
6072 overflow-x: auto;
6073 white-space: pre-wrap;
6074 word-break: break-word;
6075 max-height: 240px;
6076 overflow-y: auto;
6077 }
6078 `;
6079
6080 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6081 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6082
79136bbClaude6083 return c.html(
6084 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6085 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6086 <RepoHeader owner={owner} repo={repo} />
6087 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6088 <div class="search-hero">
6089 <div class="search-eyebrow">
6090 <strong>Search</strong> · {owner}/{repo}
6091 </div>
6092 <h1 class="search-title">
53c9249Claude6093 {isSemantic ? (
6094 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6095 ) : (
6096 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6097 )}
efb11c5Claude6098 </h1>
6099 <form
6100 method="get"
6101 action={`/${owner}/${repo}/search`}
6102 class="search-form"
6103 role="search"
6104 >
53c9249Claude6105 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6106 <div class="search-input-wrap">
6107 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6108 <input
6109 type="text"
6110 name="q"
6111 value={q}
53c9249Claude6112 placeholder={
6113 isSemantic
6114 ? "Ask a question or describe what you're looking for…"
6115 : "Search code on the default branch…"
6116 }
efb11c5Claude6117 aria-label="Search code"
6118 class="search-input"
6119 autocomplete="off"
6120 autofocus
6121 />
6122 </div>
6123 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6124 Search
6125 </button>
efb11c5Claude6126 </form>
6127 </div>
53c9249Claude6128
6129 {/* Mode toggle */}
6130 <div class="search-mode-bar">
6131 <span class="search-mode-label">Mode:</span>
6132 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6133 {aiAvailable ? (
6134 <a
6135 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6136 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6137 aria-pressed={isSemantic ? "true" : "false"}
6138 >
6139 {"✨"} Semantic AI
6140 <span class="search-mode-ai-badge">AI-powered</span>
6141 </a>
6142 ) : null}
6143 <a
6144 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6145 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6146 aria-pressed={!isSemantic ? "true" : "false"}
6147 >
6148 {"⌕"} Keyword
6149 </a>
6150 </div>
6151 {isSemantic && aiAvailable && (
6152 <span style="font-size:12px;color:var(--text-muted)">
6153 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6154 </span>
53c9249Claude6155 )}
6156 {quotaExceeded && (
6157 <span class="search-quota-warn">
6158 Semantic search quota reached — showing keyword results
efb11c5Claude6159 </span>
53c9249Claude6160 )}
6161 </div>
6162
6163 {/* ─── Semantic results ─── */}
6164 {isSemantic && q && (
6165 <>
6166 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6167 <div class="search-quota-warn" style="margin-bottom:10px">
6168 Falling back to keyword search — Claude found no relevant files.
6169 </div>
6170 )}
6171 {semanticHits.length === 0 ? (
6172 <div class="search-empty">
6173 <p>
6174 No matches for <strong>"{q}"</strong>.{" "}
6175 Try a different phrasing or{" "}
6176 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6177 switch to keyword search
6178 </a>.
6179 </p>
6180 </div>
6181 ) : (
6182 <>
6183 <div class="search-results-head">
6184 <span class="search-results-count">
6185 <strong>{semanticHits.length}</strong> result
6186 {semanticHits.length !== 1 ? "s" : ""}
6187 </span>
6188 <span class="search-results-query">
6189 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6190 <span class="search-results-q">"{q}"</span>
6191 </span>
6192 </div>
6193 <div class="sem-results">
6194 {semanticHits.map((h) => {
6195 const confClass =
6196 h.confidence > 0.7
6197 ? "sem-conf-strong"
6198 : h.confidence > 0.4
6199 ? "sem-conf-possible"
6200 : "sem-conf-weak";
6201 const confLabel =
6202 h.confidence > 0.7
6203 ? "Strong match"
6204 : h.confidence > 0.4
6205 ? "Possible match"
6206 : "Weak match";
6207 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6208 const preview =
6209 h.snippet.length > 800
6210 ? h.snippet.slice(0, 800) + "\n…"
6211 : h.snippet;
6212 return (
6213 <div class="sem-result">
6214 <div class="sem-result-head">
6215 <a href={href} class="sem-result-path">
6216 {h.file}
6217 {h.lineNumber ? (
6218 <span style="color:var(--text-muted);font-weight:500">
6219 :{h.lineNumber}
6220 </span>
6221 ) : null}
6222 </a>
6223 <span class={`sem-result-conf ${confClass}`}>
6224 {confLabel}
6225 </span>
6226 </div>
6227 {h.reason && (
6228 <p class="sem-result-reason">{h.reason}</p>
6229 )}
6230 {preview && (
6231 <pre class="sem-result-snippet">{preview}</pre>
6232 )}
6233 </div>
6234 );
6235 })}
6236 </div>
6237 </>
6238 )}
6239 </>
79136bbClaude6240 )}
53c9249Claude6241
6242 {/* ─── Keyword results ─── */}
6243 {!isSemantic && q && (
6244 <>
6245 <div class="search-results-head">
6246 <span class="search-results-count">
6247 <strong>{keywordResults.length}</strong> result
6248 {keywordResults.length !== 1 ? "s" : ""}
6249 </span>
6250 <span class="search-results-query">
6251 for <span class="search-results-q">"{q}"</span> on{" "}
6252 <code>{defaultBranch}</code>
6253 </span>
6254 </div>
6255 {keywordResults.length === 0 ? (
6256 <div class="search-empty">
6257 <p>
6258 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6259 {aiAvailable && (
6260 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6261 try AI semantic search
79136bbClaude6262 </a>
53c9249Claude6263 )}.
6264 </p>
6265 </div>
6266 ) : (
6267 <div class="search-results">
6268 {(() => {
6269 const grouped: Record<
6270 string,
6271 Array<{ lineNum: number; line: string }>
6272 > = {};
6273 for (const r of keywordResults) {
6274 if (!grouped[r.file]) grouped[r.file] = [];
6275 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6276 }
6277 return Object.entries(grouped).map(([file, matches]) => (
6278 <div class="search-file diff-file">
6279 <div class="search-file-head diff-file-header">
6280 <a
6281 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6282 class="search-file-link"
6283 >
6284 {file}
6285 </a>
6286 <span class="search-file-count">
6287 {matches.length} match{matches.length === 1 ? "" : "es"}
6288 </span>
6289 </div>
6290 <div class="blob-code">
6291 <table>
6292 <tbody>
6293 {matches.map((m) => (
6294 <tr>
6295 <td class="line-num">{m.lineNum}</td>
6296 <td class="line-content">{m.line}</td>
6297 </tr>
6298 ))}
6299 </tbody>
6300 </table>
6301 </div>
6302 </div>
6303 ));
6304 })()}
6305 </div>
6306 )}
6307 </>
6308 )}
79136bbClaude6309 </Layout>
6310 );
6311});
6312
fc1817aClaude6313export default web;