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.tsxBlame6307 lines · 4 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";
ea52715copilot-swe-agent[bot]10import { config } from "../lib/config";
3951454Claude11import {
12 users,
13 repositories,
14 stars,
15 commitVerifications,
8c790e0Claude16 activityFeed,
ebaae0fClaude17 pullRequests,
18 prComments,
19 issues,
20 labels,
21 issueLabels,
641aa42Claude22 repoOnboardingData,
3951454Claude23} from "../db/schema";
fc1817aClaude24import { Layout } from "../views/layout";
cb5a796Claude25import { PendingCommentsBanner as RepoHomePendingBanner } from "../views/pending-comments-banner";
fc1817aClaude26import {
27 RepoHeader,
28 RepoNav,
29 Breadcrumb,
30 FileTable,
06d5ffeClaude31 RepoCard,
32 BranchSwitcher,
33 HighlightedCode,
34 PlainCode,
8c790e0Claude35 type RecentPush,
fc1817aClaude36} from "../views/components";
ea9ed4cClaude37import { DiffView } from "../views/diff-view";
fc1817aClaude38import {
39 getTree,
40 getBlob,
41 listCommits,
42 getCommit,
43 getCommitFullMessage,
44 getDiff,
45 getReadme,
46 getDefaultBranch,
47 listBranches,
398a10cClaude48 listTags,
fc1817aClaude49 repoExists,
06d5ffeClaude50 initBareRepo,
79136bbClaude51 getBlame,
52 getRawBlob,
53 searchCode,
398a10cClaude54 getRepoPath,
fc1817aClaude55} from "../git/repository";
79136bbClaude56import { renderMarkdown, markdownCss } from "../lib/markdown";
06d5ffeClaude57import { highlightCode } from "../lib/highlight";
91a0204Claude58import { computeHealthScore } from "../lib/health-score";
59import type { HealthScore } from "../lib/health-score";
06d5ffeClaude60import { softAuth, requireAuth } from "../middleware/auth";
61import type { AuthEnv } from "../middleware/auth";
5bb52faccanty labs62import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
53c9249Claude63import { claudeSemanticSearch } from "../lib/claude-semantic-search";
64import { isAiAvailable } from "../lib/ai-client";
8f50ed0Claude65import { trackByName } from "../lib/traffic";
534f04aClaude66import { LandingPage, type LandingLiveFeed } from "../views/landing";
29924bcClaude67import { Landing2030Page } from "../views/landing-2030";
f8e5feaccanty labs68import { LandingProPage } from "../views/landing-pro";
52ad8b1Claude69import { computePublicStats, type PublicStats } from "../lib/public-stats";
534f04aClaude70import {
71 listQueuedAiBuildIssues,
72 listRecentAutoMerges,
73 listRecentAiReviews,
74 countAiReviewsSince,
75 listDemoActivityFeed,
76} from "../lib/demo-activity";
11c3ab6ccanty labs77import { AgentWorkspace } from "../views/agent-workspace";
78import { DailyBrief } from "../views/daily-brief";
79import { TrustReport } from "../views/trust-report";
80import { ProductionLayers } from "../views/production-layers";
81import { DistributionView } from "../views/distribution";
82import { OrgMemory } from "../views/org-memory";
fc1817aClaude83
06d5ffeClaude84const web = new Hono<AuthEnv>();
85
86// Soft auth on all web routes — c.get("user") available but may be null
87web.use("*", softAuth);
fc1817aClaude88
5bb52faccanty labs89// ---------------------------------------------------------------------------
90// SECURITY: repo-content access gate.
91//
92// Every route that serves repository CONTENT (file tree, blobs, raw files,
93// blame, commits, diffs, branches, tags, code search, repo overview) MUST call
94// this before touching git-on-disk. Public repos resolve to "read" for
95// anonymous viewers (so public browsing stays fully anonymous); private repos
96// require a logged-in viewer with at least read access.
97//
98// Returns a 404 Response when the repo is missing OR the viewer lacks read
99// access — deliberately 404 (not 403) so we never leak the existence of a
100// private repo. Returns `null` when access is granted; callers proceed as
101// normal. The owner-username → user → repositories lookup mirrors the pattern
102// used by `requireRepoAccess` in ../middleware/repo-access.
103// ---------------------------------------------------------------------------
104async function assertRepoReadable(
105 c: any,
106 owner: string,
107 repo: string
108): Promise<Response | null> {
109 const user = c.get("user") ?? null;
110
111 const notFound = () =>
112 c.html(
113 <Layout title="Not Found" user={user}>
114 <div class="empty-state">
115 <h2>Repository not found</h2>
116 <p>
117 {owner}/{repo} does not exist.
118 </p>
119 </div>
120 </Layout>,
121 404
122 );
123
6b75ac1ccanty labs124 // Explicit dev/test bypass: when no database is configured at all there are
125 // no persisted repositories to protect (private repos only exist as DB rows),
126 // and the browse routes render purely from git-on-disk. This is a positively
127 // established condition (DATABASE_URL unset), NOT an error inference — so the
128 // access gate below can safely fail CLOSED on any runtime error. In
129 // production DATABASE_URL is always set, so this branch never runs there.
130 if (!config.databaseUrl) return null;
131
5bb52faccanty labs132 try {
133 const [ownerRow] = await db
134 .select({ id: users.id })
135 .from(users)
136 .where(eq(users.username, owner))
137 .limit(1);
138 if (!ownerRow) return notFound();
139
140 const [repoRow] = await db
141 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
142 .from(repositories)
143 .where(
144 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
145 )
146 .limit(1);
147 if (!repoRow) return notFound();
148
149 const access = await resolveRepoAccess({
150 repoId: repoRow.id,
151 userId: user?.id ?? null,
152 isPublic: !repoRow.isPrivate,
153 });
154 // Positive denial: the repo exists and the viewer lacks read access.
155 // Return 404 (not 403) so we never confirm a private repo's existence.
156 if (!satisfiesAccess(access, "read")) return notFound();
157
158 return null;
159 } catch (err) {
6b75ac1ccanty labs160 // Fail CLOSED. A DB is configured (checked above) but the privacy-flag
161 // lookup errored, so we cannot positively establish that this repo is
162 // public. Denying is the only safe choice for an access-control gate —
163 // failing open here would re-expose private repos during a DB hiccup,
164 // which is the exact vulnerability this function exists to close.
5bb52faccanty labs165 console.warn(
6b75ac1ccanty labs166 `[web] assertRepoReadable: access check failed for ${owner}/${repo} — denying:`,
5bb52faccanty labs167 err instanceof Error ? err.message : err
168 );
6b75ac1ccanty labs169 return notFound();
5bb52faccanty labs170 }
171}
172
ebaae0fClaude173// ---------------------------------------------------------------------------
174// Repo AI stats — computed once per repo home page load, best-effort.
175// Fast because every WHERE clause is bounded to a 7-day window.
176// ---------------------------------------------------------------------------
177interface RepoAiStats {
178 mergedCount: number;
179 reviewCount: number;
180 securityAlertCount: number;
181 hoursSaved: string;
182}
183
184async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
185 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
186
187 // 1. AI-merged PRs this week: activity_feed entries with action =
188 // 'auto_merge.merged' in the last 7 days for this repo.
189 // This is cheaper than a JOIN on pull_requests and covers both the
190 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
191 const [mergedRow] = await db
192 .select({ n: count() })
193 .from(activityFeed)
194 .where(
195 and(
196 eq(activityFeed.repositoryId, repoId),
197 eq(activityFeed.action, "auto_merge.merged"),
198 gte(activityFeed.createdAt, since)
199 )
200 );
201 const mergedCount = Number(mergedRow?.n ?? 0);
202
203 // 2. AI reviews this week: pr_comments where is_ai_review = true in
204 // the last 7 days, joined through pull_requests to scope to this repo.
205 const [reviewRow] = await db
206 .select({ n: count() })
207 .from(prComments)
208 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
209 .where(
210 and(
211 eq(pullRequests.repositoryId, repoId),
212 eq(prComments.isAiReview, true),
213 gte(prComments.createdAt, since)
214 )
215 );
216 const reviewCount = Number(reviewRow?.n ?? 0);
217
218 // 3. Open security alerts: open issues that carry a label whose name
219 // contains 'security' (case-insensitive) for this repo.
220 const [secRow] = await db
221 .select({ n: count() })
222 .from(issues)
223 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
224 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
225 .where(
226 and(
227 eq(issues.repositoryId, repoId),
228 eq(issues.state, "open"),
229 sql`lower(${labels.name}) like '%security%'`
230 )
231 );
232 const securityAlertCount = Number(secRow?.n ?? 0);
233
234 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
235 const hours = mergedCount * 1.5 + reviewCount * 0.5;
236 const hoursSaved = hours.toFixed(1);
237
238 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
239}
240
8c790e0Claude241/**
242 * Query the most recent push to a repo from the activity_feed table.
243 * Returns a RecentPush if there's been a push in the last 24 hours,
244 * or null otherwise. Used by the RepoHeader live/watch indicator.
245 *
246 * Queries activity_feed where action='push' and targetId holds the commit SHA.
247 */
248async function getRecentPush(repoId: string): Promise<RecentPush | null> {
249 try {
250 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
251 const [row] = await db
252 .select({
253 targetId: activityFeed.targetId,
254 createdAt: activityFeed.createdAt,
255 })
256 .from(activityFeed)
257 .where(
258 and(
259 eq(activityFeed.repositoryId, repoId),
260 eq(activityFeed.action, "push"),
261 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
262 )
263 )
264 .orderBy(desc(activityFeed.createdAt))
265 .limit(1);
266 if (!row || !row.targetId) return null;
267 return {
268 sha: row.targetId,
269 ageMs: Date.now() - new Date(row.createdAt).getTime(),
270 };
271 } catch {
272 // DB unavailable or schema mismatch — degrade gracefully.
273 return null;
274 }
275}
276
efb11c5Claude277/**
278 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
279 *
280 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
281 * scope is route-local and `layout.tsx` is locked. Each polished handler
282 * injects this via a `<style>` tag; the rules are namespaced by surface
283 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
284 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
285 * `.repo-home-*` styling Agent A already shipped.
286 */
287const codeBrowseCss = `
288 /* ───────── shared primitives ───────── */
289 .cb-hairline::before,
290 .new-repo-hero::before,
291 .profile-hero::before,
292 .commits-hero::before,
293 .commit-detail-card::before,
294 .search-hero::before {
295 content: '';
296 position: absolute;
297 top: 0; left: 0; right: 0;
298 height: 2px;
6fd5915Claude299 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude300 opacity: 0.7;
301 pointer-events: none;
302 }
303 @keyframes cbHeroOrb {
304 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
305 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
306 }
307 @media (prefers-reduced-motion: reduce) {
308 .new-repo-hero-orb,
309 .profile-hero-orb,
310 .commits-hero-orb { animation: none; }
311 }
312
313 /* ───────── new-repo ───────── */
314 .new-repo-hero {
315 position: relative;
316 margin-bottom: var(--space-5);
317 padding: var(--space-5) var(--space-6);
318 background: var(--bg-elevated);
319 border: 1px solid var(--border);
320 border-radius: 16px;
321 overflow: hidden;
322 }
323 .new-repo-hero-orb-wrap {
324 position: absolute;
325 inset: -25% -10% auto auto;
326 width: 360px;
327 height: 360px;
328 pointer-events: none;
329 z-index: 0;
330 }
331 .new-repo-hero-orb {
332 position: absolute;
333 inset: 0;
6fd5915Claude334 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude335 filter: blur(80px);
336 opacity: 0.7;
337 animation: cbHeroOrb 14s ease-in-out infinite;
338 }
339 .new-repo-hero-inner { position: relative; z-index: 1; }
340 .new-repo-eyebrow {
341 font-size: 12px;
342 font-family: var(--font-mono);
343 color: var(--text-muted);
344 letter-spacing: 0.1em;
345 text-transform: uppercase;
346 margin-bottom: var(--space-2);
347 }
348 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
349 .new-repo-title {
350 font-family: var(--font-display);
351 font-weight: 800;
352 letter-spacing: -0.028em;
353 font-size: clamp(28px, 4vw, 40px);
354 line-height: 1.05;
355 margin: 0 0 var(--space-2);
356 color: var(--text-strong);
357 }
358 .new-repo-sub {
359 font-size: 15px;
360 color: var(--text-muted);
361 margin: 0;
362 line-height: 1.55;
363 max-width: 620px;
364 }
365 .new-repo-form {
366 max-width: 680px;
367 }
368 .new-repo-error {
369 background: rgba(218, 54, 51, 0.12);
370 border: 1px solid rgba(218, 54, 51, 0.35);
371 color: #ffb3b3;
372 padding: 10px 14px;
373 border-radius: 10px;
374 margin-bottom: var(--space-4);
375 font-size: 14px;
376 }
377 .new-repo-form-grid {
378 display: flex;
379 flex-direction: column;
380 gap: var(--space-4);
381 }
382 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
383 .new-repo-label {
384 font-size: 13px;
385 color: var(--text-strong);
386 font-weight: 600;
387 }
388 .new-repo-label-optional {
389 color: var(--text-muted);
390 font-weight: 400;
391 font-size: 12px;
392 }
393 .new-repo-input {
394 appearance: none;
395 width: 100%;
396 background: var(--bg);
397 border: 1px solid var(--border);
398 color: var(--text-strong);
399 border-radius: 10px;
400 padding: 10px 12px;
401 font-size: 14px;
402 font-family: inherit;
403 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
404 }
405 .new-repo-input:focus {
406 outline: none;
407 border-color: var(--accent);
6fd5915Claude408 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude409 }
410 .new-repo-input-disabled {
411 color: var(--text-muted);
412 background: var(--bg-secondary);
413 cursor: not-allowed;
414 }
415 .new-repo-hint {
416 font-size: 12px;
417 color: var(--text-muted);
418 margin: 4px 0 0;
419 }
420 .new-repo-hint code {
421 font-family: var(--font-mono);
422 font-size: 11.5px;
423 background: var(--bg-secondary);
424 border: 1px solid var(--border);
425 border-radius: 5px;
426 padding: 1px 5px;
427 color: var(--text-strong);
428 }
429 .new-repo-visibility {
430 display: grid;
431 grid-template-columns: 1fr 1fr;
432 gap: var(--space-2);
433 }
434 @media (max-width: 600px) {
435 .new-repo-visibility { grid-template-columns: 1fr; }
436 }
437 .new-repo-vis-card {
438 display: flex;
439 gap: 10px;
440 padding: 14px;
441 background: var(--bg);
442 border: 1px solid var(--border);
443 border-radius: 12px;
444 cursor: pointer;
445 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
446 }
447 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
448 .new-repo-vis-card:has(input:checked) {
6fd5915Claude449 border-color: rgba(91,110,232,0.55);
450 background: rgba(91,110,232,0.06);
451 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude452 }
453 .new-repo-vis-radio {
454 margin-top: 3px;
455 accent-color: var(--accent);
456 }
457 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
458 .new-repo-vis-label {
459 font-size: 14px;
460 font-weight: 600;
461 color: var(--text-strong);
462 }
463 .new-repo-vis-desc {
464 font-size: 12.5px;
465 color: var(--text-muted);
466 line-height: 1.45;
467 }
468 .new-repo-callout {
469 margin-top: var(--space-2);
470 padding: var(--space-3) var(--space-4);
6fd5915Claude471 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
472 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude473 border-radius: 12px;
474 }
475 .new-repo-callout-eyebrow {
476 font-size: 11px;
477 font-family: var(--font-mono);
478 text-transform: uppercase;
479 letter-spacing: 0.12em;
480 color: var(--accent);
481 font-weight: 700;
482 margin-bottom: 4px;
483 }
484 .new-repo-callout-body {
485 font-size: 13px;
486 color: var(--text);
487 line-height: 1.5;
488 margin: 0;
489 }
490 .new-repo-callout-body code {
491 font-family: var(--font-mono);
492 font-size: 12px;
493 color: var(--accent);
6fd5915Claude494 background: rgba(91,110,232,0.1);
efb11c5Claude495 border-radius: 4px;
496 padding: 1px 5px;
497 }
398a10cClaude498 .new-repo-templates {
499 display: flex;
500 flex-wrap: wrap;
501 gap: 8px;
502 margin-top: 4px;
503 }
504 .new-repo-template-chip {
505 position: relative;
506 display: inline-flex;
507 align-items: center;
508 gap: 6px;
509 padding: 8px 14px;
510 background: var(--bg);
511 border: 1px solid var(--border);
512 border-radius: 999px;
513 font-size: 13px;
514 color: var(--text);
515 cursor: pointer;
516 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
517 }
518 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
519 .new-repo-template-chip:hover {
520 border-color: var(--border-strong, var(--border));
521 color: var(--text-strong);
522 }
523 .new-repo-template-chip:has(input:checked) {
6fd5915Claude524 border-color: rgba(91,110,232,0.55);
525 background: rgba(91,110,232,0.10);
398a10cClaude526 color: var(--text-strong);
6fd5915Claude527 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude528 }
529 .new-repo-template-chip-dot {
530 width: 8px; height: 8px;
531 border-radius: 999px;
532 background: var(--text-faint, var(--text-muted));
533 transition: background 140ms ease, box-shadow 140ms ease;
534 }
535 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude536 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
537 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude538 }
efb11c5Claude539 .new-repo-actions {
540 display: flex;
541 gap: var(--space-2);
542 margin-top: var(--space-2);
398a10cClaude543 align-items: center;
544 }
545 .new-repo-submit {
546 min-width: 180px;
6fd5915Claude547 border: 1px solid rgba(91,110,232,0.45);
548 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude549 color: #fff;
550 font-weight: 700;
6fd5915Claude551 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude552 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
553 }
554 .new-repo-submit:hover {
555 transform: translateY(-1px);
6fd5915Claude556 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude557 filter: brightness(1.06);
558 }
559 .new-repo-submit:focus-visible {
6fd5915Claude560 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude561 outline-offset: 2px;
efb11c5Claude562 }
563
564 /* ───────── profile ───────── */
565 .profile-hero {
566 position: relative;
567 margin-bottom: var(--space-5);
568 padding: var(--space-5) var(--space-6);
569 background: var(--bg-elevated);
570 border: 1px solid var(--border);
571 border-radius: 16px;
572 overflow: hidden;
573 }
574 .profile-hero-orb-wrap {
575 position: absolute;
576 inset: -25% -10% auto auto;
577 width: 360px;
578 height: 360px;
579 pointer-events: none;
580 z-index: 0;
581 }
582 .profile-hero-orb {
583 position: absolute;
584 inset: 0;
6fd5915Claude585 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude586 filter: blur(80px);
587 opacity: 0.7;
588 animation: cbHeroOrb 14s ease-in-out infinite;
589 }
590 .profile-hero-inner {
591 position: relative;
592 z-index: 1;
593 display: flex;
594 align-items: flex-start;
595 gap: var(--space-5);
596 }
597 .profile-hero-avatar {
598 flex: 0 0 auto;
599 width: 88px;
600 height: 88px;
601 border-radius: 50%;
6fd5915Claude602 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude603 color: #fff;
604 display: flex;
605 align-items: center;
606 justify-content: center;
607 font-size: 38px;
608 font-weight: 700;
609 font-family: var(--font-display);
6fd5915Claude610 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude611 }
612 .profile-hero-text { flex: 1; min-width: 0; }
613 .profile-eyebrow {
614 font-size: 12px;
615 font-family: var(--font-mono);
616 color: var(--text-muted);
617 letter-spacing: 0.1em;
618 text-transform: uppercase;
619 margin-bottom: var(--space-2);
620 }
621 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
622 .profile-name {
623 font-family: var(--font-display);
624 font-weight: 800;
625 letter-spacing: -0.028em;
626 font-size: clamp(28px, 3.6vw, 36px);
627 line-height: 1.05;
628 margin: 0 0 4px;
629 color: var(--text-strong);
630 }
631 .profile-handle {
632 font-family: var(--font-mono);
633 font-size: 13px;
634 color: var(--text-muted);
635 margin-bottom: var(--space-2);
636 }
637 .profile-bio {
638 font-size: 14.5px;
639 color: var(--text);
640 line-height: 1.55;
641 margin: 0 0 var(--space-3);
642 max-width: 640px;
643 }
644 .profile-meta {
645 display: flex;
646 align-items: center;
647 gap: var(--space-4);
648 flex-wrap: wrap;
649 font-size: 13px;
650 }
651 .profile-meta-link {
652 color: var(--text-muted);
653 transition: color var(--t-fast, 0.15s) ease;
654 }
655 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
656 .profile-meta-link strong {
657 color: var(--text-strong);
658 font-weight: 600;
659 font-variant-numeric: tabular-nums;
660 }
661 .profile-follow-form { margin: 0; }
662 @media (max-width: 600px) {
663 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
664 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
665 }
666 .profile-readme {
667 margin-bottom: var(--space-6);
668 background: var(--bg-elevated);
669 border: 1px solid var(--border);
670 border-radius: 12px;
671 overflow: hidden;
672 }
673 .profile-readme-head {
674 display: flex;
675 align-items: center;
676 gap: 8px;
677 padding: 10px 16px;
678 background: var(--bg-secondary);
679 border-bottom: 1px solid var(--border);
680 font-size: 13px;
681 color: var(--text-muted);
682 }
683 .profile-readme-icon { color: var(--accent); font-size: 14px; }
684 .profile-readme-body { padding: var(--space-5) var(--space-6); }
685 .profile-section-head {
686 display: flex;
687 align-items: baseline;
688 gap: var(--space-2);
689 margin-bottom: var(--space-3);
690 }
691 .profile-section-title {
692 font-family: var(--font-display);
693 font-weight: 700;
694 font-size: 20px;
695 letter-spacing: -0.015em;
696 margin: 0;
697 color: var(--text-strong);
698 }
699 .profile-section-count {
700 font-family: var(--font-mono);
701 font-size: 12px;
702 color: var(--text-muted);
703 background: var(--bg-secondary);
704 border: 1px solid var(--border);
705 border-radius: 999px;
706 padding: 2px 10px;
707 font-variant-numeric: tabular-nums;
708 }
709 .profile-empty {
710 display: flex;
711 align-items: center;
712 justify-content: space-between;
713 gap: var(--space-3);
714 padding: var(--space-4) var(--space-5);
715 background: var(--bg-elevated);
716 border: 1px dashed var(--border);
717 border-radius: 12px;
718 }
719 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
720
721 /* ───────── tree (file browser) ───────── */
722 .tree-header {
723 margin-bottom: var(--space-3);
724 display: flex;
725 flex-direction: column;
726 gap: var(--space-2);
727 }
728 .tree-header-row {
729 display: flex;
730 align-items: center;
731 justify-content: space-between;
732 gap: var(--space-3);
733 flex-wrap: wrap;
734 }
735 .tree-header-stats {
736 display: flex;
737 align-items: center;
738 gap: var(--space-3);
739 font-size: 12px;
740 color: var(--text-muted);
741 }
742 .tree-stat strong {
743 color: var(--text-strong);
744 font-weight: 600;
745 font-variant-numeric: tabular-nums;
746 }
747 .tree-stat-link {
748 color: var(--text-muted);
749 padding: 4px 10px;
750 border-radius: 999px;
751 border: 1px solid var(--border);
752 background: var(--bg-elevated);
753 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
754 }
755 .tree-stat-link:hover {
756 color: var(--accent);
6fd5915Claude757 border-color: rgba(91,110,232,0.45);
efb11c5Claude758 text-decoration: none;
759 }
760 .tree-breadcrumb-row {
761 font-size: 13px;
762 }
763
764 /* ───────── blob (file viewer) ───────── */
765 .blob-toolbar {
766 margin-bottom: var(--space-3);
767 display: flex;
768 flex-direction: column;
769 gap: var(--space-2);
770 }
771 .blob-breadcrumb { font-size: 13px; }
772 .blob-card {
773 background: var(--bg-elevated);
774 border: 1px solid var(--border);
775 border-radius: 12px;
776 overflow: hidden;
777 }
778 .blob-header-polished {
779 display: flex;
780 align-items: center;
781 justify-content: space-between;
782 gap: var(--space-3);
783 padding: 10px 14px;
784 background: var(--bg-secondary);
785 border-bottom: 1px solid var(--border);
786 }
787 .blob-header-meta {
788 display: flex;
789 align-items: center;
790 gap: var(--space-2);
791 min-width: 0;
792 flex: 1;
793 }
794 .blob-header-icon { font-size: 14px; opacity: 0.85; }
795 .blob-header-name {
796 font-family: var(--font-mono);
797 font-size: 13px;
798 color: var(--text-strong);
799 font-weight: 600;
800 overflow: hidden;
801 text-overflow: ellipsis;
802 white-space: nowrap;
803 }
804 .blob-header-size {
805 font-family: var(--font-mono);
806 font-size: 11.5px;
807 color: var(--text-muted);
808 font-variant-numeric: tabular-nums;
809 border-left: 1px solid var(--border);
810 padding-left: var(--space-2);
811 margin-left: 2px;
812 }
813 .blob-header-actions {
814 display: flex;
815 gap: 6px;
816 flex-shrink: 0;
817 }
818 .blob-pill {
819 display: inline-flex;
820 align-items: center;
821 padding: 4px 12px;
822 font-size: 12px;
823 font-weight: 500;
824 color: var(--text);
825 background: var(--bg-elevated);
826 border: 1px solid var(--border);
827 border-radius: 999px;
828 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
829 }
830 .blob-pill:hover {
831 color: var(--accent);
6fd5915Claude832 border-color: rgba(91,110,232,0.45);
efb11c5Claude833 text-decoration: none;
6fd5915Claude834 background: rgba(91,110,232,0.06);
efb11c5Claude835 }
836 .blob-pill-accent {
837 color: var(--accent);
6fd5915Claude838 border-color: rgba(91,110,232,0.35);
839 background: rgba(91,110,232,0.08);
efb11c5Claude840 }
841 .blob-pill-accent:hover {
6fd5915Claude842 background: rgba(91,110,232,0.14);
efb11c5Claude843 }
844 .blob-binary {
845 padding: var(--space-5);
846 color: var(--text-muted);
847 text-align: center;
848 font-size: 13px;
849 background: var(--bg);
850 }
851
852 /* ───────── commits list ───────── */
853 .commits-hero {
854 position: relative;
855 margin-bottom: var(--space-4);
856 padding: var(--space-5) var(--space-6);
857 background: var(--bg-elevated);
858 border: 1px solid var(--border);
859 border-radius: 16px;
860 overflow: hidden;
861 }
862 .commits-hero-orb-wrap {
863 position: absolute;
864 inset: -25% -10% auto auto;
865 width: 320px;
866 height: 320px;
867 pointer-events: none;
868 z-index: 0;
869 }
870 .commits-hero-orb {
871 position: absolute;
872 inset: 0;
6fd5915Claude873 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude874 filter: blur(80px);
875 opacity: 0.7;
876 animation: cbHeroOrb 14s ease-in-out infinite;
877 }
878 .commits-hero-inner { position: relative; z-index: 1; }
879 .commits-eyebrow {
880 font-size: 12px;
881 font-family: var(--font-mono);
882 color: var(--text-muted);
883 letter-spacing: 0.1em;
884 text-transform: uppercase;
885 margin-bottom: var(--space-2);
886 }
887 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
888 .commits-title {
889 font-family: var(--font-display);
890 font-weight: 800;
891 letter-spacing: -0.025em;
892 font-size: clamp(22px, 3vw, 30px);
893 line-height: 1.15;
894 margin: 0 0 var(--space-2);
895 color: var(--text-strong);
896 }
897 .commits-branch {
898 font-family: var(--font-mono);
899 font-size: 0.7em;
900 color: var(--text);
901 background: var(--bg-secondary);
902 border: 1px solid var(--border);
903 border-radius: 6px;
904 padding: 2px 8px;
905 font-weight: 500;
906 vertical-align: middle;
907 }
908 .commits-sub {
909 font-size: 14px;
910 color: var(--text-muted);
911 margin: 0;
912 line-height: 1.55;
913 max-width: 640px;
914 }
398a10cClaude915 .commits-toolbar {
916 display: flex;
917 justify-content: space-between;
918 align-items: center;
919 flex-wrap: wrap;
920 gap: var(--space-2);
921 margin-bottom: var(--space-3);
922 }
923 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
924 .commits-toolbar-link {
925 display: inline-flex;
926 align-items: center;
927 gap: 6px;
928 padding: 6px 12px;
929 font-size: 12.5px;
930 font-weight: 500;
931 color: var(--text-muted);
932 background: rgba(255,255,255,0.025);
933 border: 1px solid var(--border);
934 border-radius: 8px;
935 text-decoration: none;
936 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
937 }
938 .commits-toolbar-link:hover {
939 border-color: var(--border-strong);
940 color: var(--text-strong);
941 background: rgba(255,255,255,0.04);
942 text-decoration: none;
943 }
efb11c5Claude944 .commits-list-wrap {
945 background: var(--bg-elevated);
946 border: 1px solid var(--border);
398a10cClaude947 border-radius: 14px;
efb11c5Claude948 overflow: hidden;
398a10cClaude949 position: relative;
950 }
951 .commits-list-wrap::before {
952 content: '';
953 position: absolute;
954 top: 0; left: 0; right: 0;
955 height: 2px;
6fd5915Claude956 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude957 opacity: 0.55;
958 pointer-events: none;
959 }
960 .commits-day-head {
961 display: flex;
962 align-items: center;
963 gap: 10px;
964 padding: 10px 18px;
965 font-size: 11.5px;
966 font-family: var(--font-mono);
967 text-transform: uppercase;
968 letter-spacing: 0.08em;
969 color: var(--text-muted);
970 background: var(--bg-secondary);
971 border-bottom: 1px solid var(--border);
972 }
973 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
974 .commits-day-head-dot {
975 width: 6px; height: 6px;
976 border-radius: 9999px;
6fd5915Claude977 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude978 }
979 .commits-row {
980 display: flex;
981 align-items: flex-start;
982 gap: 14px;
983 padding: 14px 18px;
984 border-bottom: 1px solid var(--border-subtle);
985 transition: background 120ms ease;
986 }
987 .commits-row:last-child { border-bottom: none; }
988 .commits-row:hover { background: rgba(255,255,255,0.022); }
989 .commits-avatar {
990 width: 34px; height: 34px;
991 border-radius: 9999px;
6fd5915Claude992 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude993 color: #fff;
994 display: inline-flex;
995 align-items: center;
996 justify-content: center;
997 font-family: var(--font-display);
998 font-weight: 700;
999 font-size: 13.5px;
1000 flex-shrink: 0;
1001 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
1002 }
1003 .commits-row-body { flex: 1; min-width: 0; }
1004 .commits-row-msg {
1005 display: flex;
1006 align-items: center;
1007 gap: 8px;
1008 flex-wrap: wrap;
1009 }
1010 .commits-row-msg a {
1011 font-family: var(--font-display);
1012 font-weight: 600;
1013 font-size: 14px;
1014 color: var(--text-strong);
1015 text-decoration: none;
1016 letter-spacing: -0.005em;
1017 }
1018 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1019 .commits-row-verified {
1020 font-size: 9.5px;
1021 padding: 1px 7px;
1022 border-radius: 9999px;
1023 background: rgba(52,211,153,0.16);
1024 color: #6ee7b7;
1025 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1026 text-transform: uppercase;
1027 letter-spacing: 0.06em;
1028 font-weight: 700;
1029 }
1030 .commits-row-meta {
1031 margin-top: 4px;
1032 display: flex;
1033 align-items: center;
1034 gap: 8px;
1035 flex-wrap: wrap;
1036 font-size: 12.5px;
1037 color: var(--text-muted);
1038 }
1039 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1040 .commits-row-meta .sep { opacity: 0.4; }
1041 .commits-row-time {
1042 font-variant-numeric: tabular-nums;
1043 }
1044 .commits-row-side {
1045 display: flex;
1046 align-items: center;
1047 gap: 8px;
1048 flex-shrink: 0;
1049 }
1050 .commits-row-sha {
1051 display: inline-flex;
1052 align-items: center;
1053 padding: 4px 10px;
1054 border-radius: 9999px;
1055 font-family: var(--font-mono);
1056 font-size: 11.5px;
1057 font-weight: 600;
1058 color: var(--text-strong);
1059 background: rgba(255,255,255,0.04);
1060 border: 1px solid var(--border);
1061 text-decoration: none;
1062 letter-spacing: 0.04em;
1063 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1064 }
1065 .commits-row-sha:hover {
6fd5915Claude1066 border-color: rgba(91,110,232,0.55);
398a10cClaude1067 color: var(--accent);
6fd5915Claude1068 background: rgba(91,110,232,0.08);
398a10cClaude1069 text-decoration: none;
1070 }
1071 .commits-row-copy {
1072 display: inline-flex;
1073 align-items: center;
1074 justify-content: center;
1075 width: 28px; height: 28px;
1076 padding: 0;
1077 border-radius: 8px;
1078 background: transparent;
1079 border: 1px solid var(--border);
1080 color: var(--text-muted);
1081 cursor: pointer;
1082 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1083 }
398a10cClaude1084 .commits-row-copy:hover {
1085 border-color: var(--border-strong);
1086 color: var(--text);
1087 background: rgba(255,255,255,0.04);
1088 }
1089 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude1090 /* Push Watch link — eye icon next to the SHA chip */
1091 .commits-row-watch {
1092 display: inline-flex;
1093 align-items: center;
1094 justify-content: center;
1095 width: 28px;
1096 height: 28px;
1097 border-radius: 6px;
1098 border: 1px solid var(--border);
1099 color: var(--text-muted);
1100 background: transparent;
1101 cursor: pointer;
1102 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1103 text-decoration: none !important;
1104 }
1105 .commits-row-watch:hover {
6fd5915Claude1106 border-color: rgba(91,110,232,0.45);
8c790e0Claude1107 color: var(--accent);
6fd5915Claude1108 background: rgba(91,110,232,0.08);
8c790e0Claude1109 text-decoration: none !important;
1110 }
efb11c5Claude1111 .commits-empty {
398a10cClaude1112 position: relative;
1113 overflow: hidden;
1114 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1115 text-align: center;
398a10cClaude1116 background: var(--bg-elevated);
1117 border: 1px dashed var(--border-strong);
1118 border-radius: 16px;
1119 }
1120 .commits-empty-orb {
1121 position: absolute;
1122 inset: -40% 30% auto 30%;
1123 height: 280px;
6fd5915Claude1124 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1125 filter: blur(70px);
1126 opacity: 0.7;
1127 pointer-events: none;
1128 z-index: 0;
1129 }
1130 .commits-empty-inner { position: relative; z-index: 1; }
1131 .commits-empty-icon {
1132 width: 56px; height: 56px;
1133 border-radius: 9999px;
6fd5915Claude1134 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1135 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1136 display: inline-flex;
1137 align-items: center;
1138 justify-content: center;
1139 color: #c4b5fd;
1140 margin: 0 auto 14px;
1141 }
1142 .commits-empty-title {
1143 font-family: var(--font-display);
1144 font-size: 18px;
1145 font-weight: 700;
1146 margin: 0 0 6px;
1147 color: var(--text-strong);
1148 }
1149 .commits-empty-sub {
1150 margin: 0 auto 0;
1151 font-size: 13.5px;
efb11c5Claude1152 color: var(--text-muted);
398a10cClaude1153 max-width: 420px;
1154 line-height: 1.5;
1155 }
1156
1157 /* ───────── branches list ───────── */
1158 .branches-list {
efb11c5Claude1159 background: var(--bg-elevated);
398a10cClaude1160 border: 1px solid var(--border);
1161 border-radius: 14px;
1162 overflow: hidden;
1163 position: relative;
1164 }
1165 .branches-list::before {
1166 content: '';
1167 position: absolute;
1168 top: 0; left: 0; right: 0;
1169 height: 2px;
6fd5915Claude1170 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1171 opacity: 0.55;
1172 pointer-events: none;
1173 }
1174 .branches-row {
1175 display: flex;
1176 align-items: center;
1177 gap: var(--space-3);
1178 padding: 14px 18px;
1179 border-bottom: 1px solid var(--border-subtle);
1180 transition: background 120ms ease;
1181 flex-wrap: wrap;
1182 }
1183 .branches-row:last-child { border-bottom: none; }
1184 .branches-row:hover { background: rgba(255,255,255,0.022); }
1185 .branches-row-icon {
1186 width: 32px; height: 32px;
1187 border-radius: 8px;
6fd5915Claude1188 background: rgba(91,110,232,0.10);
398a10cClaude1189 color: #c4b5fd;
1190 display: inline-flex;
1191 align-items: center;
1192 justify-content: center;
1193 flex-shrink: 0;
6fd5915Claude1194 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1195 }
1196 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1197 .branches-row-name {
1198 display: inline-flex;
1199 align-items: center;
1200 gap: 8px;
1201 flex-wrap: wrap;
1202 }
1203 .branches-row-name a {
1204 font-family: var(--font-mono);
1205 font-size: 13.5px;
1206 color: var(--text-strong);
1207 font-weight: 600;
1208 text-decoration: none;
1209 }
1210 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1211 .branches-row-default {
1212 font-size: 10px;
1213 padding: 2px 8px;
1214 border-radius: 9999px;
6fd5915Claude1215 background: rgba(91,110,232,0.14);
398a10cClaude1216 color: #c4b5fd;
6fd5915Claude1217 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1218 text-transform: uppercase;
1219 letter-spacing: 0.08em;
1220 font-weight: 700;
1221 font-family: var(--font-mono);
1222 }
1223 .branches-row-meta {
1224 display: flex;
1225 align-items: center;
1226 gap: 8px;
1227 flex-wrap: wrap;
1228 font-size: 12.5px;
1229 color: var(--text-muted);
1230 font-variant-numeric: tabular-nums;
1231 }
1232 .branches-row-meta .sep { opacity: 0.4; }
1233 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1234 .branches-row-side {
1235 display: flex;
1236 align-items: center;
1237 gap: 10px;
1238 flex-shrink: 0;
1239 flex-wrap: wrap;
1240 }
1241 .branches-row-divergence {
1242 display: inline-flex;
1243 align-items: center;
1244 gap: 8px;
1245 font-family: var(--font-mono);
1246 font-size: 11.5px;
1247 color: var(--text-muted);
1248 padding: 4px 10px;
1249 border-radius: 9999px;
1250 background: rgba(255,255,255,0.035);
1251 border: 1px solid var(--border);
1252 font-variant-numeric: tabular-nums;
1253 }
1254 .branches-row-divergence .ahead { color: #6ee7b7; }
1255 .branches-row-divergence .behind { color: #fca5a5; }
1256 .branches-row-actions {
1257 display: flex;
1258 align-items: center;
1259 gap: 6px;
1260 }
1261 .branches-btn {
1262 display: inline-flex;
1263 align-items: center;
1264 gap: 5px;
1265 padding: 6px 12px;
1266 border-radius: 9999px;
1267 font-size: 12px;
1268 font-weight: 600;
1269 text-decoration: none;
1270 border: 1px solid var(--border);
1271 background: transparent;
1272 color: var(--text-muted);
1273 cursor: pointer;
1274 font: inherit;
1275 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1276 }
1277 .branches-btn:hover {
1278 border-color: var(--border-strong);
1279 color: var(--text);
1280 background: rgba(255,255,255,0.04);
1281 text-decoration: none;
1282 }
1283 .branches-btn-danger {
1284 color: #fca5a5;
1285 border-color: rgba(248,113,113,0.30);
1286 }
1287 .branches-btn-danger:hover {
1288 border-style: dashed;
1289 border-color: rgba(248,113,113,0.65);
1290 background: rgba(248,113,113,0.06);
1291 color: #fecaca;
1292 }
1293
1294 /* ───────── tags list ───────── */
1295 .tags-list {
1296 background: var(--bg-elevated);
1297 border: 1px solid var(--border);
1298 border-radius: 14px;
1299 overflow: hidden;
1300 position: relative;
1301 }
1302 .tags-list::before {
1303 content: '';
1304 position: absolute;
1305 top: 0; left: 0; right: 0;
1306 height: 2px;
6fd5915Claude1307 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1308 opacity: 0.55;
1309 pointer-events: none;
1310 }
1311 .tags-row {
1312 display: flex;
1313 align-items: center;
1314 gap: var(--space-3);
1315 padding: 14px 18px;
1316 border-bottom: 1px solid var(--border-subtle);
1317 transition: background 120ms ease;
1318 flex-wrap: wrap;
1319 }
1320 .tags-row:last-child { border-bottom: none; }
1321 .tags-row:hover { background: rgba(255,255,255,0.022); }
1322 .tags-row-icon {
1323 width: 32px; height: 32px;
1324 border-radius: 8px;
6fd5915Claude1325 background: rgba(95,143,160,0.10);
398a10cClaude1326 color: #67e8f9;
1327 display: inline-flex;
1328 align-items: center;
1329 justify-content: center;
1330 flex-shrink: 0;
6fd5915Claude1331 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1332 }
1333 .tags-row-main { flex: 1; min-width: 240px; }
1334 .tags-row-name {
1335 display: inline-flex;
1336 align-items: center;
1337 gap: 8px;
1338 flex-wrap: wrap;
1339 }
1340 .tags-row-version {
1341 display: inline-flex;
1342 align-items: center;
1343 padding: 3px 11px;
1344 border-radius: 9999px;
1345 font-family: var(--font-mono);
1346 font-size: 13px;
1347 font-weight: 700;
1348 color: #67e8f9;
6fd5915Claude1349 background: rgba(95,143,160,0.12);
1350 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1351 letter-spacing: 0.01em;
1352 }
1353 .tags-row-meta {
1354 margin-top: 4px;
1355 display: flex;
1356 align-items: center;
1357 gap: 8px;
1358 flex-wrap: wrap;
1359 font-size: 12.5px;
1360 color: var(--text-muted);
1361 font-variant-numeric: tabular-nums;
1362 }
1363 .tags-row-meta .sep { opacity: 0.4; }
1364 .tags-row-sha {
1365 font-family: var(--font-mono);
1366 font-size: 11.5px;
1367 color: var(--text-strong);
1368 padding: 2px 8px;
1369 border-radius: 6px;
1370 background: rgba(255,255,255,0.04);
1371 border: 1px solid var(--border);
1372 text-decoration: none;
1373 letter-spacing: 0.04em;
1374 }
1375 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1376 .tags-row-side {
1377 display: flex;
1378 align-items: center;
1379 gap: 6px;
1380 flex-shrink: 0;
1381 flex-wrap: wrap;
1382 }
1383 .tags-row-link {
1384 display: inline-flex;
1385 align-items: center;
1386 gap: 5px;
1387 padding: 6px 12px;
1388 border-radius: 9999px;
1389 font-size: 12px;
1390 font-weight: 600;
1391 text-decoration: none;
1392 color: var(--text-muted);
1393 background: rgba(255,255,255,0.025);
1394 border: 1px solid var(--border);
1395 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1396 }
1397 .tags-row-link:hover {
6fd5915Claude1398 border-color: rgba(91,110,232,0.45);
398a10cClaude1399 color: var(--text-strong);
6fd5915Claude1400 background: rgba(91,110,232,0.06);
398a10cClaude1401 text-decoration: none;
efb11c5Claude1402 }
1403
1404 /* ───────── commit detail ───────── */
1405 .commit-detail-card {
1406 position: relative;
1407 margin-bottom: var(--space-5);
1408 padding: var(--space-5) var(--space-5);
1409 background: var(--bg-elevated);
1410 border: 1px solid var(--border);
1411 border-radius: 14px;
1412 overflow: hidden;
1413 }
1414 .commit-detail-eyebrow {
1415 display: flex;
1416 align-items: center;
1417 gap: var(--space-2);
1418 font-size: 12px;
1419 font-family: var(--font-mono);
1420 text-transform: uppercase;
1421 letter-spacing: 0.1em;
1422 color: var(--text-muted);
1423 margin-bottom: var(--space-2);
1424 }
1425 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1426 .commit-detail-sha-pill {
1427 font-family: var(--font-mono);
1428 font-size: 11.5px;
1429 color: var(--text-strong);
1430 background: var(--bg-secondary);
1431 border: 1px solid var(--border);
1432 border-radius: 999px;
1433 padding: 2px 10px;
1434 letter-spacing: 0.04em;
1435 }
1436 .commit-detail-verify {
1437 font-size: 10px;
1438 padding: 2px 8px;
1439 border-radius: 999px;
1440 text-transform: uppercase;
1441 letter-spacing: 0.06em;
1442 font-weight: 700;
1443 color: #fff;
1444 }
1445 .commit-detail-verify-ok {
1446 background: linear-gradient(135deg, #2ea043, #34d399);
1447 }
1448 .commit-detail-verify-warn {
1449 background: linear-gradient(135deg, #d29922, #f59e0b);
1450 }
1451 .commit-detail-title {
1452 font-family: var(--font-display);
1453 font-weight: 700;
1454 letter-spacing: -0.018em;
1455 font-size: clamp(20px, 2.5vw, 26px);
1456 line-height: 1.25;
1457 margin: 0 0 var(--space-2);
1458 color: var(--text-strong);
1459 }
1460 .commit-detail-body {
1461 white-space: pre-wrap;
1462 color: var(--text-muted);
1463 font-size: 14px;
1464 line-height: 1.55;
1465 margin: 0 0 var(--space-3);
1466 font-family: var(--font-mono);
1467 background: var(--bg);
1468 border: 1px solid var(--border);
1469 border-radius: 8px;
1470 padding: var(--space-3) var(--space-4);
1471 max-height: 280px;
1472 overflow: auto;
1473 }
1474 .commit-detail-meta {
1475 display: flex;
1476 flex-wrap: wrap;
1477 gap: var(--space-3);
1478 font-size: 13px;
1479 color: var(--text-muted);
1480 margin-bottom: var(--space-3);
1481 }
1482 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1483 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1484 .commit-detail-sha-link {
1485 font-family: var(--font-mono);
1486 font-size: 12.5px;
1487 color: var(--accent);
6fd5915Claude1488 background: rgba(91,110,232,0.08);
efb11c5Claude1489 border-radius: 6px;
1490 padding: 1px 6px;
1491 margin-left: 2px;
1492 }
6fd5915Claude1493 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1494 .commit-detail-stats {
1495 display: flex;
1496 flex-wrap: wrap;
1497 align-items: center;
1498 gap: var(--space-3);
1499 padding-top: var(--space-3);
1500 border-top: 1px solid var(--border);
1501 font-size: 13px;
1502 color: var(--text-muted);
1503 }
1504 .commit-detail-stat {
1505 display: inline-flex;
1506 align-items: center;
1507 gap: 4px;
1508 font-variant-numeric: tabular-nums;
1509 }
1510 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1511 .commit-detail-stat-add strong { color: #34d399; }
1512 .commit-detail-stat-del strong { color: #f87171; }
1513 .commit-detail-stat-mark {
1514 font-family: var(--font-mono);
1515 font-weight: 700;
1516 font-size: 14px;
1517 }
1518 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1519 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1520 .commit-detail-sha-full {
1521 margin-left: auto;
1522 font-family: var(--font-mono);
1523 font-size: 11.5px;
1524 color: var(--text-faint);
1525 letter-spacing: 0.02em;
1526 overflow: hidden;
1527 text-overflow: ellipsis;
1528 white-space: nowrap;
1529 max-width: 100%;
1530 }
1531 .commit-detail-checks {
1532 margin-top: var(--space-3);
1533 padding-top: var(--space-3);
1534 border-top: 1px solid var(--border);
1535 }
1536 .commit-detail-checks-head {
1537 display: flex;
1538 align-items: center;
1539 gap: var(--space-2);
1540 font-size: 13px;
1541 color: var(--text-muted);
1542 margin-bottom: 8px;
1543 }
1544 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1545 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1546 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1547 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1548 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1549 .commit-detail-check {
1550 font-size: 11px;
1551 padding: 2px 8px;
1552 border-radius: 999px;
1553 color: #fff;
1554 font-weight: 500;
1555 }
398a10cClaude1556 .commit-detail-check a { color: inherit; text-decoration: none; }
1557 .commit-detail-check-success { background: #2ea043; }
1558 .commit-detail-check-pending { background: #d29922; }
1559 .commit-detail-check-failure { background: #da3633; }
1560
1561 /* ───────── blame ───────── */
1562 .blame-head { margin-bottom: var(--space-5); }
1563 .blame-eyebrow {
1564 display: inline-flex;
1565 align-items: center;
1566 gap: 8px;
1567 text-transform: uppercase;
1568 font-family: var(--font-mono);
1569 font-size: 11px;
1570 letter-spacing: 0.16em;
1571 color: var(--text-muted);
1572 font-weight: 600;
1573 margin-bottom: 10px;
1574 }
1575 .blame-eyebrow-dot {
1576 width: 8px; height: 8px;
1577 border-radius: 9999px;
6fd5915Claude1578 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1579 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1580 }
1581 .blame-title {
1582 font-family: var(--font-display);
1583 font-size: clamp(22px, 3vw, 30px);
1584 font-weight: 800;
1585 letter-spacing: -0.025em;
1586 line-height: 1.15;
1587 margin: 0 0 6px;
1588 color: var(--text-strong);
1589 }
1590 .blame-title code {
1591 font-family: var(--font-mono);
1592 font-size: 0.78em;
1593 color: var(--text-strong);
1594 font-weight: 700;
1595 }
1596 .blame-sub {
1597 margin: 0;
1598 font-size: 14px;
1599 color: var(--text-muted);
1600 line-height: 1.5;
1601 max-width: 700px;
1602 }
efb11c5Claude1603 .blame-toolbar {
398a10cClaude1604 display: flex;
1605 justify-content: space-between;
1606 align-items: center;
1607 gap: var(--space-3);
efb11c5Claude1608 margin-bottom: var(--space-3);
398a10cClaude1609 flex-wrap: wrap;
efb11c5Claude1610 font-size: 13px;
1611 }
398a10cClaude1612 .blame-toolbar-actions { display: flex; gap: 6px; }
1613 .blame-card {
1614 background: var(--bg-elevated);
1615 border: 1px solid var(--border);
1616 border-radius: 14px;
1617 overflow: hidden;
1618 position: relative;
1619 }
1620 .blame-card::before {
1621 content: '';
1622 position: absolute;
1623 top: 0; left: 0; right: 0;
1624 height: 2px;
6fd5915Claude1625 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1626 opacity: 0.55;
1627 pointer-events: none;
1628 }
efb11c5Claude1629 .blame-header {
1630 display: flex;
1631 align-items: center;
1632 justify-content: space-between;
1633 gap: var(--space-3);
1634 padding: 10px 14px;
1635 background: var(--bg-secondary);
1636 border-bottom: 1px solid var(--border);
1637 }
1638 .blame-header-meta {
1639 display: flex;
1640 align-items: center;
1641 gap: var(--space-2);
1642 min-width: 0;
1643 flex-wrap: wrap;
1644 }
1645 .blame-header-icon { color: var(--accent); font-size: 14px; }
1646 .blame-header-name {
1647 font-family: var(--font-mono);
1648 font-size: 13px;
1649 color: var(--text-strong);
1650 font-weight: 600;
1651 }
1652 .blame-header-tag {
1653 font-size: 10.5px;
1654 text-transform: uppercase;
1655 letter-spacing: 0.08em;
1656 font-family: var(--font-mono);
6fd5915Claude1657 background: rgba(91,110,232,0.12);
efb11c5Claude1658 color: var(--accent);
1659 border-radius: 999px;
1660 padding: 2px 8px;
1661 font-weight: 600;
1662 }
1663 .blame-header-stats {
1664 font-family: var(--font-mono);
1665 font-size: 11.5px;
1666 color: var(--text-muted);
1667 font-variant-numeric: tabular-nums;
1668 border-left: 1px solid var(--border);
1669 padding-left: var(--space-2);
1670 }
1671 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1672 .blame-table {
1673 width: 100%;
1674 border-collapse: collapse;
1675 font-family: var(--font-mono);
1676 font-size: 12.5px;
1677 line-height: 1.6;
1678 }
1679 .blame-table tr { border-bottom: 1px solid transparent; }
1680 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1681 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1682 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1683 .blame-gutter {
1684 width: 220px;
1685 min-width: 220px;
1686 padding: 0 12px;
1687 vertical-align: top;
1688 background: rgba(255,255,255,0.012);
1689 border-right: 1px solid var(--border-subtle);
1690 font-variant-numeric: tabular-nums;
1691 color: var(--text-muted);
1692 font-size: 11px;
1693 white-space: nowrap;
1694 overflow: hidden;
1695 text-overflow: ellipsis;
1696 padding-top: 2px;
1697 padding-bottom: 2px;
1698 }
1699 .blame-gutter-inner {
1700 display: inline-flex;
1701 align-items: center;
1702 gap: 7px;
1703 max-width: 100%;
1704 overflow: hidden;
1705 }
1706 .blame-gutter-sha {
1707 font-family: var(--font-mono);
1708 font-size: 10.5px;
1709 font-weight: 600;
1710 color: #c4b5fd;
6fd5915Claude1711 background: rgba(91,110,232,0.10);
398a10cClaude1712 padding: 1px 7px;
1713 border-radius: 9999px;
6fd5915Claude1714 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1715 text-decoration: none;
1716 letter-spacing: 0.04em;
1717 flex-shrink: 0;
1718 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1719 }
1720 .blame-gutter-sha:hover {
6fd5915Claude1721 background: rgba(91,110,232,0.22);
398a10cClaude1722 color: #ddd6fe;
6fd5915Claude1723 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1724 text-decoration: none;
1725 }
1726 .blame-gutter-author {
1727 color: var(--text);
1728 overflow: hidden;
1729 text-overflow: ellipsis;
1730 white-space: nowrap;
1731 font-size: 11px;
1732 font-family: var(--font-sans, inherit);
1733 }
1734 .blame-line-num {
1735 width: 1%;
1736 min-width: 50px;
1737 padding: 0 12px;
1738 text-align: right;
1739 color: var(--text-faint);
1740 user-select: none;
1741 border-right: 1px solid var(--border-subtle);
1742 font-variant-numeric: tabular-nums;
1743 }
1744 .blame-line-content {
1745 padding: 0 14px;
1746 white-space: pre;
1747 color: var(--text);
1748 transition: background 120ms ease;
1749 }
efb11c5Claude1750
1751 /* ───────── search ───────── */
1752 .search-hero {
1753 position: relative;
1754 margin-bottom: var(--space-4);
1755 padding: var(--space-5) var(--space-6);
1756 background: var(--bg-elevated);
1757 border: 1px solid var(--border);
1758 border-radius: 16px;
1759 overflow: hidden;
1760 }
1761 .search-eyebrow {
1762 font-size: 12px;
1763 font-family: var(--font-mono);
1764 color: var(--text-muted);
1765 letter-spacing: 0.1em;
1766 text-transform: uppercase;
1767 margin-bottom: var(--space-2);
1768 }
1769 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1770 .search-title {
1771 font-family: var(--font-display);
1772 font-weight: 800;
1773 letter-spacing: -0.025em;
1774 font-size: clamp(22px, 3vw, 30px);
1775 line-height: 1.15;
1776 margin: 0 0 var(--space-3);
1777 color: var(--text-strong);
1778 }
1779 .search-form {
1780 display: flex;
1781 gap: var(--space-2);
1782 align-items: stretch;
1783 }
1784 .search-input-wrap {
1785 position: relative;
1786 flex: 1;
1787 display: flex;
1788 align-items: center;
1789 }
1790 .search-input-icon {
1791 position: absolute;
1792 left: 12px;
1793 color: var(--text-muted);
1794 font-size: 15px;
1795 pointer-events: none;
1796 }
1797 .search-input {
1798 appearance: none;
1799 width: 100%;
1800 padding: 10px 12px 10px 34px;
1801 background: var(--bg);
1802 border: 1px solid var(--border);
1803 border-radius: 10px;
1804 color: var(--text-strong);
1805 font-size: 14px;
1806 font-family: inherit;
1807 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1808 }
1809 .search-input:focus {
1810 outline: none;
1811 border-color: var(--accent);
6fd5915Claude1812 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1813 }
1814 .search-submit { min-width: 96px; }
1815 .search-results-head {
1816 display: flex;
1817 align-items: baseline;
1818 gap: var(--space-2);
1819 margin-bottom: var(--space-3);
1820 font-size: 13px;
1821 color: var(--text-muted);
1822 }
1823 .search-results-count strong {
1824 color: var(--text-strong);
1825 font-weight: 600;
1826 font-variant-numeric: tabular-nums;
1827 }
1828 .search-results-q { color: var(--text-strong); font-weight: 600; }
1829 .search-results-head code {
1830 font-family: var(--font-mono);
1831 font-size: 12px;
1832 background: var(--bg-secondary);
1833 border: 1px solid var(--border);
1834 border-radius: 5px;
1835 padding: 1px 6px;
1836 color: var(--text);
1837 }
1838 .search-empty {
1839 padding: var(--space-5) var(--space-6);
1840 background: var(--bg-elevated);
1841 border: 1px dashed var(--border);
1842 border-radius: 12px;
1843 color: var(--text-muted);
1844 font-size: 14px;
1845 }
1846 .search-empty strong { color: var(--text-strong); }
1847 .search-results {
1848 display: flex;
1849 flex-direction: column;
1850 gap: var(--space-3);
1851 }
1852 .search-file-head {
1853 display: flex;
1854 align-items: center;
1855 justify-content: space-between;
1856 gap: var(--space-2);
1857 }
1858 .search-file-link {
1859 font-family: var(--font-mono);
1860 font-size: 13px;
1861 color: var(--text-strong);
1862 font-weight: 600;
1863 }
1864 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1865 .search-file-count {
1866 font-family: var(--font-mono);
1867 font-size: 11.5px;
1868 color: var(--text-muted);
1869 font-variant-numeric: tabular-nums;
1870 }
1871`;
1872
fc1817aClaude1873// Home page
06d5ffeClaude1874web.get("/", async (c) => {
1875 const user = c.get("user");
1876
1877 if (user) {
0316dbbClaude1878 return c.redirect("/dashboard");
06d5ffeClaude1879 }
1880
8e9f1d9Claude1881 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1882 let publicStats: PublicStats | null = null;
8e9f1d9Claude1883 try {
1884 const [repoRow] = await db
1885 .select({ n: sql<number>`count(*)::int` })
1886 .from(repositories)
1887 .where(eq(repositories.isPrivate, false));
1888 const [userRow] = await db
1889 .select({ n: sql<number>`count(*)::int` })
1890 .from(users);
1891 stats = {
1892 publicRepos: Number(repoRow?.n ?? 0),
1893 users: Number(userRow?.n ?? 0),
1894 };
1895 } catch {
1896 stats = undefined;
1897 }
1898
52ad8b1Claude1899 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1900 try {
1901 publicStats = await computePublicStats();
1902 } catch {
1903 publicStats = null;
1904 }
1905
534f04aClaude1906 // Block M1 — initial SSR snapshot for the live-now demo feed.
1907 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1908 // in try/catch so a freak module-level explosion can't take down /.
1909 let liveFeed: LandingLiveFeed | null = null;
1910 try {
1911 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1912 listQueuedAiBuildIssues(3),
1913 listRecentAutoMerges(3, 24),
1914 listRecentAiReviews(3, 24),
1915 countAiReviewsSince(24),
1916 listDemoActivityFeed(10),
1917 ]);
1918 liveFeed = {
1919 queued: queued.map((i) => ({
1920 repo: i.repo,
1921 number: i.number,
1922 title: i.title,
1923 createdAt: i.createdAt,
1924 })),
1925 merges: merges.map((m) => ({
1926 repo: m.repo,
1927 number: m.number,
1928 title: m.title,
1929 mergedAt: m.mergedAt,
1930 })),
1931 reviews: reviewList.map((r) => ({
1932 repo: r.repo,
1933 prNumber: r.prNumber,
1934 commentSnippet: r.commentSnippet,
1935 createdAt: r.createdAt,
1936 })),
1937 reviewCount,
1938 feed: feed.map((e) => ({
1939 kind: e.kind,
1940 repo: e.repo,
1941 ref: e.ref,
1942 at: e.at,
1943 })),
1944 };
1945 } catch {
1946 liveFeed = null;
1947 }
1948
29924bcClaude1949 void LandingPage;
f8e5feaccanty labs1950 void Landing2030Page;
1951 return c.html(
1952 "<!DOCTYPE html>" +
1953 String(
1954 <LandingProPage
1955 stats={stats}
1956 publicStats={publicStats}
1957 liveFeed={liveFeed}
1958 />
1959 )
1960 );
fc1817aClaude1961});
1962
06d5ffeClaude1963// New repository form
1964web.get("/new", requireAuth, (c) => {
1965 const user = c.get("user")!;
1966 const error = c.req.query("error");
1967
1968 return c.html(
1969 <Layout title="New repository" user={user}>
efb11c5Claude1970 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1971 <div class="new-repo-hero">
1972 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1973 <div class="new-repo-hero-orb" />
1974 </div>
1975 <div class="new-repo-hero-inner">
1976 <div class="new-repo-eyebrow">
1977 <strong>Create</strong> · {user.username}
1978 </div>
1979 <h1 class="new-repo-title">
1980 Spin up a <span class="gradient-text">repository</span>.
1981 </h1>
1982 <p class="new-repo-sub">
1983 Push your first commit, and Gluecron wires up gate checks, AI review,
1984 and auto-merge from the moment your branch lands.
1985 </p>
1986 </div>
1987 </div>
06d5ffeClaude1988 <div class="new-repo-form">
efb11c5Claude1989 {error && (
1990 <div class="new-repo-error" role="alert">
1991 {decodeURIComponent(error)}
1992 </div>
1993 )}
1994 <form method="post" action="/new" class="new-repo-form-grid">
1995 <div class="new-repo-row">
1996 <label class="new-repo-label">Owner</label>
1997 <input
1998 type="text"
1999 value={user.username}
2000 disabled
2001 aria-label="Owner"
2002 class="new-repo-input new-repo-input-disabled"
2003 />
06d5ffeClaude2004 </div>
efb11c5Claude2005 <div class="new-repo-row">
2006 <label class="new-repo-label" for="name">
2007 Repository name
2008 </label>
06d5ffeClaude2009 <input
2010 type="text"
2011 id="name"
2012 name="name"
2013 required
2014 pattern="^[a-zA-Z0-9._-]+$"
2015 placeholder="my-project"
2016 autocomplete="off"
efb11c5Claude2017 class="new-repo-input"
06d5ffeClaude2018 />
efb11c5Claude2019 <p class="new-repo-hint">
2020 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2021 <code>{user.username}/&lt;name&gt;</code>.
2022 </p>
06d5ffeClaude2023 </div>
efb11c5Claude2024 <div class="new-repo-row">
2025 <label class="new-repo-label" for="description">
2026 Description{" "}
2027 <span class="new-repo-label-optional">(optional)</span>
2028 </label>
06d5ffeClaude2029 <input
2030 type="text"
2031 id="description"
2032 name="description"
2033 placeholder="A short description of your repository"
efb11c5Claude2034 class="new-repo-input"
06d5ffeClaude2035 />
2036 </div>
efb11c5Claude2037 <div class="new-repo-row">
2038 <span class="new-repo-label">Visibility</span>
2039 <div class="new-repo-visibility">
2040 <label class="new-repo-vis-card">
2041 <input
2042 type="radio"
2043 name="visibility"
2044 value="public"
2045 checked
2046 class="new-repo-vis-radio"
2047 />
2048 <span class="new-repo-vis-body">
2049 <span class="new-repo-vis-label">Public</span>
2050 <span class="new-repo-vis-desc">
2051 Anyone can see this repository. You choose who can commit.
2052 </span>
2053 </span>
2054 </label>
2055 <label class="new-repo-vis-card">
2056 <input
2057 type="radio"
2058 name="visibility"
2059 value="private"
2060 class="new-repo-vis-radio"
2061 />
2062 <span class="new-repo-vis-body">
2063 <span class="new-repo-vis-label">Private</span>
2064 <span class="new-repo-vis-desc">
2065 Only you (and collaborators you invite) can see this
2066 repository.
2067 </span>
2068 </span>
2069 </label>
2070 </div>
2071 </div>
398a10cClaude2072 <div class="new-repo-row">
2073 <span class="new-repo-label">
2074 Starter content{" "}
2075 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2076 </span>
2077 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2078 <label class="new-repo-template-chip">
2079 <input type="radio" name="starter" value="empty" checked />
2080 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2081 Empty
2082 </label>
2083 <label class="new-repo-template-chip">
2084 <input type="radio" name="starter" value="readme" />
2085 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2086 README
2087 </label>
2088 <label class="new-repo-template-chip">
2089 <input type="radio" name="starter" value="readme-mit" />
2090 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2091 README + MIT
2092 </label>
2093 <label class="new-repo-template-chip">
2094 <input type="radio" name="starter" value="node" />
2095 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2096 Node + .gitignore
2097 </label>
2098 </div>
2099 <p class="new-repo-hint">
2100 Just a UI hint — push your own commits to fill the repo.
2101 </p>
2102 </div>
44f1a02Claude2103 <div class="new-repo-row">
2104 <label class="new-repo-label" for="data_region">
2105 Data region
2106 </label>
2107 <select
2108 id="data_region"
2109 name="data_region"
2110 class="new-repo-input"
2111 style="cursor: pointer;"
2112 >
2113 <option value="us" selected>US (default)</option>
2114 <option value="eu">EU (Frankfurt)</option>
2115 </select>
2116 <p class="new-repo-hint">
2117 EU data residency requires a{" "}
2118 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2119 Pro plan or higher
2120 </a>
2121 . Repositories cannot be moved between regions after creation.
2122 </p>
2123 </div>
efb11c5Claude2124 <div class="new-repo-callout">
2125 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2126 <p class="new-repo-callout-body">
2127 Every push is gate-checked and reviewed by Claude automatically.
2128 Label an issue <code>ai-build</code> and Gluecron will open the PR
2129 for you.
2130 </p>
2131 </div>
2132 <div class="new-repo-actions">
398a10cClaude2133 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2134 Create repository
2135 </button>
2136 <a href="/dashboard" class="btn new-repo-cancel">
2137 Cancel
2138 </a>
06d5ffeClaude2139 </div>
2140 </form>
2141 </div>
2142 </Layout>
2143 );
2144});
2145
2146web.post("/new", requireAuth, async (c) => {
2147 const user = c.get("user")!;
2148 const body = await c.req.parseBody();
2149 const name = String(body.name || "").trim();
2150 const description = String(body.description || "").trim();
2151 const isPrivate = body.visibility === "private";
44f1a02Claude2152 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2153
2154 if (!name) {
2155 return c.redirect("/new?error=Repository+name+is+required");
2156 }
2157
c63b860Claude2158 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2159 // outage never blocks repo creation.
2160 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2161 const gate = await checkRepoCreateAllowed(user.id);
2162 if (!gate.ok) {
2163 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2164 }
2165
06d5ffeClaude2166 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2167 return c.redirect("/new?error=Invalid+repository+name");
2168 }
2169
2170 if (await repoExists(user.username, name)) {
2171 return c.redirect("/new?error=Repository+already+exists");
2172 }
2173
2174 const diskPath = await initBareRepo(user.username, name);
2175
3ef4c9dClaude2176 const [newRepo] = await db
2177 .insert(repositories)
2178 .values({
2179 name,
2180 ownerId: user.id,
2181 description: description || null,
2182 isPrivate,
2183 diskPath,
44f1a02Claude2184 dataRegion,
3ef4c9dClaude2185 })
2186 .returning();
2187
2188 if (newRepo) {
2189 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2190 await bootstrapRepository({
2191 repositoryId: newRepo.id,
2192 ownerUserId: user.id,
2193 defaultBranch: "main",
2194 });
2195 }
06d5ffeClaude2196
2197 return c.redirect(`/${user.username}/${name}`);
2198});
2199
11c3ab6ccanty labs2200// Daily brief — GET /brief
2201web.get("/brief", (c) => {
2202 const user = c.get("user");
2203 return c.html(<DailyBrief user={user} />);
2204});
2205
2206// Trust report — GET /trust (public)
2207web.get("/trust", (c) => {
2208 return c.html(<TrustReport />);
2209});
2210
2211// Production layers — GET /layers
2212web.get("/layers", (c) => {
2213 const user = c.get("user");
2214 return c.html(<ProductionLayers user={user} />);
2215});
2216
2217// Distribution — GET /distribute
2218web.get("/distribute", (c) => {
2219 const user = c.get("user");
2220 return c.html(<DistributionView user={user} />);
2221});
2222
06d5ffeClaude2223// User profile
fc1817aClaude2224web.get("/:owner", async (c) => {
06d5ffeClaude2225 const { owner: ownerName } = c.req.param();
2226 const user = c.get("user");
2227
2228 // Avoid clashing with fixed routes
2229 if (
2230 ["login", "register", "logout", "new", "settings", "api"].includes(
2231 ownerName
2232 )
2233 ) {
2234 return c.notFound();
2235 }
2236
2237 let ownerUser;
2238 try {
2239 const [found] = await db
2240 .select()
2241 .from(users)
2242 .where(eq(users.username, ownerName))
2243 .limit(1);
2244 ownerUser = found;
2245 } catch {
2246 // DB not available — check if repos exist on disk
2247 ownerUser = null;
2248 }
2249
2250 // Even without DB, show repos if they exist on disk
2251 let repos: any[] = [];
2252 if (ownerUser) {
2253 const allRepos = await db
2254 .select()
2255 .from(repositories)
2256 .where(eq(repositories.ownerId, ownerUser.id))
2257 .orderBy(desc(repositories.updatedAt));
2258
2259 // Show public repos to everyone, private only to owner
2260 repos =
2261 user?.id === ownerUser.id
2262 ? allRepos
2263 : allRepos.filter((r) => !r.isPrivate);
2264 }
2265
7aa8b99Claude2266 // Block J4 — follow counts + viewer's follow state
2267 let followState = {
2268 followers: 0,
2269 following: 0,
2270 viewerFollows: false,
2271 };
2272 if (ownerUser) {
2273 try {
2274 const { followCounts, isFollowing } = await import("../lib/follows");
2275 const counts = await followCounts(ownerUser.id);
2276 followState.followers = counts.followers;
2277 followState.following = counts.following;
2278 if (user && user.id !== ownerUser.id) {
2279 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2280 }
2281 } catch {
2282 // DB hiccup — fall back to zeros.
2283 }
2284 }
2285 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2286
d412586Claude2287 // Block J5 — profile README. Render owner/owner repo's README on the
2288 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2289 // back to "<user>/.github" for org-style profile repos.
2290 let profileReadmeHtml: string | null = null;
2291 try {
2292 const candidates = [ownerName, ".github"];
2293 for (const rname of candidates) {
2294 if (await repoExists(ownerName, rname)) {
2295 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2296 const md = await getReadme(ownerName, rname, ref);
2297 if (md) {
2298 profileReadmeHtml = renderMarkdown(md);
2299 break;
2300 }
2301 }
2302 }
2303 } catch {
2304 profileReadmeHtml = null;
2305 }
2306
efb11c5Claude2307 const displayName = ownerUser?.displayName || ownerName;
2308 const memberSince = ownerUser?.createdAt
2309 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2310 : null;
2311
fc1817aClaude2312 return c.html(
06d5ffeClaude2313 <Layout title={ownerName} user={user}>
efb11c5Claude2314 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2315 <div class="profile-hero">
2316 <div class="profile-hero-orb-wrap" aria-hidden="true">
2317 <div class="profile-hero-orb" />
06d5ffeClaude2318 </div>
efb11c5Claude2319 <div class="profile-hero-inner">
2320 <div class="profile-hero-avatar" aria-hidden="true">
2321 {displayName[0].toUpperCase()}
2322 </div>
2323 <div class="profile-hero-text">
2324 <div class="profile-eyebrow">
2325 <strong>Developer</strong>
2326 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2327 <>
2328 {" "}· Joined{" "}
2329 {memberSince.toLocaleDateString("en-US", {
2330 month: "short",
2331 year: "numeric",
2332 })}
2333 </>
2334 )}
2335 </div>
2336 <h1 class="profile-name">
2337 <span class="gradient-text">{displayName}</span>
2338 </h1>
2339 <div class="profile-handle">@{ownerName}</div>
2340 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2341 <div class="profile-meta">
2342 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2343 <strong>{followState.followers}</strong> follower
2344 {followState.followers === 1 ? "" : "s"}
2345 </a>
2346 <a href={`/${ownerName}/following`} class="profile-meta-link">
2347 <strong>{followState.following}</strong> following
2348 </a>
2349 <a href={`/${ownerName}`} class="profile-meta-link">
2350 <strong>{repos.length}</strong> repo
2351 {repos.length === 1 ? "" : "s"}
2352 </a>
2353 {canFollow && (
2354 <form
2355 method="post"
2356 action={`/${ownerName}/${
2357 followState.viewerFollows ? "unfollow" : "follow"
2358 }`}
2359 class="profile-follow-form"
7aa8b99Claude2360 >
efb11c5Claude2361 <button
2362 type="submit"
2363 class={`btn ${
2364 followState.viewerFollows ? "" : "btn-primary"
2365 } btn-sm`}
2366 >
2367 {followState.viewerFollows ? "Unfollow" : "Follow"}
2368 </button>
2369 </form>
2370 )}
2371 </div>
7aa8b99Claude2372 </div>
06d5ffeClaude2373 </div>
2374 </div>
d412586Claude2375 {profileReadmeHtml && (
efb11c5Claude2376 <div class="profile-readme">
2377 <div class="profile-readme-head">
2378 <span class="profile-readme-icon">{"☰"}</span>
2379 <span>{ownerName}/{ownerName} README.md</span>
2380 </div>
2381 <div
2382 class="markdown-body profile-readme-body"
2383 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2384 />
2385 </div>
d412586Claude2386 )}
efb11c5Claude2387 <div class="profile-section-head">
2388 <h2 class="profile-section-title">Repositories</h2>
2389 <span class="profile-section-count">{repos.length}</span>
2390 </div>
06d5ffeClaude2391 {repos.length === 0 ? (
efb11c5Claude2392 <div class="profile-empty">
2393 <p class="profile-empty-text">
2394 No repositories yet
2395 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2396 </p>
2397 {user?.id === ownerUser?.id && (
2398 <a href="/new" class="btn btn-primary btn-sm">
2399 + Create your first
2400 </a>
2401 )}
2402 </div>
06d5ffeClaude2403 ) : (
2404 <div class="card-grid">
2405 {repos.map((repo) => (
2406 <RepoCard repo={repo} ownerName={ownerName} />
2407 ))}
2408 </div>
2409 )}
fc1817aClaude2410 </Layout>
2411 );
2412});
2413
06d5ffeClaude2414// Star/unstar a repo
2415web.post("/:owner/:repo/star", requireAuth, async (c) => {
2416 const { owner: ownerName, repo: repoName } = c.req.param();
2417 const user = c.get("user")!;
2418
2419 try {
2420 const [ownerUser] = await db
2421 .select()
2422 .from(users)
2423 .where(eq(users.username, ownerName))
2424 .limit(1);
2425 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2426
2427 const [repo] = await db
2428 .select()
2429 .from(repositories)
2430 .where(
2431 and(
2432 eq(repositories.ownerId, ownerUser.id),
2433 eq(repositories.name, repoName)
2434 )
2435 )
2436 .limit(1);
2437 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2438
2439 // Toggle star
2440 const [existing] = await db
2441 .select()
2442 .from(stars)
2443 .where(
2444 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2445 )
2446 .limit(1);
2447
2448 if (existing) {
2449 await db.delete(stars).where(eq(stars.id, existing.id));
2450 await db
2451 .update(repositories)
2452 .set({ starCount: Math.max(0, repo.starCount - 1) })
2453 .where(eq(repositories.id, repo.id));
2454 } else {
2455 await db.insert(stars).values({
2456 userId: user.id,
2457 repositoryId: repo.id,
2458 });
2459 await db
2460 .update(repositories)
2461 .set({ starCount: repo.starCount + 1 })
2462 .where(eq(repositories.id, repo.id));
2463 }
2464 } catch {
2465 // DB error — ignore
2466 }
2467
2468 return c.redirect(`/${ownerName}/${repoName}`);
2469});
2470
641aa42Claude2471// ---------------------------------------------------------------------------
2472// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2473// ---------------------------------------------------------------------------
2474const obCss = `
2475 .ob-card {
2476 position: relative;
2477 margin: 14px 0 18px;
6fd5915Claude2478 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2479 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2480 border-radius: 14px;
2481 overflow: hidden;
2482 }
2483 .ob-card::before {
2484 content: '';
2485 position: absolute;
2486 top: 0; left: 0; right: 0;
2487 height: 2px;
6fd5915Claude2488 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2489 pointer-events: none;
2490 }
2491 .ob-header {
2492 padding: 16px 20px 10px;
2493 border-bottom: 1px solid var(--border);
2494 }
2495 .ob-header h3 {
2496 font-size: 16px;
2497 font-weight: 700;
2498 margin: 0 0 4px;
2499 color: var(--text-strong);
2500 }
2501 .ob-header p {
2502 font-size: 13px;
2503 color: var(--text-muted);
2504 margin: 0;
2505 }
2506 .ob-sections {
2507 display: grid;
2508 grid-template-columns: repeat(3, 1fr);
2509 gap: 0;
2510 }
2511 @media (max-width: 760px) {
2512 .ob-sections { grid-template-columns: 1fr; }
2513 }
2514 .ob-section {
2515 padding: 14px 20px;
2516 border-right: 1px solid var(--border);
2517 }
2518 .ob-section:last-child { border-right: none; }
2519 .ob-section h4 {
2520 font-size: 12px;
2521 font-weight: 700;
2522 text-transform: uppercase;
2523 letter-spacing: 0.08em;
2524 color: var(--accent);
2525 margin: 0 0 8px;
2526 }
2527 .ob-preview {
2528 font-family: var(--font-mono);
2529 font-size: 11.5px;
2530 background: var(--bg);
2531 border: 1px solid var(--border);
2532 border-radius: 6px;
2533 padding: 8px 10px;
2534 color: var(--text-muted);
2535 line-height: 1.55;
2536 margin-bottom: 8px;
2537 white-space: pre-wrap;
2538 overflow: hidden;
2539 max-height: 80px;
2540 position: relative;
2541 }
2542 .ob-preview::after {
2543 content: '';
2544 position: absolute;
2545 bottom: 0; left: 0; right: 0;
2546 height: 24px;
2547 background: linear-gradient(transparent, var(--bg));
2548 pointer-events: none;
2549 }
2550 .ob-labels {
2551 display: flex;
2552 flex-wrap: wrap;
2553 gap: 5px;
2554 margin-bottom: 8px;
2555 }
2556 .ob-label-chip {
2557 display: inline-flex;
2558 align-items: center;
2559 padding: 2px 8px;
2560 border-radius: 9999px;
2561 font-size: 11.5px;
2562 font-weight: 600;
2563 line-height: 1.5;
2564 border: 1px solid transparent;
2565 }
2566 .ob-section ul {
2567 margin: 0;
2568 padding: 0 0 0 16px;
2569 font-size: 13px;
2570 color: var(--text-muted);
2571 line-height: 1.7;
2572 }
2573 .ob-section ul li { margin-bottom: 2px; }
2574 .ob-footer {
2575 display: flex;
2576 align-items: center;
2577 justify-content: flex-end;
2578 padding: 10px 20px;
2579 border-top: 1px solid var(--border);
2580 gap: 10px;
2581 }
2582 .ob-dismiss {
2583 appearance: none;
2584 background: transparent;
2585 border: 1px solid var(--border);
2586 color: var(--text-muted);
2587 border-radius: 6px;
2588 padding: 5px 12px;
2589 font-size: 12.5px;
2590 font-family: inherit;
2591 cursor: pointer;
2592 transition: background var(--t-fast), color var(--t-fast);
2593 }
2594 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2595 .btn-sm {
2596 appearance: none;
2597 background: var(--bg-elevated);
2598 border: 1px solid var(--border);
2599 color: var(--text-strong);
2600 border-radius: 6px;
2601 padding: 5px 12px;
2602 font-size: 12.5px;
2603 font-weight: 600;
2604 font-family: inherit;
2605 cursor: pointer;
2606 text-decoration: none;
2607 display: inline-flex;
2608 align-items: center;
2609 transition: background var(--t-fast);
2610 }
2611 .btn-sm:hover { background: var(--bg-hover); }
2612 .btn-sm.btn-primary {
2613 background: var(--accent);
2614 color: #fff;
2615 border-color: var(--accent);
2616 }
2617 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2618`;
2619
2620// Onboarding card component — shown to repo owner until dismissed
2621function RepoOnboardingCard({
2622 owner,
2623 repo,
2624 data,
2625}: {
2626 owner: string;
2627 repo: string;
2628 data: typeof repoOnboardingData.$inferSelect;
2629}) {
2630 const labels = (data.suggestedLabels ?? []) as Array<{
2631 name: string;
2632 color: string;
2633 description: string;
2634 }>;
2635 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2636 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2637
2638 return (
2639 <>
2640 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2641 <div class="ob-card" id="repo-onboarding">
2642 <div class="ob-header">
2643 <h3>Get started with {owner}/{repo}</h3>
2644 <p>
2645 Detected: {data.detectedLanguage ?? "Unknown"}
2646 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2647 Here&rsquo;s what we suggest to hit the ground running.
2648 </p>
2649 </div>
2650 <div class="ob-sections">
2651 <div class="ob-section">
2652 <h4>Suggested README</h4>
2653 <div class="ob-preview">{readmePreview}</div>
2654 <button
2655 class="btn-sm"
2656 type="button"
2657 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(_){};})()`}
2658 aria-label="Copy suggested README to clipboard"
2659 >
2660 Copy to clipboard
2661 </button>
2662 </div>
2663 <div class="ob-section">
2664 <h4>Suggested labels ({labels.length})</h4>
2665 <div class="ob-labels">
2666 {labels.slice(0, 6).map((l) => (
2667 <span
2668 class="ob-label-chip"
2669 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2670 title={l.description}
2671 >
2672 {l.name}
2673 </span>
2674 ))}
2675 </div>
2676 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2677 <button class="btn-sm btn-primary" type="submit">
2678 Create all labels
2679 </button>
2680 </form>
2681 </div>
2682 <div class="ob-section">
2683 <h4>First steps</h4>
2684 <ul>
2685 {suggestions.map((s) => (
2686 <li>{s}</li>
2687 ))}
2688 </ul>
2689 </div>
2690 </div>
2691 <div class="ob-footer">
2692 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2693 <button class="ob-dismiss" type="submit">
2694 Dismiss
2695 </button>
2696 </form>
2697 </div>
2698 <script
2699 dangerouslySetInnerHTML={{
2700 __html: `
2701 (function(){
2702 var card = document.getElementById('repo-onboarding');
2703 if (!card) return;
2704 document.addEventListener('keydown', function(e){
2705 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2706 card.style.display = 'none';
2707 }
2708 });
2709 })();
2710 `,
2711 }}
2712 />
2713 </div>
2714 </>
2715 );
2716}
2717
2718// ---------------------------------------------------------------------------
2719// Setup routes — create suggested labels + dismiss onboarding
2720// ---------------------------------------------------------------------------
2721
2722// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2723// requireAuth + write access. Idempotent (label UPSERT by name).
2724web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2725 const { owner, repo } = c.req.param();
2726 const user = c.get("user")!;
2727
2728 // Resolve repo + verify write access
2729 let repoRow: { id: string; ownerId: string } | null = null;
2730 try {
2731 const [ownerUser] = await db
2732 .select({ id: users.id })
2733 .from(users)
2734 .where(eq(users.username, owner))
2735 .limit(1);
2736 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2737 const [r] = await db
2738 .select({ id: repositories.id, ownerId: repositories.ownerId })
2739 .from(repositories)
2740 .where(
2741 and(
2742 eq(repositories.ownerId, ownerUser.id),
2743 eq(repositories.name, repo)
2744 )
2745 )
2746 .limit(1);
2747 repoRow = r ?? null;
2748 } catch {
2749 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2750 }
2751 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2752 if (repoRow.ownerId !== user.id) {
2753 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2754 }
2755
2756 // Fetch the onboarding data
2757 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2758 try {
2759 const [r] = await db
2760 .select()
2761 .from(repoOnboardingData)
2762 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2763 .limit(1);
2764 obRow = r ?? null;
2765 } catch {
2766 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2767 }
2768 if (!obRow) {
2769 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2770 }
2771
2772 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2773 name: string;
2774 color: string;
2775 description: string;
2776 }>;
2777
2778 // Create labels — import the labels table which was already imported
2779 let created = 0;
2780 for (const l of suggestedLabels) {
2781 try {
2782 await db
2783 .insert(labels)
2784 .values({
2785 repositoryId: repoRow.id,
2786 name: l.name,
2787 color: l.color,
2788 description: l.description ?? "",
2789 })
2790 .onConflictDoNothing();
2791 created++;
2792 } catch {
2793 /* skip duplicates */
2794 }
2795 }
2796
2797 // Mark onboarding dismissed
2798 try {
2799 await db
2800 .update(repositories)
2801 .set({ onboardingShown: true })
2802 .where(eq(repositories.id, repoRow.id));
2803 } catch {
2804 /* ignore */
2805 }
2806
2807 return c.redirect(
2808 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2809 );
2810});
2811
2812// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2813web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2814 const { owner, repo } = c.req.param();
2815 const user = c.get("user")!;
2816
2817 try {
2818 const [ownerUser] = await db
2819 .select({ id: users.id })
2820 .from(users)
2821 .where(eq(users.username, owner))
2822 .limit(1);
2823 if (ownerUser) {
2824 const [r] = await db
2825 .select({ id: repositories.id, ownerId: repositories.ownerId })
2826 .from(repositories)
2827 .where(
2828 and(
2829 eq(repositories.ownerId, ownerUser.id),
2830 eq(repositories.name, repo)
2831 )
2832 )
2833 .limit(1);
2834 if (r && r.ownerId === user.id) {
2835 await db
2836 .update(repositories)
2837 .set({ onboardingShown: true })
2838 .where(eq(repositories.id, r.id));
2839 }
2840 }
2841 } catch {
2842 /* swallow — dismiss should never fail visibly */
2843 }
2844
2845 return c.redirect(`/${owner}/${repo}`);
2846});
2847
11c3ab6ccanty labs2848// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2849web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2850 const { owner, repo } = c.req.param();
2851 const user = c.get("user");
5bb52faccanty labs2852 const gate = await assertRepoReadable(c, owner, repo);
2853 if (gate) return gate;
11c3ab6ccanty labs2854 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2855});
2856
2857// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2858web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2859 const { owner, repo } = c.req.param();
2860 const user = c.get("user");
5bb52faccanty labs2861 const gate = await assertRepoReadable(c, owner, repo);
2862 if (gate) return gate;
11c3ab6ccanty labs2863 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2864});
2865
fc1817aClaude2866// Repository overview — file tree at HEAD
2867web.get("/:owner/:repo", async (c) => {
2868 const { owner, repo } = c.req.param();
06d5ffeClaude2869 const user = c.get("user");
fc1817aClaude2870
5bb52faccanty labs2871 // SECURITY: gate private-repo content before any render (incl. skeleton).
2872 const gate = await assertRepoReadable(c, owner, repo);
2873 if (gate) return gate;
2874
f1dc7c7Claude2875 // ── Loading skeleton (flag-gated) ──
2876 // Renders an SSR'd shell with file-tree + README placeholders when
2877 // `?skeleton=1` is set. Lets the user see the page structure before
2878 // git ops finish. Behind a flag for now so we never flash before the
2879 // real content lands.
2880 if (c.req.query("skeleton") === "1") {
2881 return c.html(
2882 <Layout title={`${owner}/${repo}`} user={user}>
2883 <style
2884 dangerouslySetInnerHTML={{
2885 __html: `
2886 .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; }
2887 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2888 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2889 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2890 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2891 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2892 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2893 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2894 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2895 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2896 .repo-skel-readme { height: 320px; border-radius: 12px; }
2897 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2898 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2899 `,
2900 }}
2901 />
2902 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2903 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2904 <div class="repo-skel-grid" aria-hidden="true">
2905 <div>
2906 <div class="repo-skel repo-skel-branch" />
2907 <div class="repo-skel-tree">
2908 {Array.from({ length: 8 }).map(() => (
2909 <div class="repo-skel repo-skel-tree-row" />
2910 ))}
2911 </div>
2912 <div class="repo-skel repo-skel-readme" />
2913 </div>
2914 <aside class="repo-skel-side">
2915 <div class="repo-skel repo-skel-side-card" />
2916 <div class="repo-skel repo-skel-side-card" />
2917 </aside>
2918 </div>
2919 <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">
2920 Loading {owner}/{repo}…
2921 </span>
2922 </Layout>
2923 );
2924 }
2925
8f50ed0Claude2926 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2927 trackByName(owner, repo, "view", {
2928 userId: user?.id || null,
2929 path: `/${owner}/${repo}`,
2930 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2931 userAgent: c.req.header("user-agent") || null,
2932 referer: c.req.header("referer") || null,
a28cedeClaude2933 }).catch((err) => {
2934 console.warn(
2935 `[web] view tracking failed for ${owner}/${repo}:`,
2936 err instanceof Error ? err.message : err
2937 );
2938 });
8f50ed0Claude2939
fc1817aClaude2940 if (!(await repoExists(owner, repo))) {
2941 return c.html(
06d5ffeClaude2942 <Layout title="Not Found" user={user}>
fc1817aClaude2943 <div class="empty-state">
2944 <h2>Repository not found</h2>
2945 <p>
2946 {owner}/{repo} does not exist.
2947 </p>
2948 </div>
2949 </Layout>,
2950 404
2951 );
2952 }
2953
05b973eClaude2954 // Parallelize all independent operations
2955 const [defaultBranch, branches] = await Promise.all([
2956 getDefaultBranch(owner, repo).then((b) => b || "main"),
2957 listBranches(owner, repo),
2958 ]);
2959 const [tree, starInfo] = await Promise.all([
2960 getTree(owner, repo, defaultBranch),
2961 // Star info fetched in parallel with tree
2962 (async () => {
2963 try {
2964 const [ownerUser] = await db
2965 .select()
2966 .from(users)
2967 .where(eq(users.username, owner))
2968 .limit(1);
71cd5ecClaude2969 if (!ownerUser)
2970 return {
2971 starCount: 0,
2972 starred: false,
2973 archived: false,
2974 isTemplate: false,
544d842Claude2975 forkCount: 0,
2976 description: null as string | null,
2977 pushedAt: null as Date | null,
2978 createdAt: null as Date | null,
cb5a796Claude2979 repoId: null as string | null,
2980 repoOwnerId: null as string | null,
71cd5ecClaude2981 };
05b973eClaude2982 const [repoRow] = await db
2983 .select()
2984 .from(repositories)
2985 .where(
2986 and(
2987 eq(repositories.ownerId, ownerUser.id),
2988 eq(repositories.name, repo)
2989 )
06d5ffeClaude2990 )
05b973eClaude2991 .limit(1);
71cd5ecClaude2992 if (!repoRow)
2993 return {
2994 starCount: 0,
2995 starred: false,
2996 archived: false,
2997 isTemplate: false,
544d842Claude2998 forkCount: 0,
2999 description: null as string | null,
3000 pushedAt: null as Date | null,
3001 createdAt: null as Date | null,
cb5a796Claude3002 repoId: null as string | null,
3003 repoOwnerId: null as string | null,
71cd5ecClaude3004 };
05b973eClaude3005 let starred = false;
06d5ffeClaude3006 if (user) {
3007 const [star] = await db
3008 .select()
3009 .from(stars)
3010 .where(
3011 and(
3012 eq(stars.userId, user.id),
3013 eq(stars.repositoryId, repoRow.id)
3014 )
3015 )
3016 .limit(1);
3017 starred = !!star;
3018 }
71cd5ecClaude3019 return {
3020 starCount: repoRow.starCount,
3021 starred,
3022 archived: repoRow.isArchived,
3023 isTemplate: repoRow.isTemplate,
544d842Claude3024 forkCount: repoRow.forkCount,
3025 description: repoRow.description as string | null,
3026 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3027 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3028 repoId: repoRow.id as string,
3029 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3030 };
05b973eClaude3031 } catch {
71cd5ecClaude3032 return {
3033 starCount: 0,
3034 starred: false,
3035 archived: false,
3036 isTemplate: false,
544d842Claude3037 forkCount: 0,
3038 description: null as string | null,
3039 pushedAt: null as Date | null,
3040 createdAt: null as Date | null,
cb5a796Claude3041 repoId: null as string | null,
3042 repoOwnerId: null as string | null,
71cd5ecClaude3043 };
06d5ffeClaude3044 }
05b973eClaude3045 })(),
3046 ]);
544d842Claude3047 const {
3048 starCount,
3049 starred,
3050 archived,
3051 isTemplate,
3052 forkCount,
3053 description,
3054 pushedAt,
3055 createdAt,
cb5a796Claude3056 repoId,
3057 repoOwnerId,
544d842Claude3058 } = starInfo;
3059
91a0204Claude3060 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3061 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3062 // and the badge simply doesn't render.
3063 let healthScore: HealthScore | null = null;
3064 if (repoId) {
3065 try {
3066 healthScore = await computeHealthScore(repoId);
3067 } catch {
3068 // swallow — badge is optional
3069 }
3070 }
3071
cb5a796Claude3072 // Pending-comments banner data (lazy + best-effort). Only the repo
3073 // owner sees the banner, so non-owner views skip the DB hit entirely.
3074 let repoHomePendingCount = 0;
3075 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3076 try {
3077 const { countPendingForRepo } = await import(
3078 "../lib/comment-moderation"
3079 );
3080 repoHomePendingCount = await countPendingForRepo(repoId);
3081 } catch {
3082 /* swallow */
3083 }
3084 }
3085
8c790e0Claude3086 // Push Watch discoverability — fetch the most recent push within 24 h.
3087 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3088 const recentPush: RecentPush | null = repoId
3089 ? await getRecentPush(repoId)
3090 : null;
3091
ebaae0fClaude3092 // AI stats strip — best-effort, fail-open to zero values.
3093 let aiStats: RepoAiStats = {
3094 mergedCount: 0,
3095 reviewCount: 0,
3096 securityAlertCount: 0,
3097 hoursSaved: "0.0",
3098 };
3099 if (repoId) {
3100 try {
3101 aiStats = await getRepoAiStats(repoId);
3102 } catch {
3103 /* swallow — show zero values */
3104 }
3105 }
3106
641aa42Claude3107 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3108 // if the DB is down the card simply won't appear. Only the owner sees it.
3109 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3110 let showOnboarding = false;
3111 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3112 try {
3113 // Check if onboarding_shown is still false (not yet dismissed)
3114 const [repoRow2] = await db
3115 .select({ onboardingShown: repositories.onboardingShown })
3116 .from(repositories)
3117 .where(eq(repositories.id, repoId))
3118 .limit(1);
3119 if (repoRow2 && !repoRow2.onboardingShown) {
3120 const [obRow] = await db
3121 .select()
3122 .from(repoOnboardingData)
3123 .where(eq(repoOnboardingData.repositoryId, repoId))
3124 .limit(1);
3125 onboardingRow = obRow ?? null;
3126 showOnboarding = !!onboardingRow;
3127 }
3128 } catch {
3129 /* swallow — onboarding is optional */
3130 }
3131 }
3132
544d842Claude3133 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3134 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3135 const repoHomeCss = `
3136 .repo-home-hero {
3137 position: relative;
3138 margin-bottom: var(--space-5);
3139 padding: var(--space-5) var(--space-6);
3140 background: var(--bg-elevated);
3141 border: 1px solid var(--border);
3142 border-radius: 16px;
3143 overflow: hidden;
3144 }
3145 .repo-home-hero::before {
3146 content: '';
3147 position: absolute;
3148 top: 0; left: 0; right: 0;
3149 height: 2px;
6fd5915Claude3150 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3151 opacity: 0.7;
3152 pointer-events: none;
3153 }
3154 .repo-home-hero-orb-wrap {
3155 position: absolute;
3156 inset: -25% -10% auto auto;
3157 width: 360px;
3158 height: 360px;
3159 pointer-events: none;
3160 z-index: 0;
3161 }
3162 .repo-home-hero-orb {
3163 position: absolute;
3164 inset: 0;
6fd5915Claude3165 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3166 filter: blur(80px);
3167 opacity: 0.7;
3168 animation: repoHomeOrb 14s ease-in-out infinite;
3169 }
3170 @keyframes repoHomeOrb {
3171 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3172 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3173 }
3174 @media (prefers-reduced-motion: reduce) {
3175 .repo-home-hero-orb { animation: none; }
3176 }
3177 .repo-home-hero-inner {
3178 position: relative;
3179 z-index: 1;
3180 }
3181 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3182 .repo-home-eyebrow {
3183 font-size: 12px;
3184 font-family: var(--font-mono);
3185 color: var(--text-muted);
3186 letter-spacing: 0.1em;
3187 text-transform: uppercase;
3188 margin-bottom: var(--space-2);
3189 }
3190 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3191 .repo-home-description {
3192 font-size: 15px;
3193 line-height: 1.55;
3194 color: var(--text);
3195 margin: 0;
3196 max-width: 720px;
3197 }
3198 .repo-home-description-empty {
3199 font-size: 14px;
3200 color: var(--text-muted);
3201 font-style: italic;
3202 margin: 0;
3203 }
3204 .repo-home-stat-row {
3205 display: flex;
3206 flex-wrap: wrap;
3207 gap: var(--space-4);
3208 margin-top: var(--space-3);
3209 font-size: 13px;
3210 color: var(--text-muted);
3211 }
3212 .repo-home-stat {
3213 display: inline-flex;
3214 align-items: center;
3215 gap: 6px;
3216 }
3217 .repo-home-stat strong {
3218 color: var(--text-strong);
3219 font-weight: 600;
3220 font-variant-numeric: tabular-nums;
3221 }
3222 .repo-home-stat .repo-home-stat-icon {
3223 color: var(--text-faint);
3224 font-size: 14px;
3225 line-height: 1;
3226 }
3227 .repo-home-stat a {
3228 color: var(--text-muted);
3229 transition: color var(--t-fast) var(--ease);
3230 }
3231 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3232
3233 /* Two-column layout: file tree + sidebar */
3234 .repo-home-grid {
3235 display: grid;
3236 grid-template-columns: minmax(0, 1fr) 280px;
3237 gap: var(--space-5);
3238 align-items: start;
3239 }
3240 @media (max-width: 960px) {
3241 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3242 }
3243 .repo-home-main { min-width: 0; }
3244
3245 /* Sidebar card */
3246 .repo-home-side {
3247 display: flex;
3248 flex-direction: column;
3249 gap: var(--space-4);
3250 }
3251 .repo-home-side-card {
3252 background: var(--bg-elevated);
3253 border: 1px solid var(--border);
3254 border-radius: 12px;
3255 padding: var(--space-4);
3256 }
3257 .repo-home-side-title {
3258 font-size: 11px;
3259 font-family: var(--font-mono);
3260 letter-spacing: 0.12em;
3261 text-transform: uppercase;
3262 color: var(--text-muted);
3263 margin: 0 0 var(--space-3);
3264 font-weight: 600;
3265 }
3266 .repo-home-side-row {
3267 display: flex;
3268 justify-content: space-between;
3269 align-items: center;
3270 gap: var(--space-2);
3271 font-size: 13px;
3272 padding: 6px 0;
3273 border-top: 1px solid var(--border);
3274 }
3275 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3276 .repo-home-side-key {
3277 color: var(--text-muted);
3278 display: inline-flex;
3279 align-items: center;
3280 gap: 6px;
3281 }
3282 .repo-home-side-val {
3283 color: var(--text-strong);
3284 font-weight: 500;
3285 font-variant-numeric: tabular-nums;
3286 max-width: 60%;
3287 text-align: right;
3288 overflow: hidden;
3289 text-overflow: ellipsis;
3290 white-space: nowrap;
3291 }
3292 .repo-home-side-val a { color: var(--text-strong); }
3293 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3294
3295 /* Clone / Code tabs */
3296 .repo-home-clone {
3297 background: var(--bg-elevated);
3298 border: 1px solid var(--border);
3299 border-radius: 12px;
3300 overflow: hidden;
3301 }
3302 .repo-home-clone-tabs {
3303 display: flex;
3304 gap: 0;
3305 background: var(--bg-secondary);
3306 border-bottom: 1px solid var(--border);
3307 padding: 0 var(--space-2);
3308 }
3309 .repo-home-clone-tab {
3310 appearance: none;
3311 background: transparent;
3312 border: 0;
3313 border-bottom: 2px solid transparent;
3314 padding: 9px 12px;
3315 font-size: 12px;
3316 font-weight: 500;
3317 color: var(--text-muted);
3318 cursor: pointer;
3319 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3320 font-family: inherit;
3321 margin-bottom: -1px;
3322 }
3323 .repo-home-clone-tab:hover { color: var(--text-strong); }
3324 .repo-home-clone-tab[aria-selected="true"] {
3325 color: var(--text-strong);
3326 border-bottom-color: var(--accent);
3327 }
3328 .repo-home-clone-body {
3329 padding: var(--space-3);
3330 display: flex;
3331 align-items: center;
3332 gap: var(--space-2);
3333 }
3334 .repo-home-clone-input {
3335 flex: 1;
3336 min-width: 0;
3337 font-family: var(--font-mono);
3338 font-size: 12px;
3339 background: var(--bg);
3340 border: 1px solid var(--border);
3341 border-radius: 8px;
3342 padding: 8px 10px;
3343 color: var(--text-strong);
3344 overflow: hidden;
3345 text-overflow: ellipsis;
3346 white-space: nowrap;
3347 }
3348 .repo-home-clone-copy {
3349 appearance: none;
3350 background: var(--bg-secondary);
3351 border: 1px solid var(--border);
3352 color: var(--text-strong);
3353 border-radius: 8px;
3354 padding: 8px 12px;
3355 font-size: 12px;
3356 font-weight: 600;
3357 cursor: pointer;
3358 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3359 font-family: inherit;
3360 }
3361 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3362 .repo-home-clone-pane { display: none; }
3363 .repo-home-clone-pane[data-active="true"] { display: flex; }
3364
3365 /* README card */
3366 .repo-home-readme {
3367 margin-top: var(--space-5);
3368 background: var(--bg-elevated);
3369 border: 1px solid var(--border);
3370 border-radius: 12px;
3371 overflow: hidden;
3372 }
3373 .repo-home-readme-head {
3374 display: flex;
3375 align-items: center;
3376 gap: 8px;
3377 padding: 10px 16px;
3378 background: var(--bg-secondary);
3379 border-bottom: 1px solid var(--border);
3380 font-size: 13px;
3381 color: var(--text-muted);
3382 }
3383 .repo-home-readme-head .repo-home-readme-icon {
3384 color: var(--accent);
3385 font-size: 14px;
3386 }
3387 .repo-home-readme-body {
3388 padding: var(--space-5) var(--space-6);
3389 }
3390
3391 /* Empty-state CTA */
3392 .repo-home-empty {
3393 position: relative;
3394 margin-top: var(--space-4);
3395 background: var(--bg-elevated);
3396 border: 1px solid var(--border);
3397 border-radius: 16px;
3398 padding: var(--space-6);
3399 overflow: hidden;
3400 }
3401 .repo-home-empty::before {
3402 content: '';
3403 position: absolute;
3404 top: 0; left: 0; right: 0;
3405 height: 2px;
6fd5915Claude3406 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3407 opacity: 0.7;
3408 pointer-events: none;
3409 }
3410 .repo-home-empty-eyebrow {
3411 font-size: 12px;
3412 font-family: var(--font-mono);
3413 color: var(--accent);
3414 letter-spacing: 0.12em;
3415 text-transform: uppercase;
3416 margin-bottom: var(--space-2);
3417 font-weight: 600;
3418 }
3419 .repo-home-empty-title {
3420 font-family: var(--font-display);
3421 font-weight: 800;
3422 letter-spacing: -0.025em;
3423 font-size: clamp(22px, 3vw, 30px);
3424 line-height: 1.1;
3425 margin: 0 0 var(--space-2);
3426 color: var(--text-strong);
3427 }
3428 .repo-home-empty-sub {
3429 color: var(--text-muted);
3430 font-size: 14px;
3431 line-height: 1.55;
3432 max-width: 640px;
3433 margin: 0 0 var(--space-4);
3434 }
3435 .repo-home-empty-snippet {
3436 background: var(--bg);
3437 border: 1px solid var(--border);
3438 border-radius: 10px;
3439 padding: var(--space-3) var(--space-4);
3440 font-family: var(--font-mono);
3441 font-size: 12.5px;
3442 line-height: 1.7;
3443 color: var(--text-strong);
3444 overflow-x: auto;
3445 white-space: pre;
3446 margin: 0;
3447 }
3448 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3449 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3450
91a0204Claude3451 /* Health score badge */
3452 .repo-health-badge {
3453 display: inline-flex;
3454 align-items: center;
3455 gap: 5px;
3456 padding: 3px 10px;
3457 border-radius: 999px;
3458 font-size: 11.5px;
3459 font-weight: 700;
3460 font-family: var(--font-mono);
3461 text-decoration: none;
3462 border: 1px solid transparent;
3463 transition: filter 120ms ease, opacity 120ms ease;
3464 vertical-align: middle;
3465 }
3466 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3467 .repo-health-badge-elite {
3468 background: rgba(52,211,153,0.15);
3469 color: #6ee7b7;
3470 border-color: rgba(52,211,153,0.30);
3471 }
3472 .repo-health-badge-strong {
3473 background: rgba(96,165,250,0.15);
3474 color: #93c5fd;
3475 border-color: rgba(96,165,250,0.30);
3476 }
3477 .repo-health-badge-improving {
3478 background: rgba(251,191,36,0.15);
3479 color: #fde68a;
3480 border-color: rgba(251,191,36,0.30);
3481 }
3482 .repo-health-badge-needs-attention {
3483 background: rgba(248,113,113,0.15);
3484 color: #fca5a5;
3485 border-color: rgba(248,113,113,0.30);
3486 }
3487
3488 /* Three-option empty-state panel */
3489 .repo-empty-options {
3490 display: grid;
3491 grid-template-columns: repeat(3, 1fr);
3492 gap: var(--space-3);
3493 margin-top: var(--space-5);
3494 }
3495 @media (max-width: 760px) {
3496 .repo-empty-options { grid-template-columns: 1fr; }
3497 }
3498 .repo-empty-option {
3499 position: relative;
3500 background: var(--bg-elevated);
3501 border: 1px solid var(--border);
3502 border-radius: 14px;
3503 padding: var(--space-4) var(--space-4);
3504 display: flex;
3505 flex-direction: column;
3506 gap: var(--space-2);
3507 overflow: hidden;
3508 transition: border-color 140ms ease, background 140ms ease;
3509 }
3510 .repo-empty-option:hover {
6fd5915Claude3511 border-color: rgba(91,110,232,0.45);
3512 background: rgba(91,110,232,0.04);
91a0204Claude3513 }
3514 .repo-empty-option::before {
3515 content: '';
3516 position: absolute;
3517 top: 0; left: 0; right: 0;
3518 height: 2px;
6fd5915Claude3519 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3520 opacity: 0.55;
3521 pointer-events: none;
3522 }
3523 .repo-empty-option-label {
3524 font-size: 10px;
3525 font-family: var(--font-mono);
3526 font-weight: 700;
3527 letter-spacing: 0.12em;
3528 text-transform: uppercase;
3529 color: var(--accent);
3530 margin-bottom: 2px;
3531 }
3532 .repo-empty-option-title {
3533 font-family: var(--font-display);
3534 font-weight: 700;
3535 font-size: 16px;
3536 letter-spacing: -0.01em;
3537 color: var(--text-strong);
3538 margin: 0;
3539 }
3540 .repo-empty-option-sub {
3541 font-size: 13px;
3542 color: var(--text-muted);
3543 line-height: 1.5;
3544 margin: 0;
3545 flex: 1;
3546 }
3547 .repo-empty-option-snippet {
3548 background: var(--bg);
3549 border: 1px solid var(--border);
3550 border-radius: 8px;
3551 padding: var(--space-2) var(--space-3);
3552 font-family: var(--font-mono);
3553 font-size: 11.5px;
3554 line-height: 1.75;
3555 color: var(--text-strong);
3556 overflow-x: auto;
3557 white-space: pre;
3558 margin: var(--space-1) 0 0;
3559 }
3560 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3561 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3562 .repo-empty-option-cta {
3563 display: inline-flex;
3564 align-items: center;
3565 gap: 6px;
3566 margin-top: auto;
3567 padding-top: var(--space-2);
3568 font-size: 13px;
3569 font-weight: 600;
3570 color: var(--accent);
3571 text-decoration: none;
3572 transition: color 120ms ease;
3573 }
3574 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3575
544d842Claude3576 @media (max-width: 720px) {
3577 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3578 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3579 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3580 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3581 .repo-home-side-val { max-width: 55%; }
3582 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3583 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3584 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3585 }
ebaae0fClaude3586
3587 /* AI stats strip */
3588 .repo-ai-stats-strip {
3589 display: flex;
3590 align-items: center;
3591 gap: 6px;
3592 margin-top: 12px;
3593 margin-bottom: 4px;
3594 font-size: 12px;
3595 color: var(--text-muted);
3596 flex-wrap: wrap;
3597 }
3598 .repo-ai-stats-strip a {
3599 color: var(--text-muted);
3600 text-decoration: underline;
3601 text-decoration-color: transparent;
3602 transition: color 120ms ease, text-decoration-color 120ms ease;
3603 }
3604 .repo-ai-stats-strip a:hover {
3605 color: var(--accent);
3606 text-decoration-color: currentColor;
3607 }
3608 .repo-ai-stats-sep {
3609 opacity: 0.4;
3610 user-select: none;
3611 }
544d842Claude3612 `;
3613 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3614 // SSH URL — port-aware:
3615 // Standard port 22 → git@host:owner/repo.git
3616 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3617 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3618 try {
3619 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3620 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3621 ? host
3622 : "localhost";
3623 const sshPort = config.sshPort;
3624 if (sshPort === 22) {
3625 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3626 } else {
3627 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3628 }
3629 } catch {
3630 // Fall through to default.
3631 }
3632 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3633 const formatRelative = (date: Date | null): string => {
3634 if (!date) return "never";
3635 const ms = Date.now() - date.getTime();
3636 const s = Math.max(0, Math.round(ms / 1000));
3637 if (s < 60) return "just now";
3638 const m = Math.round(s / 60);
3639 if (m < 60) return `${m} min ago`;
3640 const h = Math.round(m / 60);
3641 if (h < 24) return `${h}h ago`;
3642 const d = Math.round(h / 24);
3643 if (d < 30) return `${d}d ago`;
3644 const mo = Math.round(d / 30);
3645 if (mo < 12) return `${mo}mo ago`;
3646 const y = Math.round(d / 365);
3647 return `${y}y ago`;
3648 };
06d5ffeClaude3649
fc1817aClaude3650 if (tree.length === 0) {
5acce80Claude3651 const repoOgDesc = description
3652 ? `${owner}/${repo} on Gluecron — ${description}`
3653 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3654 return c.html(
5acce80Claude3655 <Layout
3656 title={`${owner}/${repo}`}
3657 user={user}
3658 description={repoOgDesc}
3659 ogTitle={`${owner}/${repo} — Gluecron`}
3660 ogDescription={repoOgDesc}
3661 twitterCard="summary"
3662 >
544d842Claude3663 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3664 <style dangerouslySetInnerHTML={{ __html: `
3665 .empty-options-grid {
3666 display: grid;
3667 grid-template-columns: repeat(3, 1fr);
3668 gap: var(--space-4);
3669 margin-top: var(--space-4);
3670 }
3671 @media (max-width: 800px) {
3672 .empty-options-grid { grid-template-columns: 1fr; }
3673 }
3674 .empty-option-card {
3675 position: relative;
3676 background: var(--bg-elevated);
3677 border: 1px solid var(--border);
3678 border-radius: 14px;
3679 padding: var(--space-5);
3680 display: flex;
3681 flex-direction: column;
3682 gap: var(--space-3);
3683 overflow: hidden;
3684 transition: border-color 140ms ease, box-shadow 140ms ease;
3685 }
3686 .empty-option-card:hover {
6fd5915Claude3687 border-color: rgba(91,110,232,0.45);
3688 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3689 }
3690 .empty-option-card::before {
3691 content: '';
3692 position: absolute;
3693 top: 0; left: 0; right: 0;
3694 height: 2px;
6fd5915Claude3695 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3696 opacity: 0.55;
3697 pointer-events: none;
3698 }
3699 .empty-option-label {
3700 font-size: 10.5px;
3701 font-family: var(--font-mono);
3702 text-transform: uppercase;
3703 letter-spacing: 0.14em;
3704 color: var(--accent);
3705 font-weight: 700;
3706 }
3707 .empty-option-title {
3708 font-family: var(--font-display);
3709 font-weight: 700;
3710 font-size: 17px;
3711 letter-spacing: -0.01em;
3712 color: var(--text-strong);
3713 margin: 0;
3714 line-height: 1.25;
3715 }
3716 .empty-option-body {
3717 font-size: 13px;
3718 color: var(--text-muted);
3719 line-height: 1.55;
3720 margin: 0;
3721 flex: 1;
3722 }
3723 .empty-option-snippet {
3724 background: var(--bg);
3725 border: 1px solid var(--border);
3726 border-radius: 8px;
3727 padding: var(--space-3) var(--space-4);
3728 font-family: var(--font-mono);
3729 font-size: 11.5px;
3730 line-height: 1.75;
3731 color: var(--text-strong);
3732 overflow-x: auto;
3733 white-space: pre;
3734 margin: 0;
3735 }
3736 .empty-option-snippet .ec { color: var(--text-faint); }
3737 .empty-option-snippet .em { color: var(--accent); }
3738 .empty-option-cta {
3739 display: inline-flex;
3740 align-items: center;
3741 gap: 6px;
3742 padding: 8px 16px;
3743 font-size: 13px;
3744 font-weight: 600;
3745 color: var(--text-strong);
3746 background: var(--bg-secondary);
3747 border: 1px solid var(--border);
3748 border-radius: 9999px;
3749 text-decoration: none;
3750 align-self: flex-start;
3751 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3752 }
3753 .empty-option-cta:hover {
6fd5915Claude3754 border-color: rgba(91,110,232,0.55);
3755 background: rgba(91,110,232,0.08);
91a0204Claude3756 color: var(--accent);
3757 text-decoration: none;
3758 }
3759 .empty-option-cta-accent {
6fd5915Claude3760 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
91a0204Claude3761 border-color: transparent;
3762 color: #fff;
3763 }
3764 .empty-option-cta-accent:hover {
3765 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3766 border-color: transparent;
3767 color: #fff;
6fd5915Claude3768 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.55);
91a0204Claude3769 }
3770 ` }} />
544d842Claude3771 <div class="repo-home-hero">
3772 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3773 <div class="repo-home-hero-orb" />
3774 </div>
3775 <div class="repo-home-hero-inner">
3776 <div class="repo-home-eyebrow">
3777 <strong>Repository</strong> · {owner}
3778 </div>
91a0204Claude3779 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3780 <RepoHeader
3781 owner={owner}
3782 repo={repo}
3783 starCount={starCount}
3784 starred={starred}
3785 forkCount={forkCount}
3786 currentUser={user?.username}
3787 archived={archived}
3788 isTemplate={isTemplate}
8c790e0Claude3789 recentPush={recentPush}
91a0204Claude3790 />
3791 {healthScore && (() => {
3792 const gradeLabel: Record<string, string> = {
3793 elite: "Elite", strong: "Strong",
3794 improving: "Improving", "needs-attention": "Needs Attention",
3795 };
3796 return (
3797 <a
3798 href={`/${owner}/${repo}/insights/health`}
3799 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3800 title={`Health score: ${healthScore!.total}/100`}
3801 >
3802 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3803 </a>
3804 );
3805 })()}
3806 </div>
544d842Claude3807 {description ? (
3808 <p class="repo-home-description">{description}</p>
3809 ) : (
3810 <p class="repo-home-description-empty">
3811 No description yet — push a README to tell the world what this
3812 ships.
3813 </p>
3814 )}
3815 </div>
3816 </div>
fc1817aClaude3817 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3818 <div style="margin-top:var(--space-4)">
3819 <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)">
3820 Getting started
3821 </div>
544d842Claude3822 <h2 class="repo-home-empty-title">
91a0204Claude3823 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3824 </h2>
3825 <p class="repo-home-empty-sub">
91a0204Claude3826 Choose how you want to kick things off. Every push wires up gate
3827 checks and AI review automatically.
544d842Claude3828 </p>
91a0204Claude3829 <div class="empty-options-grid">
3830 <div class="empty-option-card">
3831 <div class="empty-option-label">Option A</div>
3832 <h3 class="empty-option-title">Push your first commit</h3>
3833 <p class="empty-option-body">
3834 Wire up an existing project in seconds. Run these commands from
3835 your project directory.
3836 </p>
3837 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3838<span class="em">git remote add</span> origin {cloneHttpsUrl}
3839<span class="em">git branch</span> -M main
3840<span class="em">git push</span> -u origin main</pre>
3841 </div>
3842 <div class="empty-option-card">
3843 <div class="empty-option-label">Option B</div>
3844 <h3 class="empty-option-title">Import from GitHub</h3>
3845 <p class="empty-option-body">
3846 Mirror an existing GitHub repository here in one click. Gluecron
3847 syncs the full history and branches.
3848 </p>
3849 <a href="/import" class="empty-option-cta">
3850 Import repository
3851 </a>
3852 </div>
3853 <div class="empty-option-card">
3854 <div class="empty-option-label">Option C</div>
3855 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3856 <p class="empty-option-body">
3857 Let AI write your first feature. Describe what you want to build
3858 and Gluecron opens a pull request with the code.
3859 </p>
3860 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3861 Let AI write your first feature
3862 </a>
3863 </div>
3864 </div>
fc1817aClaude3865 </div>
3866 </Layout>
3867 );
3868 }
3869
3870 const readme = await getReadme(owner, repo, defaultBranch);
3871
544d842Claude3872 // Sidebar facts — derived from data we already have.
3873 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3874 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3875
5acce80Claude3876 const repoOgDesc = description
3877 ? `${owner}/${repo} on Gluecron — ${description}`
3878 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3879
fc1817aClaude3880 return c.html(
5acce80Claude3881 <Layout
3882 title={`${owner}/${repo}`}
3883 user={user}
3884 description={repoOgDesc}
3885 ogTitle={`${owner}/${repo} — Gluecron`}
3886 ogDescription={repoOgDesc}
3887 twitterCard="summary"
3888 >
544d842Claude3889 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3890 {/* Repo-context commands for the command palette (Feature 2) */}
3891 <script
3892 id="cmdk-repo-context"
3893 dangerouslySetInnerHTML={{
3894 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3895 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3896 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3897 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3898 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3899 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3900 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3901 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3902 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3903 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3904 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3905 ])};`,
3906 }}
3907 />
544d842Claude3908 <div class="repo-home-hero">
3909 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3910 <div class="repo-home-hero-orb" />
3911 </div>
3912 <div class="repo-home-hero-inner">
3913 <div class="repo-home-eyebrow">
3914 <strong>Repository</strong> · {owner}
3915 </div>
91a0204Claude3916 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3917 <RepoHeader
3918 owner={owner}
3919 repo={repo}
3920 starCount={starCount}
3921 starred={starred}
3922 forkCount={forkCount}
3923 currentUser={user?.username}
3924 archived={archived}
3925 isTemplate={isTemplate}
8c790e0Claude3926 recentPush={recentPush}
91a0204Claude3927 />
3928 {healthScore && (() => {
3929 const gradeLabel: Record<string, string> = {
3930 elite: "Elite", strong: "Strong",
3931 improving: "Improving", "needs-attention": "Needs Attention",
3932 };
3933 return (
3934 <a
3935 href={`/${owner}/${repo}/insights/health`}
3936 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3937 title={`Health score: ${healthScore!.total}/100`}
3938 >
3939 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3940 </a>
3941 );
3942 })()}
3943 </div>
544d842Claude3944 {description ? (
3945 <p class="repo-home-description">{description}</p>
3946 ) : (
3947 <p class="repo-home-description-empty">
3948 No description yet.
3949 </p>
3950 )}
3951 <div class="repo-home-stat-row" aria-label="Repository stats">
3952 <span class="repo-home-stat" title="Default branch">
3953 <span class="repo-home-stat-icon">{"⎇"}</span>
3954 <strong>{defaultBranch}</strong>
3955 </span>
3956 <a
3957 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3958 class="repo-home-stat"
3959 title="Browse all branches"
3960 >
3961 <span class="repo-home-stat-icon">{"⊢"}</span>
3962 <strong>{branches.length}</strong>{" "}
3963 branch{branches.length === 1 ? "" : "es"}
3964 </a>
3965 <span class="repo-home-stat" title="Top-level entries">
3966 <span class="repo-home-stat-icon">{"■"}</span>
3967 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3968 {dirCount > 0 && (
3969 <>
3970 {" · "}
3971 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3972 </>
3973 )}
3974 </span>
3975 {pushedAt && (
3976 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3977 <span class="repo-home-stat-icon">{"↻"}</span>
3978 Updated <strong>{formatRelative(pushedAt)}</strong>
3979 </span>
3980 )}
3981 </div>
3982 </div>
3983 </div>
71cd5ecClaude3984 {isTemplate && user && user.username !== owner && (
3985 <div
3986 class="panel"
dc26881CC LABS App3987 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3988 >
3989 <div style="font-size:13px">
3990 <strong>Template repository.</strong> Create a new repository from
3991 this template's files.
3992 </div>
3993 <form
001af43Claude3994 method="post"
71cd5ecClaude3995 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3996 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3997 >
3998 <input
3999 type="text"
4000 name="name"
4001 placeholder="new-repo-name"
4002 required
2c3ba6ecopilot-swe-agent[bot]4003 aria-label="New repository name"
71cd5ecClaude4004 style="width:200px"
4005 />
4006 <button type="submit" class="btn btn-primary">
4007 Use this template
4008 </button>
4009 </form>
4010 </div>
4011 )}
fc1817aClaude4012 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4013 <RepoHomePendingBanner
4014 owner={owner}
4015 repo={repo}
4016 count={repoHomePendingCount}
4017 />
641aa42Claude4018 {showOnboarding && onboardingRow && (
4019 <RepoOnboardingCard
4020 owner={owner}
4021 repo={repo}
4022 data={onboardingRow}
4023 />
4024 )}
c6018a5Claude4025 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4026 row sits just below the nav as a slim CTA strip. Scoped under
4027 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4028 <style
4029 dangerouslySetInnerHTML={{
4030 __html: `
4031 .repo-ai-cta-row {
4032 display: flex;
4033 flex-wrap: wrap;
4034 gap: 8px;
4035 margin: 12px 0 18px;
4036 padding: 10px 14px;
6fd5915Claude4037 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4038 border: 1px solid var(--border);
4039 border-radius: 10px;
4040 position: relative;
4041 overflow: hidden;
4042 }
4043 .repo-ai-cta-row::before {
4044 content: '';
4045 position: absolute;
4046 top: 0; left: 0; right: 0;
4047 height: 1px;
6fd5915Claude4048 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4049 opacity: 0.7;
4050 pointer-events: none;
4051 }
4052 .repo-ai-cta-label {
4053 font-size: 11px;
4054 font-weight: 600;
4055 letter-spacing: 0.06em;
4056 text-transform: uppercase;
4057 color: var(--accent);
4058 align-self: center;
4059 padding-right: 8px;
4060 border-right: 1px solid var(--border);
4061 margin-right: 4px;
4062 }
4063 .repo-ai-cta {
4064 display: inline-flex;
4065 align-items: center;
4066 gap: 6px;
4067 padding: 5px 10px;
4068 font-size: 12.5px;
4069 font-weight: 500;
4070 color: var(--text);
4071 background: var(--bg);
4072 border: 1px solid var(--border);
4073 border-radius: 7px;
4074 text-decoration: none;
4075 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4076 }
4077 .repo-ai-cta:hover {
6fd5915Claude4078 border-color: rgba(91,110,232,0.45);
c6018a5Claude4079 color: var(--text-strong);
6fd5915Claude4080 background: rgba(91,110,232,0.06);
c6018a5Claude4081 text-decoration: none;
4082 }
4083 .repo-ai-cta-icon {
4084 opacity: 0.75;
4085 font-size: 12px;
4086 }
4087 @media (max-width: 640px) {
4088 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4089 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4090 }
4091 `,
4092 }}
4093 />
4094 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4095 <span class="repo-ai-cta-label">AI surfaces</span>
4096 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4097 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4098 Chat
4099 </a>
4100 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4101 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4102 Previews
4103 </a>
4104 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4105 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4106 Migrations
4107 </a>
53c9249Claude4108 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4109 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4110 AI Search
c6018a5Claude4111 </a>
4112 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4113 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4114 AI release notes
4115 </a>
3646bfeClaude4116 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4117 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4118 Dev environment
4119 </a>
c6018a5Claude4120 </nav>
544d842Claude4121 <div class="repo-home-grid">
4122 <div class="repo-home-main">
4123 <BranchSwitcher
4124 owner={owner}
4125 repo={repo}
4126 currentRef={defaultBranch}
4127 branches={branches}
4128 pathType="tree"
4129 />
4130 <FileTable
4131 entries={tree}
4132 owner={owner}
4133 repo={repo}
4134 ref={defaultBranch}
4135 path=""
4136 />
ebaae0fClaude4137 {/* AI stats strip — one-line summary of AI value for this repo */}
4138 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4139 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4140 <span>{"⚡"}</span>
4141 {aiStats.mergedCount > 0 ? (
4142 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4143 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4144 </a>
4145 ) : (
4146 <span>0 AI merges this week</span>
4147 )}
4148 <span class="repo-ai-stats-sep">{"·"}</span>
4149 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4150 <span class="repo-ai-stats-sep">{"·"}</span>
4151 {aiStats.securityAlertCount > 0 ? (
4152 <a href={`/${owner}/${repo}/issues?label=security`}>
4153 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4154 </a>
4155 ) : (
4156 <span>0 open security alerts</span>
4157 )}
4158 </div>
4159 ) : (
4160 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4161 <span>{"⚡"}</span>
4162 <span>AI is watching — no activity this week</span>
4163 </div>
4164 )}
544d842Claude4165 {readme && (() => {
4166 const readmeHtml = renderMarkdown(readme);
4167 return (
4168 <div class="repo-home-readme">
4169 <div class="repo-home-readme-head">
4170 <span class="repo-home-readme-icon">{"☰"}</span>
4171 <span>README.md</span>
4172 </div>
4173 <style>{markdownCss}</style>
4174 <div class="markdown-body repo-home-readme-body">
4175 {html([readmeHtml] as unknown as TemplateStringsArray)}
4176 </div>
4177 </div>
4178 );
4179 })()}
4180 </div>
4181 <aside class="repo-home-side" aria-label="Repository details">
4182 <div class="repo-home-clone">
4183 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4184 <button
4185 type="button"
4186 class="repo-home-clone-tab"
4187 role="tab"
4188 aria-selected="true"
4189 data-pane="https"
4190 data-repo-home-clone-tab
4191 >
4192 HTTPS
4193 </button>
4194 <button
4195 type="button"
4196 class="repo-home-clone-tab"
4197 role="tab"
4198 aria-selected="false"
4199 data-pane="ssh"
4200 data-repo-home-clone-tab
4201 >
4202 SSH
4203 </button>
4204 <button
4205 type="button"
4206 class="repo-home-clone-tab"
4207 role="tab"
4208 aria-selected="false"
4209 data-pane="cli"
4210 data-repo-home-clone-tab
4211 >
4212 CLI
4213 </button>
4214 </div>
4215 <div
4216 class="repo-home-clone-pane"
4217 data-pane="https"
4218 data-active="true"
4219 role="tabpanel"
4220 >
4221 <div class="repo-home-clone-body">
4222 <input
4223 class="repo-home-clone-input"
4224 type="text"
4225 value={cloneHttpsUrl}
4226 readonly
4227 aria-label="HTTPS clone URL"
4228 data-repo-home-clone-input
4229 />
4230 <button
4231 type="button"
4232 class="repo-home-clone-copy"
4233 data-repo-home-copy={cloneHttpsUrl}
4234 >
4235 Copy
4236 </button>
4237 </div>
4238 </div>
4239 <div
4240 class="repo-home-clone-pane"
4241 data-pane="ssh"
4242 data-active="false"
4243 role="tabpanel"
4244 >
4245 <div class="repo-home-clone-body">
4246 <input
4247 class="repo-home-clone-input"
4248 type="text"
4249 value={cloneSshUrl}
4250 readonly
4251 aria-label="SSH clone URL"
4252 data-repo-home-clone-input
4253 />
4254 <button
4255 type="button"
4256 class="repo-home-clone-copy"
4257 data-repo-home-copy={cloneSshUrl}
4258 >
4259 Copy
4260 </button>
4261 </div>
4262 </div>
4263 <div
4264 class="repo-home-clone-pane"
4265 data-pane="cli"
4266 data-active="false"
4267 role="tabpanel"
4268 >
4269 <div class="repo-home-clone-body">
4270 <input
4271 class="repo-home-clone-input"
4272 type="text"
4273 value={cloneCliCmd}
4274 readonly
4275 aria-label="Gluecron CLI clone command"
4276 data-repo-home-clone-input
4277 />
4278 <button
4279 type="button"
4280 class="repo-home-clone-copy"
4281 data-repo-home-copy={cloneCliCmd}
4282 >
4283 Copy
4284 </button>
4285 </div>
79136bbClaude4286 </div>
fc1817aClaude4287 </div>
544d842Claude4288 <div class="repo-home-side-card">
4289 <h3 class="repo-home-side-title">About</h3>
4290 <div class="repo-home-side-row">
4291 <span class="repo-home-side-key">Default branch</span>
4292 <span class="repo-home-side-val">{defaultBranch}</span>
4293 </div>
4294 <div class="repo-home-side-row">
4295 <span class="repo-home-side-key">Branches</span>
4296 <span class="repo-home-side-val">
4297 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4298 {branches.length}
4299 </a>
4300 </span>
4301 </div>
4302 <div class="repo-home-side-row">
4303 <span class="repo-home-side-key">Stars</span>
4304 <span class="repo-home-side-val">{starCount}</span>
4305 </div>
4306 <div class="repo-home-side-row">
4307 <span class="repo-home-side-key">Forks</span>
4308 <span class="repo-home-side-val">{forkCount}</span>
4309 </div>
4310 {pushedAt && (
4311 <div class="repo-home-side-row">
4312 <span class="repo-home-side-key">Last push</span>
4313 <span class="repo-home-side-val">
4314 {formatRelative(pushedAt)}
4315 </span>
4316 </div>
4317 )}
4318 {createdAt && (
4319 <div class="repo-home-side-row">
4320 <span class="repo-home-side-key">Created</span>
4321 <span class="repo-home-side-val">
4322 {formatRelative(createdAt)}
4323 </span>
4324 </div>
4325 )}
4326 {(archived || isTemplate) && (
4327 <div class="repo-home-side-row">
4328 <span class="repo-home-side-key">State</span>
4329 <span class="repo-home-side-val">
4330 {archived ? "Archived" : "Template"}
4331 </span>
4332 </div>
4333 )}
4334 </div>
f1dc38bClaude4335 {/* Claude AI — quick-access card for authenticated users */}
4336 {user && (
4337 <div class="repo-home-side-card" style="margin-top:12px">
4338 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4339 ✨ Claude AI
4340 </h3>
4341 <a
4342 href={`/${owner}/${repo}/claude`}
4343 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"
4344 >
4345 Claude Code sessions
4346 </a>
4347 <a
4348 href={`/${owner}/${repo}/ask`}
4349 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4350 >
4351 Ask AI about this repo
4352 </a>
4353 </div>
4354 )}
544d842Claude4355 </aside>
4356 </div>
4357 <script
4358 dangerouslySetInnerHTML={{
4359 __html: `
4360 (function(){
4361 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4362 tabs.forEach(function(tab){
4363 tab.addEventListener('click', function(){
4364 var target = tab.getAttribute('data-pane');
4365 tabs.forEach(function(t){
4366 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4367 });
4368 var panes = document.querySelectorAll('.repo-home-clone-pane');
4369 panes.forEach(function(p){
4370 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4371 });
4372 });
4373 });
4374 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4375 copyBtns.forEach(function(btn){
4376 btn.addEventListener('click', function(){
4377 var text = btn.getAttribute('data-repo-home-copy') || '';
4378 var done = function(){
4379 var prev = btn.textContent;
4380 btn.textContent = 'Copied';
4381 setTimeout(function(){ btn.textContent = prev; }, 1200);
4382 };
4383 if (navigator.clipboard && navigator.clipboard.writeText) {
4384 navigator.clipboard.writeText(text).then(done, done);
4385 } else {
4386 var ta = document.createElement('textarea');
4387 ta.value = text;
4388 document.body.appendChild(ta);
4389 ta.select();
4390 try { document.execCommand('copy'); } catch (e) {}
4391 document.body.removeChild(ta);
4392 done();
4393 }
4394 });
4395 });
4396 })();
4397 `,
4398 }}
4399 />
fc1817aClaude4400 </Layout>
4401 );
4402});
4403
4404// Browse tree at ref/path
4405web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4406 const { owner, repo } = c.req.param();
06d5ffeClaude4407 const user = c.get("user");
5bb52faccanty labs4408 const gate = await assertRepoReadable(c, owner, repo);
4409 if (gate) return gate;
fc1817aClaude4410 const refAndPath = c.req.param("ref");
4411
4412 const branches = await listBranches(owner, repo);
4413 let ref = "";
4414 let treePath = "";
4415
4416 for (const branch of branches) {
4417 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4418 ref = branch;
4419 treePath = refAndPath.slice(branch.length + 1);
4420 break;
4421 }
4422 }
4423
4424 if (!ref) {
4425 const slashIdx = refAndPath.indexOf("/");
4426 if (slashIdx === -1) {
4427 ref = refAndPath;
4428 } else {
4429 ref = refAndPath.slice(0, slashIdx);
4430 treePath = refAndPath.slice(slashIdx + 1);
4431 }
4432 }
4433
4434 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4435 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4436 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4437
4438 return c.html(
06d5ffeClaude4439 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4440 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4441 <RepoHeader owner={owner} repo={repo} />
4442 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4443 <div class="tree-header">
4444 <div class="tree-header-row">
4445 <BranchSwitcher
4446 owner={owner}
4447 repo={repo}
4448 currentRef={ref}
4449 branches={branches}
4450 pathType="tree"
4451 subPath={treePath}
4452 />
4453 <div class="tree-header-stats">
4454 <span class="tree-stat" title="Entries in this directory">
4455 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4456 {dirCount > 0 && (
4457 <>
4458 {" · "}
4459 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4460 </>
4461 )}
4462 </span>
4463 <a
4464 href={`/${owner}/${repo}/search`}
4465 class="tree-stat tree-stat-link"
4466 title="Search code in this repository"
4467 >
4468 {"⌕"} Search
4469 </a>
4470 </div>
4471 </div>
4472 <div class="tree-breadcrumb-row">
4473 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4474 </div>
4475 </div>
fc1817aClaude4476 <FileTable
4477 entries={tree}
4478 owner={owner}
4479 repo={repo}
4480 ref={ref}
4481 path={treePath}
4482 />
4483 </Layout>
4484 );
4485});
4486
06d5ffeClaude4487// View file blob with syntax highlighting
fc1817aClaude4488web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4489 const { owner, repo } = c.req.param();
06d5ffeClaude4490 const user = c.get("user");
5bb52faccanty labs4491 const gate = await assertRepoReadable(c, owner, repo);
4492 if (gate) return gate;
fc1817aClaude4493 const refAndPath = c.req.param("ref");
4494
4495 const branches = await listBranches(owner, repo);
4496 let ref = "";
4497 let filePath = "";
4498
4499 for (const branch of branches) {
4500 if (refAndPath.startsWith(branch + "/")) {
4501 ref = branch;
4502 filePath = refAndPath.slice(branch.length + 1);
4503 break;
4504 }
4505 }
4506
4507 if (!ref) {
4508 const slashIdx = refAndPath.indexOf("/");
4509 if (slashIdx === -1) return c.text("Not found", 404);
4510 ref = refAndPath.slice(0, slashIdx);
4511 filePath = refAndPath.slice(slashIdx + 1);
4512 }
4513
4514 const blob = await getBlob(owner, repo, ref, filePath);
4515 if (!blob) {
4516 return c.html(
06d5ffeClaude4517 <Layout title="Not Found" user={user}>
fc1817aClaude4518 <div class="empty-state">
4519 <h2>File not found</h2>
4520 </div>
4521 </Layout>,
4522 404
4523 );
4524 }
4525
06d5ffeClaude4526 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4527 const lineCount = blob.isBinary
4528 ? 0
4529 : (blob.content.endsWith("\n")
4530 ? blob.content.split("\n").length - 1
4531 : blob.content.split("\n").length);
4532 const formatBytes = (n: number): string => {
4533 if (n < 1024) return `${n} B`;
4534 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4535 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4536 };
fc1817aClaude4537
4538 return c.html(
06d5ffeClaude4539 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4540 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4541 <RepoHeader owner={owner} repo={repo} />
4542 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4543 <div class="blob-toolbar">
4544 <BranchSwitcher
4545 owner={owner}
4546 repo={repo}
4547 currentRef={ref}
4548 branches={branches}
4549 pathType="blob"
4550 subPath={filePath}
4551 />
4552 <div class="blob-breadcrumb">
4553 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4554 </div>
4555 </div>
4556 <div class="blob-view blob-card">
4557 <div class="blob-header blob-header-polished">
4558 <div class="blob-header-meta">
4559 <span class="blob-header-icon" aria-hidden="true">
4560 {"📄"}
4561 </span>
4562 <span class="blob-header-name">{fileName}</span>
4563 <span class="blob-header-size">
4564 {formatBytes(blob.size)}
4565 {!blob.isBinary && (
4566 <>
4567 {" · "}
4568 {lineCount} line{lineCount === 1 ? "" : "s"}
4569 </>
4570 )}
4571 </span>
4572 </div>
4573 <div class="blob-header-actions">
4574 <a
4575 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4576 class="blob-pill"
4577 >
79136bbClaude4578 Raw
4579 </a>
efb11c5Claude4580 <a
4581 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4582 class="blob-pill"
4583 >
79136bbClaude4584 Blame
4585 </a>
efb11c5Claude4586 <a
4587 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4588 class="blob-pill"
4589 >
16b325cClaude4590 History
4591 </a>
0074234Claude4592 {user && (
efb11c5Claude4593 <a
4594 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4595 class="blob-pill blob-pill-accent"
4596 >
0074234Claude4597 Edit
4598 </a>
4599 )}
efb11c5Claude4600 </div>
fc1817aClaude4601 </div>
4602 {blob.isBinary ? (
efb11c5Claude4603 <div class="blob-binary">
fc1817aClaude4604 Binary file not shown.
4605 </div>
06d5ffeClaude4606 ) : (() => {
4607 const { html: highlighted, language } = highlightCode(
4608 blob.content,
4609 fileName
4610 );
4611 if (language) {
4612 return (
4613 <HighlightedCode
4614 highlightedHtml={highlighted}
efb11c5Claude4615 lineCount={lineCount}
06d5ffeClaude4616 />
4617 );
4618 }
4619 const lines = blob.content.split("\n");
4620 if (lines[lines.length - 1] === "") lines.pop();
4621 return <PlainCode lines={lines} />;
4622 })()}
fc1817aClaude4623 </div>
4624 </Layout>
4625 );
4626});
4627
398a10cClaude4628// ─── Branches list ────────────────────────────────────────────────────────
4629// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4630// author + relative time + ahead/behind vs the default branch. No DB. All
4631// data comes from git plumbing; failures degrade gracefully (counts omitted).
4632web.get("/:owner/:repo/branches", async (c) => {
4633 const { owner, repo } = c.req.param();
4634 const user = c.get("user");
5bb52faccanty labs4635 const gate = await assertRepoReadable(c, owner, repo);
4636 if (gate) return gate;
398a10cClaude4637
4638 if (!(await repoExists(owner, repo))) return c.notFound();
4639
4640 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4641 const branches = await listBranches(owner, repo);
4642 const repoDir = getRepoPath(owner, repo);
4643
4644 type BranchRow = {
4645 name: string;
4646 isDefault: boolean;
4647 sha: string;
4648 subject: string;
4649 author: string;
4650 date: string;
4651 ahead: number;
4652 behind: number;
4653 };
4654
4655 const runGit = async (args: string[]): Promise<string> => {
4656 try {
4657 const proc = Bun.spawn(["git", ...args], {
4658 cwd: repoDir,
4659 stdout: "pipe",
4660 stderr: "pipe",
4661 });
4662 const out = await new Response(proc.stdout).text();
4663 await proc.exited;
4664 return out;
4665 } catch {
4666 return "";
4667 }
4668 };
4669
4670 const meta = await runGit([
4671 "for-each-ref",
4672 "--sort=-committerdate",
4673 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4674 "refs/heads/",
4675 ]);
4676 const metaByName: Record<
4677 string,
4678 { sha: string; subject: string; author: string; date: string }
4679 > = {};
4680 for (const line of meta.split("\n").filter(Boolean)) {
4681 const [name, sha, subject, author, date] = line.split("\0");
4682 metaByName[name] = { sha, subject, author, date };
4683 }
4684
4685 const branchOrder = [...branches].sort((a, b) => {
4686 if (a === defaultBranch) return -1;
4687 if (b === defaultBranch) return 1;
4688 const aDate = metaByName[a]?.date || "";
4689 const bDate = metaByName[b]?.date || "";
4690 return bDate.localeCompare(aDate);
4691 });
4692
4693 const rows: BranchRow[] = [];
4694 for (const name of branchOrder) {
4695 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4696 let ahead = 0;
4697 let behind = 0;
4698 if (name !== defaultBranch && metaByName[defaultBranch]) {
4699 const out = await runGit([
4700 "rev-list",
4701 "--left-right",
4702 "--count",
4703 `${defaultBranch}...${name}`,
4704 ]);
4705 const parts = out.trim().split(/\s+/);
4706 if (parts.length === 2) {
4707 behind = parseInt(parts[0], 10) || 0;
4708 ahead = parseInt(parts[1], 10) || 0;
4709 }
4710 }
4711 rows.push({
4712 name,
4713 isDefault: name === defaultBranch,
4714 sha: m.sha,
4715 subject: m.subject,
4716 author: m.author,
4717 date: m.date,
4718 ahead,
4719 behind,
4720 });
4721 }
4722
4723 const relative = (iso: string): string => {
4724 if (!iso) return "—";
4725 const d = new Date(iso);
4726 if (Number.isNaN(d.getTime())) return "—";
4727 const diff = Date.now() - d.getTime();
4728 const m = Math.floor(diff / 60000);
4729 if (m < 1) return "just now";
4730 if (m < 60) return `${m} min ago`;
4731 const h = Math.floor(m / 60);
4732 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4733 const dd = Math.floor(h / 24);
4734 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4735 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4736 };
4737
4738 const success = c.req.query("success");
4739 const error = c.req.query("error");
4740
4741 return c.html(
4742 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4743 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4744 <RepoHeader owner={owner} repo={repo} />
4745 <RepoNav owner={owner} repo={repo} active="code" />
4746 <div
4747 class="branches-wrap"
eed4684Claude4748 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4749 >
4750 <header class="branches-head" style="margin-bottom:var(--space-5)">
4751 <div
4752 class="branches-eyebrow"
4753 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"
4754 >
4755 <span
4756 class="branches-eyebrow-dot"
4757 aria-hidden="true"
6fd5915Claude4758 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)"
398a10cClaude4759 />
4760 Repository · Branches
4761 </div>
4762 <h1
4763 class="branches-title"
4764 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)"
4765 >
4766 <span class="gradient-text">{rows.length}</span>{" "}
4767 branch{rows.length === 1 ? "" : "es"}
4768 </h1>
4769 <p
4770 class="branches-sub"
4771 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4772 >
4773 All work-in-progress lines for{" "}
4774 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4775 counts are relative to{" "}
4776 <code style="font-size:12.5px">{defaultBranch}</code>.
4777 </p>
4778 </header>
4779
4780 {success && (
4781 <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">
4782 {decodeURIComponent(success)}
4783 </div>
4784 )}
4785 {error && (
4786 <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">
4787 {decodeURIComponent(error)}
4788 </div>
4789 )}
4790
4791 {rows.length === 0 ? (
4792 <div class="commits-empty">
4793 <div class="commits-empty-orb" aria-hidden="true" />
4794 <div class="commits-empty-inner">
4795 <div class="commits-empty-icon" aria-hidden="true">
4796 <svg
4797 width="22"
4798 height="22"
4799 viewBox="0 0 24 24"
4800 fill="none"
4801 stroke="currentColor"
4802 stroke-width="2"
4803 stroke-linecap="round"
4804 stroke-linejoin="round"
4805 >
4806 <line x1="6" y1="3" x2="6" y2="15" />
4807 <circle cx="18" cy="6" r="3" />
4808 <circle cx="6" cy="18" r="3" />
4809 <path d="M18 9a9 9 0 0 1-9 9" />
4810 </svg>
4811 </div>
4812 <h3 class="commits-empty-title">No branches yet</h3>
4813 <p class="commits-empty-sub">
4814 Push your first commit to create the default branch.
4815 </p>
4816 </div>
4817 </div>
4818 ) : (
4819 <div class="branches-list">
4820 {rows.map((r) => (
4821 <div class="branches-row">
4822 <div class="branches-row-icon" aria-hidden="true">
4823 <svg
4824 width="14"
4825 height="14"
4826 viewBox="0 0 24 24"
4827 fill="none"
4828 stroke="currentColor"
4829 stroke-width="2"
4830 stroke-linecap="round"
4831 stroke-linejoin="round"
4832 >
4833 <line x1="6" y1="3" x2="6" y2="15" />
4834 <circle cx="18" cy="6" r="3" />
4835 <circle cx="6" cy="18" r="3" />
4836 <path d="M18 9a9 9 0 0 1-9 9" />
4837 </svg>
4838 </div>
4839 <div class="branches-row-main">
4840 <div class="branches-row-name">
4841 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4842 {r.isDefault && (
4843 <span
4844 class="branches-row-default"
4845 title="Default branch"
4846 >
4847 Default
4848 </span>
4849 )}
4850 </div>
4851 <div class="branches-row-meta">
4852 {r.subject ? (
4853 <>
4854 <strong>{r.author || "—"}</strong>
4855 <span class="sep">·</span>
4856 <span
4857 title={r.date ? new Date(r.date).toISOString() : ""}
4858 >
4859 updated {relative(r.date)}
4860 </span>
4861 {r.sha && (
4862 <>
4863 <span class="sep">·</span>
4864 <a
4865 href={`/${owner}/${repo}/commit/${r.sha}`}
4866 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4867 >
4868 {r.sha.slice(0, 7)}
4869 </a>
4870 </>
4871 )}
4872 </>
4873 ) : (
4874 <span>No commit metadata</span>
4875 )}
4876 </div>
4877 </div>
4878 <div class="branches-row-side">
4879 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4880 <span
4881 class="branches-row-divergence"
4882 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4883 >
4884 <span class="ahead">{r.ahead} ahead</span>
4885 <span style="opacity:0.4">|</span>
4886 <span class="behind">{r.behind} behind</span>
4887 </span>
4888 )}
4889 <div class="branches-row-actions">
4890 <a
4891 href={`/${owner}/${repo}/commits/${r.name}`}
4892 class="branches-btn"
4893 title="View commits on this branch"
4894 >
4895 Commits
4896 </a>
4897 {!r.isDefault &&
4898 user &&
4899 user.username === owner && (
4900 <form
4901 method="post"
4902 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4903 style="margin:0"
4904 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4905 >
4906 <button
4907 type="submit"
4908 class="branches-btn branches-btn-danger"
4909 >
4910 Delete
4911 </button>
4912 </form>
4913 )}
4914 </div>
4915 </div>
4916 </div>
4917 ))}
4918 </div>
4919 )}
4920 </div>
4921 </Layout>
4922 );
4923});
4924
4925// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4926// that are not merged into the default branch — matches the explicit
4927// confirmation on the row's delete button.
4928web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4929 const { owner, repo } = c.req.param();
4930 const branchName = decodeURIComponent(c.req.param("name"));
4931 const user = c.get("user")!;
4932
4933 // Owner-only check (mirrors collaborators.tsx pattern).
4934 const [ownerRow] = await db
4935 .select()
4936 .from(users)
4937 .where(eq(users.username, owner))
4938 .limit(1);
4939 if (!ownerRow || ownerRow.id !== user.id) {
4940 return c.redirect(
4941 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4942 );
4943 }
4944
4945 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4946 if (branchName === defaultBranch) {
4947 return c.redirect(
4948 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4949 );
4950 }
4951
4952 const branches = await listBranches(owner, repo);
4953 if (!branches.includes(branchName)) {
4954 return c.redirect(
4955 `/${owner}/${repo}/branches?error=Branch+not+found`
4956 );
4957 }
4958
4959 try {
4960 const repoDir = getRepoPath(owner, repo);
4961 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4962 cwd: repoDir,
4963 stdout: "pipe",
4964 stderr: "pipe",
4965 });
4966 await proc.exited;
4967 if (proc.exitCode !== 0) {
4968 return c.redirect(
4969 `/${owner}/${repo}/branches?error=Delete+failed`
4970 );
4971 }
4972 } catch {
4973 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4974 }
4975
4976 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4977});
4978
4979// ─── Tags list ────────────────────────────────────────────────────────────
4980// Pulls from `listTags` (sorted newest first). For each tag we look up an
4981// associated release row so the "View release" CTA can deep-link directly
4982// without making the user hunt for it.
4983web.get("/:owner/:repo/tags", async (c) => {
4984 const { owner, repo } = c.req.param();
4985 const user = c.get("user");
5bb52faccanty labs4986 const gate = await assertRepoReadable(c, owner, repo);
4987 if (gate) return gate;
398a10cClaude4988
4989 if (!(await repoExists(owner, repo))) return c.notFound();
4990
4991 const tags = await listTags(owner, repo);
4992
4993 // Map tags -> releases. Best-effort; releases table may not exist in
4994 // every test setup, so any error falls through with an empty set.
4995 const tagsWithReleases = new Set<string>();
4996 try {
4997 const [ownerRow] = await db
4998 .select()
4999 .from(users)
5000 .where(eq(users.username, owner))
5001 .limit(1);
5002 if (ownerRow) {
5003 const [repoRow] = await db
5004 .select()
5005 .from(repositories)
5006 .where(
5007 and(
5008 eq(repositories.ownerId, ownerRow.id),
5009 eq(repositories.name, repo)
5010 )
5011 )
5012 .limit(1);
5013 if (repoRow) {
5014 // Raw SQL so we don't need to import the releases schema here.
5015 const result = await db.execute(
5016 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5017 );
5018 const rows: any[] = (result as any).rows || (result as any) || [];
5019 for (const row of rows) {
5020 const tag = row?.tag;
5021 if (typeof tag === "string") tagsWithReleases.add(tag);
5022 }
5023 }
5024 }
5025 } catch {
5026 // No releases table or DB error — leave set empty.
5027 }
5028
5029 const relative = (iso: string): string => {
5030 if (!iso) return "—";
5031 const d = new Date(iso);
5032 if (Number.isNaN(d.getTime())) return "—";
5033 const diff = Date.now() - d.getTime();
5034 const m = Math.floor(diff / 60000);
5035 if (m < 1) return "just now";
5036 if (m < 60) return `${m} min ago`;
5037 const h = Math.floor(m / 60);
5038 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5039 const dd = Math.floor(h / 24);
5040 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5041 return d.toLocaleDateString("en-US", {
5042 month: "short",
5043 day: "numeric",
5044 year: "numeric",
5045 });
5046 };
5047
5048 return c.html(
5049 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5050 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5051 <RepoHeader owner={owner} repo={repo} />
5052 <RepoNav owner={owner} repo={repo} active="code" />
5053 <div
5054 class="tags-wrap"
eed4684Claude5055 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5056 >
5057 <header class="tags-head" style="margin-bottom:var(--space-5)">
5058 <div
5059 class="tags-eyebrow"
5060 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"
5061 >
5062 <span
5063 class="tags-eyebrow-dot"
5064 aria-hidden="true"
6fd5915Claude5065 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)"
398a10cClaude5066 />
5067 Repository · Tags
5068 </div>
5069 <h1
5070 class="tags-title"
5071 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)"
5072 >
5073 <span class="gradient-text">{tags.length}</span>{" "}
5074 tag{tags.length === 1 ? "" : "s"}
5075 </h1>
5076 <p
5077 class="tags-sub"
5078 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5079 >
5080 Named points in the history — typically releases, milestones,
5081 or shipped versions. Click a tag to browse the tree at that
5082 revision.
5083 </p>
5084 </header>
5085
5086 {tags.length === 0 ? (
5087 <div class="commits-empty">
5088 <div class="commits-empty-orb" aria-hidden="true" />
5089 <div class="commits-empty-inner">
5090 <div class="commits-empty-icon" aria-hidden="true">
5091 <svg
5092 width="22"
5093 height="22"
5094 viewBox="0 0 24 24"
5095 fill="none"
5096 stroke="currentColor"
5097 stroke-width="2"
5098 stroke-linecap="round"
5099 stroke-linejoin="round"
5100 >
5101 <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" />
5102 <line x1="7" y1="7" x2="7.01" y2="7" />
5103 </svg>
5104 </div>
5105 <h3 class="commits-empty-title">No tags yet</h3>
5106 <p class="commits-empty-sub">
5107 Tag a commit to mark a release or milestone. From the CLI:{" "}
5108 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5109 </p>
5110 </div>
5111 </div>
5112 ) : (
5113 <div class="tags-list">
5114 {tags.map((t) => {
5115 const hasRelease = tagsWithReleases.has(t.name);
5116 return (
5117 <div class="tags-row">
5118 <div class="tags-row-icon" aria-hidden="true">
5119 <svg
5120 width="14"
5121 height="14"
5122 viewBox="0 0 24 24"
5123 fill="none"
5124 stroke="currentColor"
5125 stroke-width="2"
5126 stroke-linecap="round"
5127 stroke-linejoin="round"
5128 >
5129 <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" />
5130 <line x1="7" y1="7" x2="7.01" y2="7" />
5131 </svg>
5132 </div>
5133 <div class="tags-row-main">
5134 <div class="tags-row-name">
5135 <a
5136 href={`/${owner}/${repo}/tree/${t.name}`}
5137 style="text-decoration:none"
5138 >
5139 <span class="tags-row-version">{t.name}</span>
5140 </a>
5141 </div>
5142 <div class="tags-row-meta">
5143 <span
5144 title={t.date ? new Date(t.date).toISOString() : ""}
5145 >
5146 Tagged {relative(t.date)}
5147 </span>
5148 <span class="sep">·</span>
5149 <a
5150 href={`/${owner}/${repo}/commit/${t.sha}`}
5151 class="tags-row-sha"
5152 title={t.sha}
5153 >
5154 {t.sha.slice(0, 7)}
5155 </a>
5156 </div>
5157 </div>
5158 <div class="tags-row-side">
5159 <a
5160 href={`/${owner}/${repo}/tree/${t.name}`}
5161 class="tags-row-link"
5162 >
5163 Browse files
5164 </a>
5165 {hasRelease && (
5166 <a
5167 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5168 class="tags-row-link"
6fd5915Claude5169 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5170 >
5171 View release
5172 </a>
5173 )}
5174 </div>
5175 </div>
5176 );
5177 })}
5178 </div>
5179 )}
5180 </div>
5181 </Layout>
5182 );
5183});
5184
fc1817aClaude5185// Commit log
5186web.get("/:owner/:repo/commits/:ref?", async (c) => {
5187 const { owner, repo } = c.req.param();
06d5ffeClaude5188 const user = c.get("user");
5bb52faccanty labs5189 const gate = await assertRepoReadable(c, owner, repo);
5190 if (gate) return gate;
fc1817aClaude5191 const ref =
5192 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5193 const branches = await listBranches(owner, repo);
fc1817aClaude5194
5195 const commits = await listCommits(owner, repo, ref, 50);
5196
3951454Claude5197 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5198 // Also resolve repoId here so we can query the recent push for the
5199 // Push Watch live/watch indicator in the header.
3951454Claude5200 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5201 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5202 try {
5203 const [ownerRow] = await db
5204 .select()
5205 .from(users)
5206 .where(eq(users.username, owner))
5207 .limit(1);
5208 if (ownerRow) {
5209 const [repoRow] = await db
5210 .select()
5211 .from(repositories)
5212 .where(
5213 and(
5214 eq(repositories.ownerId, ownerRow.id),
5215 eq(repositories.name, repo)
5216 )
5217 )
5218 .limit(1);
8c790e0Claude5219 if (repoRow) {
5220 // Fetch verifications and recent push in parallel.
5221 const [verRows, rp] = await Promise.all([
5222 commits.length > 0
5223 ? db
5224 .select()
5225 .from(commitVerifications)
5226 .where(
5227 and(
5228 eq(commitVerifications.repositoryId, repoRow.id),
5229 inArray(
5230 commitVerifications.commitSha,
5231 commits.map((c) => c.sha)
5232 )
5233 )
5234 )
5235 : Promise.resolve([]),
5236 getRecentPush(repoRow.id),
5237 ]);
5238 for (const r of verRows) {
3951454Claude5239 verifications[r.commitSha] = {
5240 verified: r.verified,
5241 reason: r.reason,
5242 };
5243 }
8c790e0Claude5244 commitsPageRecentPush = rp;
3951454Claude5245 }
5246 }
5247 } catch {
5248 // DB unavailable — skip the badges gracefully.
5249 }
5250
fc1817aClaude5251 return c.html(
06d5ffeClaude5252 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5253 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5254 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5255 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5256 <div class="commits-hero">
5257 <div class="commits-hero-orb-wrap" aria-hidden="true">
5258 <div class="commits-hero-orb" />
fc1817aClaude5259 </div>
efb11c5Claude5260 <div class="commits-hero-inner">
5261 <div class="commits-eyebrow">
5262 <strong>History</strong> · {owner}/{repo}
5263 </div>
5264 <h1 class="commits-title">
5265 <span class="gradient-text">{commits.length}</span> recent commit
5266 {commits.length === 1 ? "" : "s"} on{" "}
5267 <span class="commits-branch">{ref}</span>
5268 </h1>
5269 <p class="commits-sub">
5270 Browse the project's history. Click any commit to see the full
5271 diff, AI review notes, and signature status.
5272 </p>
5273 </div>
5274 </div>
5275 <div class="commits-toolbar">
5276 <BranchSwitcher
3951454Claude5277 owner={owner}
5278 repo={repo}
efb11c5Claude5279 currentRef={ref}
5280 branches={branches}
5281 pathType="commits"
3951454Claude5282 />
398a10cClaude5283 <div class="commits-toolbar-actions">
5284 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5285 {"⊢"} Branches
5286 </a>
5287 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5288 {"#"} Tags
5289 </a>
5290 </div>
efb11c5Claude5291 </div>
5292 {commits.length === 0 ? (
5293 <div class="commits-empty">
398a10cClaude5294 <div class="commits-empty-orb" aria-hidden="true" />
5295 <div class="commits-empty-inner">
5296 <div class="commits-empty-icon" aria-hidden="true">
5297 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5298 <circle cx="12" cy="12" r="4" />
5299 <line x1="1.05" y1="12" x2="7" y2="12" />
5300 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5301 </svg>
5302 </div>
5303 <h3 class="commits-empty-title">No commits yet</h3>
5304 <p class="commits-empty-sub">
5305 This branch is empty. Push your first commit, or use the
5306 web editor to create a file.
5307 </p>
5308 </div>
efb11c5Claude5309 </div>
5310 ) : (
5311 <div class="commits-list-wrap">
398a10cClaude5312 {(() => {
5313 // Group commits by day for the section headers.
5314 const dayLabel = (iso: string): string => {
5315 const d = new Date(iso);
5316 if (Number.isNaN(d.getTime())) return "Unknown";
5317 const today = new Date();
5318 const yesterday = new Date(today);
5319 yesterday.setDate(today.getDate() - 1);
5320 const sameDay = (a: Date, b: Date) =>
5321 a.getFullYear() === b.getFullYear() &&
5322 a.getMonth() === b.getMonth() &&
5323 a.getDate() === b.getDate();
5324 if (sameDay(d, today)) return "Today";
5325 if (sameDay(d, yesterday)) return "Yesterday";
5326 return d.toLocaleDateString("en-US", {
5327 month: "long",
5328 day: "numeric",
5329 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5330 });
5331 };
5332 const relative = (iso: string): string => {
5333 const d = new Date(iso);
5334 if (Number.isNaN(d.getTime())) return "";
5335 const diff = Date.now() - d.getTime();
5336 const m = Math.floor(diff / 60000);
5337 if (m < 1) return "just now";
5338 if (m < 60) return `${m} min ago`;
5339 const h = Math.floor(m / 60);
5340 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5341 const dd = Math.floor(h / 24);
5342 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5343 return d.toLocaleDateString("en-US", {
5344 month: "short",
5345 day: "numeric",
5346 });
5347 };
5348 const initial = (name: string): string =>
5349 (name || "?").trim().charAt(0).toUpperCase() || "?";
5350 // Build groups preserving order.
5351 const groups: Array<{ label: string; items: typeof commits }> = [];
5352 let lastLabel = "";
5353 for (const cm of commits) {
5354 const label = dayLabel(cm.date);
5355 if (label !== lastLabel) {
5356 groups.push({ label, items: [] });
5357 lastLabel = label;
5358 }
5359 groups[groups.length - 1].items.push(cm);
5360 }
5361 return groups.map((g) => (
5362 <>
5363 <div class="commits-day-head">
5364 <span class="commits-day-head-dot" aria-hidden="true" />
5365 Commits on {g.label}
5366 </div>
5367 {g.items.map((cm) => {
5368 const v = verifications[cm.sha];
5369 return (
5370 <div class="commits-row">
5371 <div class="commits-avatar" aria-hidden="true">
5372 {initial(cm.author)}
5373 </div>
5374 <div class="commits-row-body">
5375 <div class="commits-row-msg">
5376 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5377 {cm.message}
5378 </a>
5379 {v?.verified && (
5380 <span
5381 class="commits-row-verified"
5382 title="Signed with a registered key"
5383 >
5384 Verified
5385 </span>
5386 )}
5387 </div>
5388 <div class="commits-row-meta">
5389 <strong>{cm.author}</strong>
5390 <span class="sep">·</span>
5391 <span
5392 class="commits-row-time"
5393 title={new Date(cm.date).toISOString()}
5394 >
5395 committed {relative(cm.date)}
5396 </span>
5397 {cm.parentShas.length > 1 && (
5398 <>
5399 <span class="sep">·</span>
5400 <span>merge of {cm.parentShas.length} parents</span>
5401 </>
5402 )}
5403 </div>
5404 </div>
5405 <div class="commits-row-side">
5406 <a
5407 href={`/${owner}/${repo}/commit/${cm.sha}`}
5408 class="commits-row-sha"
5409 title={cm.sha}
5410 >
5411 {cm.sha.slice(0, 7)}
5412 </a>
8c790e0Claude5413 <a
5414 href={`/${owner}/${repo}/push/${cm.sha}`}
5415 class="commits-row-watch"
5416 title="Watch gate + deploy results for this push"
5417 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5418 >
5419 <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">
5420 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5421 <circle cx="12" cy="12" r="3" />
5422 </svg>
5423 </a>
398a10cClaude5424 <button
5425 type="button"
5426 class="commits-row-copy"
5427 data-copy-sha={cm.sha}
5428 title="Copy full SHA"
5429 aria-label={`Copy SHA ${cm.sha}`}
5430 >
5431 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5432 <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" />
5433 <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" />
5434 </svg>
5435 </button>
5436 </div>
5437 </div>
5438 );
5439 })}
5440 </>
5441 ));
5442 })()}
efb11c5Claude5443 </div>
fc1817aClaude5444 )}
398a10cClaude5445 <script
5446 dangerouslySetInnerHTML={{
5447 __html: `
5448 (function(){
5449 document.addEventListener('click', function(e){
5450 var t = e.target; if (!t) return;
5451 var btn = t.closest && t.closest('[data-copy-sha]');
5452 if (!btn) return;
5453 e.preventDefault();
5454 var sha = btn.getAttribute('data-copy-sha') || '';
5455 if (!navigator.clipboard) return;
5456 navigator.clipboard.writeText(sha).then(function(){
5457 btn.classList.add('is-copied');
5458 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5459 }).catch(function(){});
5460 });
5461 })();
5462 `,
5463 }}
5464 />
fc1817aClaude5465 </Layout>
5466 );
5467});
5468
5469// Single commit with diff
5470web.get("/:owner/:repo/commit/:sha", async (c) => {
5471 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5472 const user = c.get("user");
5bb52faccanty labs5473 const gate = await assertRepoReadable(c, owner, repo);
5474 if (gate) return gate;
fc1817aClaude5475
05b973eClaude5476 // Fetch commit, full message, and diff in parallel
5477 const [commit, fullMessage, diffResult] = await Promise.all([
5478 getCommit(owner, repo, sha),
5479 getCommitFullMessage(owner, repo, sha),
5480 getDiff(owner, repo, sha),
5481 ]);
fc1817aClaude5482 if (!commit) {
5483 return c.html(
06d5ffeClaude5484 <Layout title="Not Found" user={user}>
fc1817aClaude5485 <div class="empty-state">
5486 <h2>Commit not found</h2>
5487 </div>
5488 </Layout>,
5489 404
5490 );
5491 }
5492
3951454Claude5493 // Block J3 — try to verify this commit's signature.
5494 let verification:
5495 | { verified: boolean; reason: string; signatureType: string | null }
5496 | null = null;
0cdfd89Claude5497 // Block J8 — external CI commit statuses rollup.
5498 let statusCombined:
5499 | {
5500 state: "pending" | "success" | "failure";
5501 total: number;
5502 contexts: Array<{
5503 context: string;
5504 state: string;
5505 description: string | null;
5506 targetUrl: string | null;
5507 }>;
5508 }
5509 | null = null;
3951454Claude5510 try {
5511 const [ownerRow] = await db
5512 .select()
5513 .from(users)
5514 .where(eq(users.username, owner))
5515 .limit(1);
5516 if (ownerRow) {
5517 const [repoRow] = await db
5518 .select()
5519 .from(repositories)
5520 .where(
5521 and(
5522 eq(repositories.ownerId, ownerRow.id),
5523 eq(repositories.name, repo)
5524 )
5525 )
5526 .limit(1);
5527 if (repoRow) {
5528 const { verifyCommit } = await import("../lib/signatures");
5529 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5530 verification = {
5531 verified: v.verified,
5532 reason: v.reason,
5533 signatureType: v.signatureType,
5534 };
0cdfd89Claude5535 try {
5536 const { combinedStatus } = await import("../lib/commit-statuses");
5537 const combined = await combinedStatus(repoRow.id, commit.sha);
5538 if (combined.total > 0) {
5539 statusCombined = {
5540 state: combined.state as any,
5541 total: combined.total,
5542 contexts: combined.contexts.map((c) => ({
5543 context: c.context,
5544 state: c.state,
5545 description: c.description,
5546 targetUrl: c.targetUrl,
5547 })),
5548 };
5549 }
5550 } catch {
5551 statusCombined = null;
5552 }
3951454Claude5553 }
5554 }
5555 } catch {
5556 verification = null;
5557 }
5558
05b973eClaude5559 const { files, raw } = diffResult;
fc1817aClaude5560
efb11c5Claude5561 // Diff stats: count additions / deletions across all files for the
5562 // header summary bar. Computed here from the parsed diff so we don't
5563 // touch the DiffView component.
5564 let additions = 0;
5565 let deletions = 0;
5566 for (const f of files) {
5567 const hunks = (f as any).hunks as Array<any> | undefined;
5568 if (Array.isArray(hunks)) {
5569 for (const h of hunks) {
5570 const lines = (h?.lines || []) as Array<any>;
5571 for (const ln of lines) {
5572 const t = ln?.type || ln?.kind;
5573 if (t === "add" || t === "added" || t === "+") additions += 1;
5574 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5575 deletions += 1;
5576 }
5577 }
5578 }
5579 }
5580 // Fall back: scan raw if file-level counting yielded zero (it's just a
5581 // header polish — never let a parsing miss break the page).
5582 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5583 for (const line of raw.split("\n")) {
5584 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5585 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5586 }
5587 }
5588 const fileCount = files.length;
5589
fc1817aClaude5590 return c.html(
06d5ffeClaude5591 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5592 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5593 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5594 <div class="commit-detail-card">
5595 <div class="commit-detail-eyebrow">
5596 <strong>Commit</strong>
5597 <span class="commit-detail-sha-pill" title={commit.sha}>
5598 {commit.sha.slice(0, 7)}
5599 </span>
3951454Claude5600 {verification && verification.reason !== "unsigned" && (
5601 <span
efb11c5Claude5602 class={`commit-detail-verify ${
3951454Claude5603 verification.verified
efb11c5Claude5604 ? "commit-detail-verify-ok"
5605 : "commit-detail-verify-warn"
3951454Claude5606 }`}
5607 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5608 >
5609 {verification.verified ? "Verified" : verification.reason}
5610 </span>
5611 )}
fc1817aClaude5612 </div>
efb11c5Claude5613 <h1 class="commit-detail-title">{commit.message}</h1>
5614 {fullMessage !== commit.message && (
5615 <pre class="commit-detail-body">{fullMessage}</pre>
5616 )}
5617 <div class="commit-detail-meta">
5618 <span class="commit-detail-author">
5619 <strong>{commit.author}</strong> committed on{" "}
5620 {new Date(commit.date).toLocaleDateString("en-US", {
5621 month: "long",
5622 day: "numeric",
5623 year: "numeric",
5624 })}
5625 </span>
fc1817aClaude5626 {commit.parentShas.length > 0 && (
efb11c5Claude5627 <span class="commit-detail-parents">
5628 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5629 {commit.parentShas.map((p, idx) => (
5630 <>
5631 {idx > 0 && " "}
5632 <a
5633 href={`/${owner}/${repo}/commit/${p}`}
5634 class="commit-detail-sha-link"
5635 >
5636 {p.slice(0, 7)}
5637 </a>
5638 </>
fc1817aClaude5639 ))}
5640 </span>
5641 )}
5642 </div>
efb11c5Claude5643 <div class="commit-detail-stats">
5644 <span class="commit-detail-stat">
5645 <strong>{fileCount}</strong>{" "}
5646 file{fileCount === 1 ? "" : "s"} changed
5647 </span>
5648 <span class="commit-detail-stat commit-detail-stat-add">
5649 <span class="commit-detail-stat-mark">+</span>
5650 <strong>{additions}</strong>
5651 </span>
5652 <span class="commit-detail-stat commit-detail-stat-del">
5653 <span class="commit-detail-stat-mark">−</span>
5654 <strong>{deletions}</strong>
5655 </span>
5656 <span class="commit-detail-sha-full" title="Full SHA">
5657 {commit.sha}
5658 </span>
5659 </div>
0cdfd89Claude5660 {statusCombined && (
efb11c5Claude5661 <div class="commit-detail-checks">
5662 <div class="commit-detail-checks-head">
5663 <strong>Checks</strong>
5664 <span class="commit-detail-checks-summary">
5665 {statusCombined.total} total ·{" "}
5666 <span
5667 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5668 >
5669 {statusCombined.state}
5670 </span>
0cdfd89Claude5671 </span>
efb11c5Claude5672 </div>
5673 <div class="commit-detail-check-row">
0cdfd89Claude5674 {statusCombined.contexts.map((cx) => (
5675 <span
efb11c5Claude5676 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5677 title={cx.description || cx.context}
5678 >
5679 {cx.targetUrl ? (
efb11c5Claude5680 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5681 {cx.context}: {cx.state}
5682 </a>
5683 ) : (
5684 <>
5685 {cx.context}: {cx.state}
5686 </>
5687 )}
5688 </span>
5689 ))}
5690 </div>
5691 </div>
5692 )}
fc1817aClaude5693 </div>
ea9ed4cClaude5694 <DiffView
5695 raw={raw}
5696 files={files}
5697 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5698 />
fc1817aClaude5699 </Layout>
5700 );
5701});
5702
79136bbClaude5703// Raw file download
5704web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5705 const { owner, repo } = c.req.param();
5bb52faccanty labs5706 const gate = await assertRepoReadable(c, owner, repo);
5707 if (gate) return gate;
79136bbClaude5708 const refAndPath = c.req.param("ref");
5709
5710 const branches = await listBranches(owner, repo);
5711 let ref = "";
5712 let filePath = "";
5713
5714 for (const branch of branches) {
5715 if (refAndPath.startsWith(branch + "/")) {
5716 ref = branch;
5717 filePath = refAndPath.slice(branch.length + 1);
5718 break;
5719 }
5720 }
5721
5722 if (!ref) {
5723 const slashIdx = refAndPath.indexOf("/");
5724 if (slashIdx === -1) return c.text("Not found", 404);
5725 ref = refAndPath.slice(0, slashIdx);
5726 filePath = refAndPath.slice(slashIdx + 1);
5727 }
5728
5729 const data = await getRawBlob(owner, repo, ref, filePath);
5730 if (!data) return c.text("Not found", 404);
5731
5732 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5733 return new Response(data as BodyInit, {
79136bbClaude5734 headers: {
5735 "Content-Type": "application/octet-stream",
5736 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5737 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5738 },
5739 });
5740});
5741
5742// Blame view
5743web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5744 const { owner, repo } = c.req.param();
5745 const user = c.get("user");
5bb52faccanty labs5746 const gate = await assertRepoReadable(c, owner, repo);
5747 if (gate) return gate;
79136bbClaude5748 const refAndPath = c.req.param("ref");
5749
5750 const branches = await listBranches(owner, repo);
5751 let ref = "";
5752 let filePath = "";
5753
5754 for (const branch of branches) {
5755 if (refAndPath.startsWith(branch + "/")) {
5756 ref = branch;
5757 filePath = refAndPath.slice(branch.length + 1);
5758 break;
5759 }
5760 }
5761
5762 if (!ref) {
5763 const slashIdx = refAndPath.indexOf("/");
5764 if (slashIdx === -1) return c.text("Not found", 404);
5765 ref = refAndPath.slice(0, slashIdx);
5766 filePath = refAndPath.slice(slashIdx + 1);
5767 }
5768
5769 const blameLines = await getBlame(owner, repo, ref, filePath);
5770 if (blameLines.length === 0) {
5771 return c.html(
5772 <Layout title="Not Found" user={user}>
5773 <div class="empty-state">
5774 <h2>File not found</h2>
5775 </div>
5776 </Layout>,
5777 404
5778 );
5779 }
5780
5781 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5782 // Unique contributors (by author) tracked once for the header chip.
5783 const blameAuthors = new Set<string>();
5784 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5785
5786 return c.html(
5787 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5788 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5789 <RepoHeader owner={owner} repo={repo} />
5790 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5791 <header class="blame-head">
5792 <div class="blame-eyebrow">
5793 <span class="blame-eyebrow-dot" aria-hidden="true" />
5794 Blame · Line-by-line history
5795 </div>
5796 <h1 class="blame-title">
5797 <code>{fileName}</code>
5798 </h1>
5799 <p class="blame-sub">
5800 Each line is annotated with the commit that last touched it.
5801 Click any SHA to jump to that commit and see the surrounding
5802 change.
5803 </p>
5804 </header>
efb11c5Claude5805 <div class="blame-toolbar">
5806 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5807 </div>
398a10cClaude5808 <div class="blame-card">
5809 <div class="blame-header">
efb11c5Claude5810 <div class="blame-header-meta">
5811 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5812 <span class="blame-header-name">{fileName}</span>
5813 <span class="blame-header-tag">Blame</span>
5814 <span class="blame-header-stats">
5815 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5816 {blameAuthors.size} contributor
5817 {blameAuthors.size === 1 ? "" : "s"}
5818 </span>
5819 </div>
5820 <div class="blame-header-actions">
5821 <a
5822 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5823 class="blob-pill"
5824 >
5825 Normal view
5826 </a>
5827 <a
5828 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5829 class="blob-pill"
5830 >
5831 Raw
5832 </a>
5833 </div>
79136bbClaude5834 </div>
398a10cClaude5835 <div style="overflow-x:auto">
5836 <table class="blame-table">
79136bbClaude5837 <tbody>
5838 {blameLines.map((line, i) => {
5839 const showInfo =
5840 i === 0 || blameLines[i - 1].sha !== line.sha;
5841 return (
398a10cClaude5842 <tr class={showInfo ? "blame-row-first" : ""}>
5843 <td class="blame-gutter">
79136bbClaude5844 {showInfo && (
398a10cClaude5845 <span class="blame-gutter-inner">
79136bbClaude5846 <a
5847 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5848 class="blame-gutter-sha"
5849 title={`Commit ${line.sha}`}
79136bbClaude5850 >
5851 {line.sha.slice(0, 7)}
398a10cClaude5852 </a>
5853 <span class="blame-gutter-author" title={line.author}>
5854 {line.author}
5855 </span>
5856 </span>
79136bbClaude5857 )}
5858 </td>
398a10cClaude5859 <td class="blame-line-num">{line.lineNum}</td>
5860 <td class="blame-line-content">{line.content}</td>
79136bbClaude5861 </tr>
5862 );
5863 })}
5864 </tbody>
5865 </table>
5866 </div>
5867 </div>
5868 </Layout>
5869 );
5870});
5871
53c9249Claude5872// Search — keyword + optional Claude semantic mode
79136bbClaude5873web.get("/:owner/:repo/search", async (c) => {
5874 const { owner, repo } = c.req.param();
5875 const user = c.get("user");
5bb52faccanty labs5876 const gate = await assertRepoReadable(c, owner, repo);
5877 if (gate) return gate;
79136bbClaude5878 const q = c.req.query("q") || "";
53c9249Claude5879 const aiAvailable = isAiAvailable();
5880 // Default to semantic when Claude is available and no explicit mode set.
5881 const modeParam = c.req.query("mode");
5882 const mode: "semantic" | "keyword" =
5883 modeParam === "keyword"
5884 ? "keyword"
5885 : modeParam === "semantic"
5886 ? "semantic"
5887 : aiAvailable
5888 ? "semantic"
5889 : "keyword";
5890 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5891
5892 if (!(await repoExists(owner, repo))) return c.notFound();
5893
5894 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5895
5896 // Keyword results (always available as fallback / when in keyword mode)
5897 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5898 // Semantic results (Claude-powered)
5899 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5900 let semanticHits: SemanticHit[] = [];
5901 let semanticMode: "semantic" | "keyword" = "semantic";
5902 let quotaExceeded = false;
79136bbClaude5903
5904 if (q.trim()) {
53c9249Claude5905 if (isSemantic) {
5906 // Resolve repo DB id for caching
5907 const [ownerRow] = await db
5908 .select({ id: users.id })
5909 .from(users)
5910 .where(eq(users.username, owner))
5911 .limit(1);
5912 const [repoRow] = ownerRow
5913 ? await db
5914 .select({ id: repositories.id })
5915 .from(repositories)
5916 .where(
5917 and(
5918 eq(repositories.ownerId, ownerRow.id),
5919 eq(repositories.name, repo)
5920 )
5921 )
5922 .limit(1)
5923 : [];
5924
5925 const rateLimitKey = user
5926 ? `user:${user.id}`
5927 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5928
5929 const searchResult = await claudeSemanticSearch(
5930 owner,
5931 repo,
5932 repoRow?.id ?? `${owner}/${repo}`,
5933 q.trim(),
5934 { branch: defaultBranch, rateLimitKey }
5935 );
5936 semanticHits = searchResult.results;
5937 semanticMode = searchResult.mode;
5938 quotaExceeded = searchResult.quotaExceeded;
5939 } else {
5940 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5941 }
79136bbClaude5942 }
5943
53c9249Claude5944 const aiSearchCss = `
5945 /* ─── Semantic search mode toggle ─── */
5946 .search-mode-bar {
5947 display: flex;
5948 align-items: center;
5949 gap: 8px;
5950 margin-bottom: 14px;
5951 flex-wrap: wrap;
5952 }
5953 .search-mode-label {
5954 font-size: 12.5px;
5955 color: var(--text-muted);
5956 font-weight: 500;
5957 }
5958 .search-mode-toggle {
5959 display: inline-flex;
5960 background: var(--bg-elevated);
5961 border: 1px solid var(--border);
5962 border-radius: 9999px;
5963 padding: 3px;
5964 gap: 2px;
5965 }
5966 .search-mode-btn {
5967 display: inline-flex;
5968 align-items: center;
5969 gap: 5px;
5970 padding: 5px 12px;
5971 border-radius: 9999px;
5972 font-size: 12.5px;
5973 font-weight: 500;
5974 color: var(--text-muted);
5975 text-decoration: none;
5976 transition: color 120ms ease, background 120ms ease;
5977 cursor: pointer;
5978 border: none;
5979 background: none;
5980 font: inherit;
5981 }
5982 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5983 .search-mode-btn.active {
6fd5915Claude5984 background: rgba(91,110,232,0.15);
53c9249Claude5985 color: var(--text-strong);
5986 }
5987 .search-mode-ai-badge {
5988 display: inline-flex;
5989 align-items: center;
5990 gap: 4px;
5991 padding: 2px 8px;
5992 border-radius: 9999px;
5993 font-size: 11px;
5994 font-weight: 600;
6fd5915Claude5995 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5996 color: #c4b5fd;
6fd5915Claude5997 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5998 margin-left: 2px;
5999 }
6000 .search-quota-warn {
6001 font-size: 12.5px;
6002 color: var(--text-muted);
6003 padding: 6px 12px;
6004 background: rgba(255,200,0,0.06);
6005 border: 1px solid rgba(255,200,0,0.20);
6006 border-radius: 8px;
6007 }
6008
6009 /* ─── Semantic result cards ─── */
6010 .sem-results { display: flex; flex-direction: column; gap: 10px; }
6011 .sem-result {
6012 padding: 14px 16px;
6013 background: var(--bg-elevated);
6014 border: 1px solid var(--border);
6015 border-radius: 12px;
6016 transition: border-color 120ms ease;
6017 }
6018 .sem-result:hover { border-color: var(--border-strong); }
6019 .sem-result-head {
6020 display: flex;
6021 align-items: flex-start;
6022 justify-content: space-between;
6023 gap: 10px;
6024 flex-wrap: wrap;
6025 margin-bottom: 6px;
6026 }
6027 .sem-result-path {
6028 font-family: var(--font-mono);
6029 font-size: 13px;
6030 font-weight: 600;
6031 color: var(--text-strong);
6032 text-decoration: none;
6033 word-break: break-all;
6034 }
6035 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6036 .sem-result-conf {
6037 display: inline-flex;
6038 align-items: center;
6039 gap: 4px;
6040 padding: 2px 9px;
6041 border-radius: 9999px;
6042 font-size: 11.5px;
6043 font-weight: 600;
6044 white-space: nowrap;
6045 flex-shrink: 0;
6046 }
6047 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
6fd5915Claude6048 .sem-conf-possible { background: rgba(91,110,232,0.12); color: #5b6ee8; border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6049 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6050 .sem-result-reason {
6051 font-size: 12.5px;
6052 color: var(--text-muted);
6053 margin-bottom: 8px;
6054 line-height: 1.5;
6055 }
6056 .sem-result-snippet {
6057 margin: 0;
6058 padding: 10px 12px;
6059 background: rgba(0,0,0,0.22);
6060 border: 1px solid var(--border);
6061 border-radius: 8px;
6062 font-family: var(--font-mono);
6063 font-size: 12px;
6064 line-height: 1.55;
6065 color: var(--text);
6066 overflow-x: auto;
6067 white-space: pre-wrap;
6068 word-break: break-word;
6069 max-height: 240px;
6070 overflow-y: auto;
6071 }
6072 `;
6073
6074 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6075 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6076
79136bbClaude6077 return c.html(
6078 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6079 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6080 <RepoHeader owner={owner} repo={repo} />
6081 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6082 <div class="search-hero">
6083 <div class="search-eyebrow">
6084 <strong>Search</strong> · {owner}/{repo}
6085 </div>
6086 <h1 class="search-title">
53c9249Claude6087 {isSemantic ? (
6088 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6089 ) : (
6090 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6091 )}
efb11c5Claude6092 </h1>
6093 <form
6094 method="get"
6095 action={`/${owner}/${repo}/search`}
6096 class="search-form"
6097 role="search"
6098 >
53c9249Claude6099 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6100 <div class="search-input-wrap">
6101 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6102 <input
6103 type="text"
6104 name="q"
6105 value={q}
53c9249Claude6106 placeholder={
6107 isSemantic
6108 ? "Ask a question or describe what you're looking for…"
6109 : "Search code on the default branch…"
6110 }
efb11c5Claude6111 aria-label="Search code"
6112 class="search-input"
6113 autocomplete="off"
6114 autofocus
6115 />
6116 </div>
6117 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6118 Search
6119 </button>
efb11c5Claude6120 </form>
6121 </div>
53c9249Claude6122
6123 {/* Mode toggle */}
6124 <div class="search-mode-bar">
6125 <span class="search-mode-label">Mode:</span>
6126 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6127 {aiAvailable ? (
6128 <a
6129 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6130 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6131 aria-pressed={isSemantic ? "true" : "false"}
6132 >
6133 {"✨"} Semantic AI
6134 <span class="search-mode-ai-badge">AI-powered</span>
6135 </a>
6136 ) : null}
6137 <a
6138 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6139 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6140 aria-pressed={!isSemantic ? "true" : "false"}
6141 >
6142 {"⌕"} Keyword
6143 </a>
6144 </div>
6145 {isSemantic && aiAvailable && (
6146 <span style="font-size:12px;color:var(--text-muted)">
6147 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6148 </span>
53c9249Claude6149 )}
6150 {quotaExceeded && (
6151 <span class="search-quota-warn">
6152 Semantic search quota reached — showing keyword results
efb11c5Claude6153 </span>
53c9249Claude6154 )}
6155 </div>
6156
6157 {/* ─── Semantic results ─── */}
6158 {isSemantic && q && (
6159 <>
6160 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6161 <div class="search-quota-warn" style="margin-bottom:10px">
6162 Falling back to keyword search — Claude found no relevant files.
6163 </div>
6164 )}
6165 {semanticHits.length === 0 ? (
6166 <div class="search-empty">
6167 <p>
6168 No matches for <strong>"{q}"</strong>.{" "}
6169 Try a different phrasing or{" "}
6170 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6171 switch to keyword search
6172 </a>.
6173 </p>
6174 </div>
6175 ) : (
6176 <>
6177 <div class="search-results-head">
6178 <span class="search-results-count">
6179 <strong>{semanticHits.length}</strong> result
6180 {semanticHits.length !== 1 ? "s" : ""}
6181 </span>
6182 <span class="search-results-query">
6183 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6184 <span class="search-results-q">"{q}"</span>
6185 </span>
6186 </div>
6187 <div class="sem-results">
6188 {semanticHits.map((h) => {
6189 const confClass =
6190 h.confidence > 0.7
6191 ? "sem-conf-strong"
6192 : h.confidence > 0.4
6193 ? "sem-conf-possible"
6194 : "sem-conf-weak";
6195 const confLabel =
6196 h.confidence > 0.7
6197 ? "Strong match"
6198 : h.confidence > 0.4
6199 ? "Possible match"
6200 : "Weak match";
6201 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6202 const preview =
6203 h.snippet.length > 800
6204 ? h.snippet.slice(0, 800) + "\n…"
6205 : h.snippet;
6206 return (
6207 <div class="sem-result">
6208 <div class="sem-result-head">
6209 <a href={href} class="sem-result-path">
6210 {h.file}
6211 {h.lineNumber ? (
6212 <span style="color:var(--text-muted);font-weight:500">
6213 :{h.lineNumber}
6214 </span>
6215 ) : null}
6216 </a>
6217 <span class={`sem-result-conf ${confClass}`}>
6218 {confLabel}
6219 </span>
6220 </div>
6221 {h.reason && (
6222 <p class="sem-result-reason">{h.reason}</p>
6223 )}
6224 {preview && (
6225 <pre class="sem-result-snippet">{preview}</pre>
6226 )}
6227 </div>
6228 );
6229 })}
6230 </div>
6231 </>
6232 )}
6233 </>
79136bbClaude6234 )}
53c9249Claude6235
6236 {/* ─── Keyword results ─── */}
6237 {!isSemantic && q && (
6238 <>
6239 <div class="search-results-head">
6240 <span class="search-results-count">
6241 <strong>{keywordResults.length}</strong> result
6242 {keywordResults.length !== 1 ? "s" : ""}
6243 </span>
6244 <span class="search-results-query">
6245 for <span class="search-results-q">"{q}"</span> on{" "}
6246 <code>{defaultBranch}</code>
6247 </span>
6248 </div>
6249 {keywordResults.length === 0 ? (
6250 <div class="search-empty">
6251 <p>
6252 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6253 {aiAvailable && (
6254 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6255 try AI semantic search
79136bbClaude6256 </a>
53c9249Claude6257 )}.
6258 </p>
6259 </div>
6260 ) : (
6261 <div class="search-results">
6262 {(() => {
6263 const grouped: Record<
6264 string,
6265 Array<{ lineNum: number; line: string }>
6266 > = {};
6267 for (const r of keywordResults) {
6268 if (!grouped[r.file]) grouped[r.file] = [];
6269 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6270 }
6271 return Object.entries(grouped).map(([file, matches]) => (
6272 <div class="search-file diff-file">
6273 <div class="search-file-head diff-file-header">
6274 <a
6275 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6276 class="search-file-link"
6277 >
6278 {file}
6279 </a>
6280 <span class="search-file-count">
6281 {matches.length} match{matches.length === 1 ? "" : "es"}
6282 </span>
6283 </div>
6284 <div class="blob-code">
6285 <table>
6286 <tbody>
6287 {matches.map((m) => (
6288 <tr>
6289 <td class="line-num">{m.lineNum}</td>
6290 <td class="line-content">{m.line}</td>
6291 </tr>
6292 ))}
6293 </tbody>
6294 </table>
6295 </div>
6296 </div>
6297 ));
6298 })()}
6299 </div>
6300 )}
6301 </>
6302 )}
79136bbClaude6303 </Layout>
6304 );
6305});
6306
fc1817aClaude6307export default web;