Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

web.tsx

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

web.tsxBlame6303 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
124 try {
125 const [ownerRow] = await db
126 .select({ id: users.id })
127 .from(users)
128 .where(eq(users.username, owner))
129 .limit(1);
130 if (!ownerRow) return notFound();
131
132 const [repoRow] = await db
133 .select({ id: repositories.id, isPrivate: repositories.isPrivate })
134 .from(repositories)
135 .where(
136 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
137 )
138 .limit(1);
139 if (!repoRow) return notFound();
140
141 const access = await resolveRepoAccess({
142 repoId: repoRow.id,
143 userId: user?.id ?? null,
144 isPublic: !repoRow.isPrivate,
145 });
146 // Positive denial: the repo exists and the viewer lacks read access.
147 // Return 404 (not 403) so we never confirm a private repo's existence.
148 if (!satisfiesAccess(access, "read")) return notFound();
149
150 return null;
151 } catch (err) {
152 // The DB is unreachable — we cannot read the repo's privacy flag, so we
153 // cannot positively enforce the gate here. We fail OPEN rather than 404
154 // every repo (including public ones) during a DB outage: a private repo
155 // only exists as a DB row in the first place, and when the DB is down the
156 // platform is non-functional across the board. This mirrors the codebase's
157 // behavior when the DB is absent (e.g. the test harness runs the browse
158 // routes with no DATABASE_URL). It does NOT weaken the fix for the actual
159 // vulnerability, which is anonymous browsing of private repos while the DB
160 // is up — that path reads the row and denies above.
161 console.warn(
162 `[web] assertRepoReadable: access check degraded for ${owner}/${repo}:`,
163 err instanceof Error ? err.message : err
164 );
165 return null;
166 }
167}
168
ebaae0fClaude169// ---------------------------------------------------------------------------
170// Repo AI stats — computed once per repo home page load, best-effort.
171// Fast because every WHERE clause is bounded to a 7-day window.
172// ---------------------------------------------------------------------------
173interface RepoAiStats {
174 mergedCount: number;
175 reviewCount: number;
176 securityAlertCount: number;
177 hoursSaved: string;
178}
179
180async function getRepoAiStats(repoId: string): Promise<RepoAiStats> {
181 const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
182
183 // 1. AI-merged PRs this week: activity_feed entries with action =
184 // 'auto_merge.merged' in the last 7 days for this repo.
185 // This is cheaper than a JOIN on pull_requests and covers both the
186 // `mergedBy = botUser` pattern and the autopilot auto-merge path.
187 const [mergedRow] = await db
188 .select({ n: count() })
189 .from(activityFeed)
190 .where(
191 and(
192 eq(activityFeed.repositoryId, repoId),
193 eq(activityFeed.action, "auto_merge.merged"),
194 gte(activityFeed.createdAt, since)
195 )
196 );
197 const mergedCount = Number(mergedRow?.n ?? 0);
198
199 // 2. AI reviews this week: pr_comments where is_ai_review = true in
200 // the last 7 days, joined through pull_requests to scope to this repo.
201 const [reviewRow] = await db
202 .select({ n: count() })
203 .from(prComments)
204 .innerJoin(pullRequests, eq(prComments.pullRequestId, pullRequests.id))
205 .where(
206 and(
207 eq(pullRequests.repositoryId, repoId),
208 eq(prComments.isAiReview, true),
209 gte(prComments.createdAt, since)
210 )
211 );
212 const reviewCount = Number(reviewRow?.n ?? 0);
213
214 // 3. Open security alerts: open issues that carry a label whose name
215 // contains 'security' (case-insensitive) for this repo.
216 const [secRow] = await db
217 .select({ n: count() })
218 .from(issues)
219 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
220 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
221 .where(
222 and(
223 eq(issues.repositoryId, repoId),
224 eq(issues.state, "open"),
225 sql`lower(${labels.name}) like '%security%'`
226 )
227 );
228 const securityAlertCount = Number(secRow?.n ?? 0);
229
230 // Hours saved: each AI-merged PR ~ 1.5 h, each AI review ~ 0.5 h.
231 const hours = mergedCount * 1.5 + reviewCount * 0.5;
232 const hoursSaved = hours.toFixed(1);
233
234 return { mergedCount, reviewCount, securityAlertCount, hoursSaved };
235}
236
8c790e0Claude237/**
238 * Query the most recent push to a repo from the activity_feed table.
239 * Returns a RecentPush if there's been a push in the last 24 hours,
240 * or null otherwise. Used by the RepoHeader live/watch indicator.
241 *
242 * Queries activity_feed where action='push' and targetId holds the commit SHA.
243 */
244async function getRecentPush(repoId: string): Promise<RecentPush | null> {
245 try {
246 const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
247 const [row] = await db
248 .select({
249 targetId: activityFeed.targetId,
250 createdAt: activityFeed.createdAt,
251 })
252 .from(activityFeed)
253 .where(
254 and(
255 eq(activityFeed.repositoryId, repoId),
256 eq(activityFeed.action, "push"),
257 sql`${activityFeed.createdAt} >= ${cutoff.toISOString()}`
258 )
259 )
260 .orderBy(desc(activityFeed.createdAt))
261 .limit(1);
262 if (!row || !row.targetId) return null;
263 return {
264 sha: row.targetId,
265 ageMs: Date.now() - new Date(row.createdAt).getTime(),
266 };
267 } catch {
268 // DB unavailable or schema mismatch — degrade gracefully.
269 return null;
270 }
271}
272
efb11c5Claude273/**
274 * Shared CSS for the polished code-browse surfaces (parallel session 3.E).
275 *
276 * Inlined here rather than in `src/views/layout.tsx` because session 3.E's
277 * scope is route-local and `layout.tsx` is locked. Each polished handler
278 * injects this via a `<style>` tag; the rules are namespaced by surface
279 * prefix (`.new-repo-*`, `.profile-*`, `.tree-*`, `.blob-*`, `.commits-*`,
280 * `.commit-detail-*`, `.blame-*`, `.search-*`) so nothing bleeds into the
281 * `.repo-home-*` styling Agent A already shipped.
282 */
283const codeBrowseCss = `
284 /* ───────── shared primitives ───────── */
285 .cb-hairline::before,
286 .new-repo-hero::before,
287 .profile-hero::before,
288 .commits-hero::before,
289 .commit-detail-card::before,
290 .search-hero::before {
291 content: '';
292 position: absolute;
293 top: 0; left: 0; right: 0;
294 height: 2px;
6fd5915Claude295 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
efb11c5Claude296 opacity: 0.7;
297 pointer-events: none;
298 }
299 @keyframes cbHeroOrb {
300 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
301 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
302 }
303 @media (prefers-reduced-motion: reduce) {
304 .new-repo-hero-orb,
305 .profile-hero-orb,
306 .commits-hero-orb { animation: none; }
307 }
308
309 /* ───────── new-repo ───────── */
310 .new-repo-hero {
311 position: relative;
312 margin-bottom: var(--space-5);
313 padding: var(--space-5) var(--space-6);
314 background: var(--bg-elevated);
315 border: 1px solid var(--border);
316 border-radius: 16px;
317 overflow: hidden;
318 }
319 .new-repo-hero-orb-wrap {
320 position: absolute;
321 inset: -25% -10% auto auto;
322 width: 360px;
323 height: 360px;
324 pointer-events: none;
325 z-index: 0;
326 }
327 .new-repo-hero-orb {
328 position: absolute;
329 inset: 0;
6fd5915Claude330 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude331 filter: blur(80px);
332 opacity: 0.7;
333 animation: cbHeroOrb 14s ease-in-out infinite;
334 }
335 .new-repo-hero-inner { position: relative; z-index: 1; }
336 .new-repo-eyebrow {
337 font-size: 12px;
338 font-family: var(--font-mono);
339 color: var(--text-muted);
340 letter-spacing: 0.1em;
341 text-transform: uppercase;
342 margin-bottom: var(--space-2);
343 }
344 .new-repo-eyebrow strong { color: var(--accent); font-weight: 600; }
345 .new-repo-title {
346 font-family: var(--font-display);
347 font-weight: 800;
348 letter-spacing: -0.028em;
349 font-size: clamp(28px, 4vw, 40px);
350 line-height: 1.05;
351 margin: 0 0 var(--space-2);
352 color: var(--text-strong);
353 }
354 .new-repo-sub {
355 font-size: 15px;
356 color: var(--text-muted);
357 margin: 0;
358 line-height: 1.55;
359 max-width: 620px;
360 }
361 .new-repo-form {
362 max-width: 680px;
363 }
364 .new-repo-error {
365 background: rgba(218, 54, 51, 0.12);
366 border: 1px solid rgba(218, 54, 51, 0.35);
367 color: #ffb3b3;
368 padding: 10px 14px;
369 border-radius: 10px;
370 margin-bottom: var(--space-4);
371 font-size: 14px;
372 }
373 .new-repo-form-grid {
374 display: flex;
375 flex-direction: column;
376 gap: var(--space-4);
377 }
378 .new-repo-row { display: flex; flex-direction: column; gap: 6px; }
379 .new-repo-label {
380 font-size: 13px;
381 color: var(--text-strong);
382 font-weight: 600;
383 }
384 .new-repo-label-optional {
385 color: var(--text-muted);
386 font-weight: 400;
387 font-size: 12px;
388 }
389 .new-repo-input {
390 appearance: none;
391 width: 100%;
392 background: var(--bg);
393 border: 1px solid var(--border);
394 color: var(--text-strong);
395 border-radius: 10px;
396 padding: 10px 12px;
397 font-size: 14px;
398 font-family: inherit;
399 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
400 }
401 .new-repo-input:focus {
402 outline: none;
403 border-color: var(--accent);
6fd5915Claude404 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude405 }
406 .new-repo-input-disabled {
407 color: var(--text-muted);
408 background: var(--bg-secondary);
409 cursor: not-allowed;
410 }
411 .new-repo-hint {
412 font-size: 12px;
413 color: var(--text-muted);
414 margin: 4px 0 0;
415 }
416 .new-repo-hint code {
417 font-family: var(--font-mono);
418 font-size: 11.5px;
419 background: var(--bg-secondary);
420 border: 1px solid var(--border);
421 border-radius: 5px;
422 padding: 1px 5px;
423 color: var(--text-strong);
424 }
425 .new-repo-visibility {
426 display: grid;
427 grid-template-columns: 1fr 1fr;
428 gap: var(--space-2);
429 }
430 @media (max-width: 600px) {
431 .new-repo-visibility { grid-template-columns: 1fr; }
432 }
433 .new-repo-vis-card {
434 display: flex;
435 gap: 10px;
436 padding: 14px;
437 background: var(--bg);
438 border: 1px solid var(--border);
439 border-radius: 12px;
440 cursor: pointer;
441 transition: border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
442 }
443 .new-repo-vis-card:hover { border-color: var(--border-strong, var(--border)); }
444 .new-repo-vis-card:has(input:checked) {
6fd5915Claude445 border-color: rgba(91,110,232,0.55);
446 background: rgba(91,110,232,0.06);
447 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
efb11c5Claude448 }
449 .new-repo-vis-radio {
450 margin-top: 3px;
451 accent-color: var(--accent);
452 }
453 .new-repo-vis-body { display: flex; flex-direction: column; gap: 4px; }
454 .new-repo-vis-label {
455 font-size: 14px;
456 font-weight: 600;
457 color: var(--text-strong);
458 }
459 .new-repo-vis-desc {
460 font-size: 12.5px;
461 color: var(--text-muted);
462 line-height: 1.45;
463 }
464 .new-repo-callout {
465 margin-top: var(--space-2);
466 padding: var(--space-3) var(--space-4);
6fd5915Claude467 background: var(--accent-gradient-faint, rgba(91,110,232,0.06));
468 border: 1px solid rgba(91,110,232,0.2);
efb11c5Claude469 border-radius: 12px;
470 }
471 .new-repo-callout-eyebrow {
472 font-size: 11px;
473 font-family: var(--font-mono);
474 text-transform: uppercase;
475 letter-spacing: 0.12em;
476 color: var(--accent);
477 font-weight: 700;
478 margin-bottom: 4px;
479 }
480 .new-repo-callout-body {
481 font-size: 13px;
482 color: var(--text);
483 line-height: 1.5;
484 margin: 0;
485 }
486 .new-repo-callout-body code {
487 font-family: var(--font-mono);
488 font-size: 12px;
489 color: var(--accent);
6fd5915Claude490 background: rgba(91,110,232,0.1);
efb11c5Claude491 border-radius: 4px;
492 padding: 1px 5px;
493 }
398a10cClaude494 .new-repo-templates {
495 display: flex;
496 flex-wrap: wrap;
497 gap: 8px;
498 margin-top: 4px;
499 }
500 .new-repo-template-chip {
501 position: relative;
502 display: inline-flex;
503 align-items: center;
504 gap: 6px;
505 padding: 8px 14px;
506 background: var(--bg);
507 border: 1px solid var(--border);
508 border-radius: 999px;
509 font-size: 13px;
510 color: var(--text);
511 cursor: pointer;
512 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
513 }
514 .new-repo-template-chip input { position: absolute; opacity: 0; pointer-events: none; }
515 .new-repo-template-chip:hover {
516 border-color: var(--border-strong, var(--border));
517 color: var(--text-strong);
518 }
519 .new-repo-template-chip:has(input:checked) {
6fd5915Claude520 border-color: rgba(91,110,232,0.55);
521 background: rgba(91,110,232,0.10);
398a10cClaude522 color: var(--text-strong);
6fd5915Claude523 box-shadow: 0 0 0 1px rgba(91,110,232,0.25);
398a10cClaude524 }
525 .new-repo-template-chip-dot {
526 width: 8px; height: 8px;
527 border-radius: 999px;
528 background: var(--text-faint, var(--text-muted));
529 transition: background 140ms ease, box-shadow 140ms ease;
530 }
531 .new-repo-template-chip:has(input:checked) .new-repo-template-chip-dot {
6fd5915Claude532 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
533 box-shadow: 0 0 8px rgba(91,110,232,0.6);
398a10cClaude534 }
efb11c5Claude535 .new-repo-actions {
536 display: flex;
537 gap: var(--space-2);
538 margin-top: var(--space-2);
398a10cClaude539 align-items: center;
540 }
541 .new-repo-submit {
542 min-width: 180px;
6fd5915Claude543 border: 1px solid rgba(91,110,232,0.45);
544 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
398a10cClaude545 color: #fff;
546 font-weight: 700;
6fd5915Claude547 box-shadow: 0 8px 20px -8px rgba(91,110,232,0.55);
398a10cClaude548 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
549 }
550 .new-repo-submit:hover {
551 transform: translateY(-1px);
6fd5915Claude552 box-shadow: 0 12px 24px -8px rgba(91,110,232,0.7);
398a10cClaude553 filter: brightness(1.06);
554 }
555 .new-repo-submit:focus-visible {
6fd5915Claude556 outline: 3px solid rgba(91,110,232,0.45);
398a10cClaude557 outline-offset: 2px;
efb11c5Claude558 }
559
560 /* ───────── profile ───────── */
561 .profile-hero {
562 position: relative;
563 margin-bottom: var(--space-5);
564 padding: var(--space-5) var(--space-6);
565 background: var(--bg-elevated);
566 border: 1px solid var(--border);
567 border-radius: 16px;
568 overflow: hidden;
569 }
570 .profile-hero-orb-wrap {
571 position: absolute;
572 inset: -25% -10% auto auto;
573 width: 360px;
574 height: 360px;
575 pointer-events: none;
576 z-index: 0;
577 }
578 .profile-hero-orb {
579 position: absolute;
580 inset: 0;
6fd5915Claude581 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
efb11c5Claude582 filter: blur(80px);
583 opacity: 0.7;
584 animation: cbHeroOrb 14s ease-in-out infinite;
585 }
586 .profile-hero-inner {
587 position: relative;
588 z-index: 1;
589 display: flex;
590 align-items: flex-start;
591 gap: var(--space-5);
592 }
593 .profile-hero-avatar {
594 flex: 0 0 auto;
595 width: 88px;
596 height: 88px;
597 border-radius: 50%;
6fd5915Claude598 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
efb11c5Claude599 color: #fff;
600 display: flex;
601 align-items: center;
602 justify-content: center;
603 font-size: 38px;
604 font-weight: 700;
605 font-family: var(--font-display);
6fd5915Claude606 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.55);
efb11c5Claude607 }
608 .profile-hero-text { flex: 1; min-width: 0; }
609 .profile-eyebrow {
610 font-size: 12px;
611 font-family: var(--font-mono);
612 color: var(--text-muted);
613 letter-spacing: 0.1em;
614 text-transform: uppercase;
615 margin-bottom: var(--space-2);
616 }
617 .profile-eyebrow strong { color: var(--accent); font-weight: 600; }
618 .profile-name {
619 font-family: var(--font-display);
620 font-weight: 800;
621 letter-spacing: -0.028em;
622 font-size: clamp(28px, 3.6vw, 36px);
623 line-height: 1.05;
624 margin: 0 0 4px;
625 color: var(--text-strong);
626 }
627 .profile-handle {
628 font-family: var(--font-mono);
629 font-size: 13px;
630 color: var(--text-muted);
631 margin-bottom: var(--space-2);
632 }
633 .profile-bio {
634 font-size: 14.5px;
635 color: var(--text);
636 line-height: 1.55;
637 margin: 0 0 var(--space-3);
638 max-width: 640px;
639 }
640 .profile-meta {
641 display: flex;
642 align-items: center;
643 gap: var(--space-4);
644 flex-wrap: wrap;
645 font-size: 13px;
646 }
647 .profile-meta-link {
648 color: var(--text-muted);
649 transition: color var(--t-fast, 0.15s) ease;
650 }
651 .profile-meta-link:hover { color: var(--accent); text-decoration: none; }
652 .profile-meta-link strong {
653 color: var(--text-strong);
654 font-weight: 600;
655 font-variant-numeric: tabular-nums;
656 }
657 .profile-follow-form { margin: 0; }
658 @media (max-width: 600px) {
659 .profile-hero-inner { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
660 .profile-hero-avatar { width: 64px; height: 64px; font-size: 28px; }
661 }
662 .profile-readme {
663 margin-bottom: var(--space-6);
664 background: var(--bg-elevated);
665 border: 1px solid var(--border);
666 border-radius: 12px;
667 overflow: hidden;
668 }
669 .profile-readme-head {
670 display: flex;
671 align-items: center;
672 gap: 8px;
673 padding: 10px 16px;
674 background: var(--bg-secondary);
675 border-bottom: 1px solid var(--border);
676 font-size: 13px;
677 color: var(--text-muted);
678 }
679 .profile-readme-icon { color: var(--accent); font-size: 14px; }
680 .profile-readme-body { padding: var(--space-5) var(--space-6); }
681 .profile-section-head {
682 display: flex;
683 align-items: baseline;
684 gap: var(--space-2);
685 margin-bottom: var(--space-3);
686 }
687 .profile-section-title {
688 font-family: var(--font-display);
689 font-weight: 700;
690 font-size: 20px;
691 letter-spacing: -0.015em;
692 margin: 0;
693 color: var(--text-strong);
694 }
695 .profile-section-count {
696 font-family: var(--font-mono);
697 font-size: 12px;
698 color: var(--text-muted);
699 background: var(--bg-secondary);
700 border: 1px solid var(--border);
701 border-radius: 999px;
702 padding: 2px 10px;
703 font-variant-numeric: tabular-nums;
704 }
705 .profile-empty {
706 display: flex;
707 align-items: center;
708 justify-content: space-between;
709 gap: var(--space-3);
710 padding: var(--space-4) var(--space-5);
711 background: var(--bg-elevated);
712 border: 1px dashed var(--border);
713 border-radius: 12px;
714 }
715 .profile-empty-text { color: var(--text-muted); font-size: 14px; margin: 0; }
716
717 /* ───────── tree (file browser) ───────── */
718 .tree-header {
719 margin-bottom: var(--space-3);
720 display: flex;
721 flex-direction: column;
722 gap: var(--space-2);
723 }
724 .tree-header-row {
725 display: flex;
726 align-items: center;
727 justify-content: space-between;
728 gap: var(--space-3);
729 flex-wrap: wrap;
730 }
731 .tree-header-stats {
732 display: flex;
733 align-items: center;
734 gap: var(--space-3);
735 font-size: 12px;
736 color: var(--text-muted);
737 }
738 .tree-stat strong {
739 color: var(--text-strong);
740 font-weight: 600;
741 font-variant-numeric: tabular-nums;
742 }
743 .tree-stat-link {
744 color: var(--text-muted);
745 padding: 4px 10px;
746 border-radius: 999px;
747 border: 1px solid var(--border);
748 background: var(--bg-elevated);
749 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease;
750 }
751 .tree-stat-link:hover {
752 color: var(--accent);
6fd5915Claude753 border-color: rgba(91,110,232,0.45);
efb11c5Claude754 text-decoration: none;
755 }
756 .tree-breadcrumb-row {
757 font-size: 13px;
758 }
759
760 /* ───────── blob (file viewer) ───────── */
761 .blob-toolbar {
762 margin-bottom: var(--space-3);
763 display: flex;
764 flex-direction: column;
765 gap: var(--space-2);
766 }
767 .blob-breadcrumb { font-size: 13px; }
768 .blob-card {
769 background: var(--bg-elevated);
770 border: 1px solid var(--border);
771 border-radius: 12px;
772 overflow: hidden;
773 }
774 .blob-header-polished {
775 display: flex;
776 align-items: center;
777 justify-content: space-between;
778 gap: var(--space-3);
779 padding: 10px 14px;
780 background: var(--bg-secondary);
781 border-bottom: 1px solid var(--border);
782 }
783 .blob-header-meta {
784 display: flex;
785 align-items: center;
786 gap: var(--space-2);
787 min-width: 0;
788 flex: 1;
789 }
790 .blob-header-icon { font-size: 14px; opacity: 0.85; }
791 .blob-header-name {
792 font-family: var(--font-mono);
793 font-size: 13px;
794 color: var(--text-strong);
795 font-weight: 600;
796 overflow: hidden;
797 text-overflow: ellipsis;
798 white-space: nowrap;
799 }
800 .blob-header-size {
801 font-family: var(--font-mono);
802 font-size: 11.5px;
803 color: var(--text-muted);
804 font-variant-numeric: tabular-nums;
805 border-left: 1px solid var(--border);
806 padding-left: var(--space-2);
807 margin-left: 2px;
808 }
809 .blob-header-actions {
810 display: flex;
811 gap: 6px;
812 flex-shrink: 0;
813 }
814 .blob-pill {
815 display: inline-flex;
816 align-items: center;
817 padding: 4px 12px;
818 font-size: 12px;
819 font-weight: 500;
820 color: var(--text);
821 background: var(--bg-elevated);
822 border: 1px solid var(--border);
823 border-radius: 999px;
824 transition: color var(--t-fast, 0.15s) ease, border-color var(--t-fast, 0.15s) ease, background var(--t-fast, 0.15s) ease;
825 }
826 .blob-pill:hover {
827 color: var(--accent);
6fd5915Claude828 border-color: rgba(91,110,232,0.45);
efb11c5Claude829 text-decoration: none;
6fd5915Claude830 background: rgba(91,110,232,0.06);
efb11c5Claude831 }
832 .blob-pill-accent {
833 color: var(--accent);
6fd5915Claude834 border-color: rgba(91,110,232,0.35);
835 background: rgba(91,110,232,0.08);
efb11c5Claude836 }
837 .blob-pill-accent:hover {
6fd5915Claude838 background: rgba(91,110,232,0.14);
efb11c5Claude839 }
840 .blob-binary {
841 padding: var(--space-5);
842 color: var(--text-muted);
843 text-align: center;
844 font-size: 13px;
845 background: var(--bg);
846 }
847
848 /* ───────── commits list ───────── */
849 .commits-hero {
850 position: relative;
851 margin-bottom: var(--space-4);
852 padding: var(--space-5) var(--space-6);
853 background: var(--bg-elevated);
854 border: 1px solid var(--border);
855 border-radius: 16px;
856 overflow: hidden;
857 }
858 .commits-hero-orb-wrap {
859 position: absolute;
860 inset: -25% -10% auto auto;
861 width: 320px;
862 height: 320px;
863 pointer-events: none;
864 z-index: 0;
865 }
866 .commits-hero-orb {
867 position: absolute;
868 inset: 0;
6fd5915Claude869 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
efb11c5Claude870 filter: blur(80px);
871 opacity: 0.7;
872 animation: cbHeroOrb 14s ease-in-out infinite;
873 }
874 .commits-hero-inner { position: relative; z-index: 1; }
875 .commits-eyebrow {
876 font-size: 12px;
877 font-family: var(--font-mono);
878 color: var(--text-muted);
879 letter-spacing: 0.1em;
880 text-transform: uppercase;
881 margin-bottom: var(--space-2);
882 }
883 .commits-eyebrow strong { color: var(--accent); font-weight: 600; }
884 .commits-title {
885 font-family: var(--font-display);
886 font-weight: 800;
887 letter-spacing: -0.025em;
888 font-size: clamp(22px, 3vw, 30px);
889 line-height: 1.15;
890 margin: 0 0 var(--space-2);
891 color: var(--text-strong);
892 }
893 .commits-branch {
894 font-family: var(--font-mono);
895 font-size: 0.7em;
896 color: var(--text);
897 background: var(--bg-secondary);
898 border: 1px solid var(--border);
899 border-radius: 6px;
900 padding: 2px 8px;
901 font-weight: 500;
902 vertical-align: middle;
903 }
904 .commits-sub {
905 font-size: 14px;
906 color: var(--text-muted);
907 margin: 0;
908 line-height: 1.55;
909 max-width: 640px;
910 }
398a10cClaude911 .commits-toolbar {
912 display: flex;
913 justify-content: space-between;
914 align-items: center;
915 flex-wrap: wrap;
916 gap: var(--space-2);
917 margin-bottom: var(--space-3);
918 }
919 .commits-toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
920 .commits-toolbar-link {
921 display: inline-flex;
922 align-items: center;
923 gap: 6px;
924 padding: 6px 12px;
925 font-size: 12.5px;
926 font-weight: 500;
927 color: var(--text-muted);
928 background: rgba(255,255,255,0.025);
929 border: 1px solid var(--border);
930 border-radius: 8px;
931 text-decoration: none;
932 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
933 }
934 .commits-toolbar-link:hover {
935 border-color: var(--border-strong);
936 color: var(--text-strong);
937 background: rgba(255,255,255,0.04);
938 text-decoration: none;
939 }
efb11c5Claude940 .commits-list-wrap {
941 background: var(--bg-elevated);
942 border: 1px solid var(--border);
398a10cClaude943 border-radius: 14px;
efb11c5Claude944 overflow: hidden;
398a10cClaude945 position: relative;
946 }
947 .commits-list-wrap::before {
948 content: '';
949 position: absolute;
950 top: 0; left: 0; right: 0;
951 height: 2px;
6fd5915Claude952 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude953 opacity: 0.55;
954 pointer-events: none;
955 }
956 .commits-day-head {
957 display: flex;
958 align-items: center;
959 gap: 10px;
960 padding: 10px 18px;
961 font-size: 11.5px;
962 font-family: var(--font-mono);
963 text-transform: uppercase;
964 letter-spacing: 0.08em;
965 color: var(--text-muted);
966 background: var(--bg-secondary);
967 border-bottom: 1px solid var(--border);
968 }
969 .commits-day-head:not(:first-child) { border-top: 1px solid var(--border); }
970 .commits-day-head-dot {
971 width: 6px; height: 6px;
972 border-radius: 9999px;
6fd5915Claude973 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
398a10cClaude974 }
975 .commits-row {
976 display: flex;
977 align-items: flex-start;
978 gap: 14px;
979 padding: 14px 18px;
980 border-bottom: 1px solid var(--border-subtle);
981 transition: background 120ms ease;
982 }
983 .commits-row:last-child { border-bottom: none; }
984 .commits-row:hover { background: rgba(255,255,255,0.022); }
985 .commits-avatar {
986 width: 34px; height: 34px;
987 border-radius: 9999px;
6fd5915Claude988 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
398a10cClaude989 color: #fff;
990 display: inline-flex;
991 align-items: center;
992 justify-content: center;
993 font-family: var(--font-display);
994 font-weight: 700;
995 font-size: 13.5px;
996 flex-shrink: 0;
997 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
998 }
999 .commits-row-body { flex: 1; min-width: 0; }
1000 .commits-row-msg {
1001 display: flex;
1002 align-items: center;
1003 gap: 8px;
1004 flex-wrap: wrap;
1005 }
1006 .commits-row-msg a {
1007 font-family: var(--font-display);
1008 font-weight: 600;
1009 font-size: 14px;
1010 color: var(--text-strong);
1011 text-decoration: none;
1012 letter-spacing: -0.005em;
1013 }
1014 .commits-row-msg a:hover { color: var(--accent); text-decoration: none; }
1015 .commits-row-verified {
1016 font-size: 9.5px;
1017 padding: 1px 7px;
1018 border-radius: 9999px;
1019 background: rgba(52,211,153,0.16);
1020 color: #6ee7b7;
1021 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
1022 text-transform: uppercase;
1023 letter-spacing: 0.06em;
1024 font-weight: 700;
1025 }
1026 .commits-row-meta {
1027 margin-top: 4px;
1028 display: flex;
1029 align-items: center;
1030 gap: 8px;
1031 flex-wrap: wrap;
1032 font-size: 12.5px;
1033 color: var(--text-muted);
1034 }
1035 .commits-row-meta strong { color: var(--text); font-weight: 600; }
1036 .commits-row-meta .sep { opacity: 0.4; }
1037 .commits-row-time {
1038 font-variant-numeric: tabular-nums;
1039 }
1040 .commits-row-side {
1041 display: flex;
1042 align-items: center;
1043 gap: 8px;
1044 flex-shrink: 0;
1045 }
1046 .commits-row-sha {
1047 display: inline-flex;
1048 align-items: center;
1049 padding: 4px 10px;
1050 border-radius: 9999px;
1051 font-family: var(--font-mono);
1052 font-size: 11.5px;
1053 font-weight: 600;
1054 color: var(--text-strong);
1055 background: rgba(255,255,255,0.04);
1056 border: 1px solid var(--border);
1057 text-decoration: none;
1058 letter-spacing: 0.04em;
1059 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1060 }
1061 .commits-row-sha:hover {
6fd5915Claude1062 border-color: rgba(91,110,232,0.55);
398a10cClaude1063 color: var(--accent);
6fd5915Claude1064 background: rgba(91,110,232,0.08);
398a10cClaude1065 text-decoration: none;
1066 }
1067 .commits-row-copy {
1068 display: inline-flex;
1069 align-items: center;
1070 justify-content: center;
1071 width: 28px; height: 28px;
1072 padding: 0;
1073 border-radius: 8px;
1074 background: transparent;
1075 border: 1px solid var(--border);
1076 color: var(--text-muted);
1077 cursor: pointer;
1078 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
efb11c5Claude1079 }
398a10cClaude1080 .commits-row-copy:hover {
1081 border-color: var(--border-strong);
1082 color: var(--text);
1083 background: rgba(255,255,255,0.04);
1084 }
1085 .commits-row-copy.is-copied { color: #6ee7b7; border-color: rgba(52,211,153,0.35); }
8c790e0Claude1086 /* Push Watch link — eye icon next to the SHA chip */
1087 .commits-row-watch {
1088 display: inline-flex;
1089 align-items: center;
1090 justify-content: center;
1091 width: 28px;
1092 height: 28px;
1093 border-radius: 6px;
1094 border: 1px solid var(--border);
1095 color: var(--text-muted);
1096 background: transparent;
1097 cursor: pointer;
1098 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1099 text-decoration: none !important;
1100 }
1101 .commits-row-watch:hover {
6fd5915Claude1102 border-color: rgba(91,110,232,0.45);
8c790e0Claude1103 color: var(--accent);
6fd5915Claude1104 background: rgba(91,110,232,0.08);
8c790e0Claude1105 text-decoration: none !important;
1106 }
efb11c5Claude1107 .commits-empty {
398a10cClaude1108 position: relative;
1109 overflow: hidden;
1110 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
efb11c5Claude1111 text-align: center;
398a10cClaude1112 background: var(--bg-elevated);
1113 border: 1px dashed var(--border-strong);
1114 border-radius: 16px;
1115 }
1116 .commits-empty-orb {
1117 position: absolute;
1118 inset: -40% 30% auto 30%;
1119 height: 280px;
6fd5915Claude1120 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
398a10cClaude1121 filter: blur(70px);
1122 opacity: 0.7;
1123 pointer-events: none;
1124 z-index: 0;
1125 }
1126 .commits-empty-inner { position: relative; z-index: 1; }
1127 .commits-empty-icon {
1128 width: 56px; height: 56px;
1129 border-radius: 9999px;
6fd5915Claude1130 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
1131 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
398a10cClaude1132 display: inline-flex;
1133 align-items: center;
1134 justify-content: center;
1135 color: #c4b5fd;
1136 margin: 0 auto 14px;
1137 }
1138 .commits-empty-title {
1139 font-family: var(--font-display);
1140 font-size: 18px;
1141 font-weight: 700;
1142 margin: 0 0 6px;
1143 color: var(--text-strong);
1144 }
1145 .commits-empty-sub {
1146 margin: 0 auto 0;
1147 font-size: 13.5px;
efb11c5Claude1148 color: var(--text-muted);
398a10cClaude1149 max-width: 420px;
1150 line-height: 1.5;
1151 }
1152
1153 /* ───────── branches list ───────── */
1154 .branches-list {
efb11c5Claude1155 background: var(--bg-elevated);
398a10cClaude1156 border: 1px solid var(--border);
1157 border-radius: 14px;
1158 overflow: hidden;
1159 position: relative;
1160 }
1161 .branches-list::before {
1162 content: '';
1163 position: absolute;
1164 top: 0; left: 0; right: 0;
1165 height: 2px;
6fd5915Claude1166 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1167 opacity: 0.55;
1168 pointer-events: none;
1169 }
1170 .branches-row {
1171 display: flex;
1172 align-items: center;
1173 gap: var(--space-3);
1174 padding: 14px 18px;
1175 border-bottom: 1px solid var(--border-subtle);
1176 transition: background 120ms ease;
1177 flex-wrap: wrap;
1178 }
1179 .branches-row:last-child { border-bottom: none; }
1180 .branches-row:hover { background: rgba(255,255,255,0.022); }
1181 .branches-row-icon {
1182 width: 32px; height: 32px;
1183 border-radius: 8px;
6fd5915Claude1184 background: rgba(91,110,232,0.10);
398a10cClaude1185 color: #c4b5fd;
1186 display: inline-flex;
1187 align-items: center;
1188 justify-content: center;
1189 flex-shrink: 0;
6fd5915Claude1190 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1191 }
1192 .branches-row-main { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 4px; }
1193 .branches-row-name {
1194 display: inline-flex;
1195 align-items: center;
1196 gap: 8px;
1197 flex-wrap: wrap;
1198 }
1199 .branches-row-name a {
1200 font-family: var(--font-mono);
1201 font-size: 13.5px;
1202 color: var(--text-strong);
1203 font-weight: 600;
1204 text-decoration: none;
1205 }
1206 .branches-row-name a:hover { color: var(--accent); text-decoration: none; }
1207 .branches-row-default {
1208 font-size: 10px;
1209 padding: 2px 8px;
1210 border-radius: 9999px;
6fd5915Claude1211 background: rgba(91,110,232,0.14);
398a10cClaude1212 color: #c4b5fd;
6fd5915Claude1213 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
398a10cClaude1214 text-transform: uppercase;
1215 letter-spacing: 0.08em;
1216 font-weight: 700;
1217 font-family: var(--font-mono);
1218 }
1219 .branches-row-meta {
1220 display: flex;
1221 align-items: center;
1222 gap: 8px;
1223 flex-wrap: wrap;
1224 font-size: 12.5px;
1225 color: var(--text-muted);
1226 font-variant-numeric: tabular-nums;
1227 }
1228 .branches-row-meta .sep { opacity: 0.4; }
1229 .branches-row-meta strong { color: var(--text); font-weight: 600; }
1230 .branches-row-side {
1231 display: flex;
1232 align-items: center;
1233 gap: 10px;
1234 flex-shrink: 0;
1235 flex-wrap: wrap;
1236 }
1237 .branches-row-divergence {
1238 display: inline-flex;
1239 align-items: center;
1240 gap: 8px;
1241 font-family: var(--font-mono);
1242 font-size: 11.5px;
1243 color: var(--text-muted);
1244 padding: 4px 10px;
1245 border-radius: 9999px;
1246 background: rgba(255,255,255,0.035);
1247 border: 1px solid var(--border);
1248 font-variant-numeric: tabular-nums;
1249 }
1250 .branches-row-divergence .ahead { color: #6ee7b7; }
1251 .branches-row-divergence .behind { color: #fca5a5; }
1252 .branches-row-actions {
1253 display: flex;
1254 align-items: center;
1255 gap: 6px;
1256 }
1257 .branches-btn {
1258 display: inline-flex;
1259 align-items: center;
1260 gap: 5px;
1261 padding: 6px 12px;
1262 border-radius: 9999px;
1263 font-size: 12px;
1264 font-weight: 600;
1265 text-decoration: none;
1266 border: 1px solid var(--border);
1267 background: transparent;
1268 color: var(--text-muted);
1269 cursor: pointer;
1270 font: inherit;
1271 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1272 }
1273 .branches-btn:hover {
1274 border-color: var(--border-strong);
1275 color: var(--text);
1276 background: rgba(255,255,255,0.04);
1277 text-decoration: none;
1278 }
1279 .branches-btn-danger {
1280 color: #fca5a5;
1281 border-color: rgba(248,113,113,0.30);
1282 }
1283 .branches-btn-danger:hover {
1284 border-style: dashed;
1285 border-color: rgba(248,113,113,0.65);
1286 background: rgba(248,113,113,0.06);
1287 color: #fecaca;
1288 }
1289
1290 /* ───────── tags list ───────── */
1291 .tags-list {
1292 background: var(--bg-elevated);
1293 border: 1px solid var(--border);
1294 border-radius: 14px;
1295 overflow: hidden;
1296 position: relative;
1297 }
1298 .tags-list::before {
1299 content: '';
1300 position: absolute;
1301 top: 0; left: 0; right: 0;
1302 height: 2px;
6fd5915Claude1303 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1304 opacity: 0.55;
1305 pointer-events: none;
1306 }
1307 .tags-row {
1308 display: flex;
1309 align-items: center;
1310 gap: var(--space-3);
1311 padding: 14px 18px;
1312 border-bottom: 1px solid var(--border-subtle);
1313 transition: background 120ms ease;
1314 flex-wrap: wrap;
1315 }
1316 .tags-row:last-child { border-bottom: none; }
1317 .tags-row:hover { background: rgba(255,255,255,0.022); }
1318 .tags-row-icon {
1319 width: 32px; height: 32px;
1320 border-radius: 8px;
6fd5915Claude1321 background: rgba(95,143,160,0.10);
398a10cClaude1322 color: #67e8f9;
1323 display: inline-flex;
1324 align-items: center;
1325 justify-content: center;
1326 flex-shrink: 0;
6fd5915Claude1327 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.22);
398a10cClaude1328 }
1329 .tags-row-main { flex: 1; min-width: 240px; }
1330 .tags-row-name {
1331 display: inline-flex;
1332 align-items: center;
1333 gap: 8px;
1334 flex-wrap: wrap;
1335 }
1336 .tags-row-version {
1337 display: inline-flex;
1338 align-items: center;
1339 padding: 3px 11px;
1340 border-radius: 9999px;
1341 font-family: var(--font-mono);
1342 font-size: 13px;
1343 font-weight: 700;
1344 color: #67e8f9;
6fd5915Claude1345 background: rgba(95,143,160,0.12);
1346 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.30);
398a10cClaude1347 letter-spacing: 0.01em;
1348 }
1349 .tags-row-meta {
1350 margin-top: 4px;
1351 display: flex;
1352 align-items: center;
1353 gap: 8px;
1354 flex-wrap: wrap;
1355 font-size: 12.5px;
1356 color: var(--text-muted);
1357 font-variant-numeric: tabular-nums;
1358 }
1359 .tags-row-meta .sep { opacity: 0.4; }
1360 .tags-row-sha {
1361 font-family: var(--font-mono);
1362 font-size: 11.5px;
1363 color: var(--text-strong);
1364 padding: 2px 8px;
1365 border-radius: 6px;
1366 background: rgba(255,255,255,0.04);
1367 border: 1px solid var(--border);
1368 text-decoration: none;
1369 letter-spacing: 0.04em;
1370 }
1371 .tags-row-sha:hover { border-color: var(--border-strong); color: var(--accent); text-decoration: none; }
1372 .tags-row-side {
1373 display: flex;
1374 align-items: center;
1375 gap: 6px;
1376 flex-shrink: 0;
1377 flex-wrap: wrap;
1378 }
1379 .tags-row-link {
1380 display: inline-flex;
1381 align-items: center;
1382 gap: 5px;
1383 padding: 6px 12px;
1384 border-radius: 9999px;
1385 font-size: 12px;
1386 font-weight: 600;
1387 text-decoration: none;
1388 color: var(--text-muted);
1389 background: rgba(255,255,255,0.025);
1390 border: 1px solid var(--border);
1391 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1392 }
1393 .tags-row-link:hover {
6fd5915Claude1394 border-color: rgba(91,110,232,0.45);
398a10cClaude1395 color: var(--text-strong);
6fd5915Claude1396 background: rgba(91,110,232,0.06);
398a10cClaude1397 text-decoration: none;
efb11c5Claude1398 }
1399
1400 /* ───────── commit detail ───────── */
1401 .commit-detail-card {
1402 position: relative;
1403 margin-bottom: var(--space-5);
1404 padding: var(--space-5) var(--space-5);
1405 background: var(--bg-elevated);
1406 border: 1px solid var(--border);
1407 border-radius: 14px;
1408 overflow: hidden;
1409 }
1410 .commit-detail-eyebrow {
1411 display: flex;
1412 align-items: center;
1413 gap: var(--space-2);
1414 font-size: 12px;
1415 font-family: var(--font-mono);
1416 text-transform: uppercase;
1417 letter-spacing: 0.1em;
1418 color: var(--text-muted);
1419 margin-bottom: var(--space-2);
1420 }
1421 .commit-detail-eyebrow strong { color: var(--accent); font-weight: 600; }
1422 .commit-detail-sha-pill {
1423 font-family: var(--font-mono);
1424 font-size: 11.5px;
1425 color: var(--text-strong);
1426 background: var(--bg-secondary);
1427 border: 1px solid var(--border);
1428 border-radius: 999px;
1429 padding: 2px 10px;
1430 letter-spacing: 0.04em;
1431 }
1432 .commit-detail-verify {
1433 font-size: 10px;
1434 padding: 2px 8px;
1435 border-radius: 999px;
1436 text-transform: uppercase;
1437 letter-spacing: 0.06em;
1438 font-weight: 700;
1439 color: #fff;
1440 }
1441 .commit-detail-verify-ok {
1442 background: linear-gradient(135deg, #2ea043, #34d399);
1443 }
1444 .commit-detail-verify-warn {
1445 background: linear-gradient(135deg, #d29922, #f59e0b);
1446 }
1447 .commit-detail-title {
1448 font-family: var(--font-display);
1449 font-weight: 700;
1450 letter-spacing: -0.018em;
1451 font-size: clamp(20px, 2.5vw, 26px);
1452 line-height: 1.25;
1453 margin: 0 0 var(--space-2);
1454 color: var(--text-strong);
1455 }
1456 .commit-detail-body {
1457 white-space: pre-wrap;
1458 color: var(--text-muted);
1459 font-size: 14px;
1460 line-height: 1.55;
1461 margin: 0 0 var(--space-3);
1462 font-family: var(--font-mono);
1463 background: var(--bg);
1464 border: 1px solid var(--border);
1465 border-radius: 8px;
1466 padding: var(--space-3) var(--space-4);
1467 max-height: 280px;
1468 overflow: auto;
1469 }
1470 .commit-detail-meta {
1471 display: flex;
1472 flex-wrap: wrap;
1473 gap: var(--space-3);
1474 font-size: 13px;
1475 color: var(--text-muted);
1476 margin-bottom: var(--space-3);
1477 }
1478 .commit-detail-author strong { color: var(--text-strong); font-weight: 600; }
1479 .commit-detail-parents { font-size: 13px; color: var(--text-muted); }
1480 .commit-detail-sha-link {
1481 font-family: var(--font-mono);
1482 font-size: 12.5px;
1483 color: var(--accent);
6fd5915Claude1484 background: rgba(91,110,232,0.08);
efb11c5Claude1485 border-radius: 6px;
1486 padding: 1px 6px;
1487 margin-left: 2px;
1488 }
6fd5915Claude1489 .commit-detail-sha-link:hover { background: rgba(91,110,232,0.16); text-decoration: none; }
efb11c5Claude1490 .commit-detail-stats {
1491 display: flex;
1492 flex-wrap: wrap;
1493 align-items: center;
1494 gap: var(--space-3);
1495 padding-top: var(--space-3);
1496 border-top: 1px solid var(--border);
1497 font-size: 13px;
1498 color: var(--text-muted);
1499 }
1500 .commit-detail-stat {
1501 display: inline-flex;
1502 align-items: center;
1503 gap: 4px;
1504 font-variant-numeric: tabular-nums;
1505 }
1506 .commit-detail-stat strong { color: var(--text-strong); font-weight: 600; }
1507 .commit-detail-stat-add strong { color: #34d399; }
1508 .commit-detail-stat-del strong { color: #f87171; }
1509 .commit-detail-stat-mark {
1510 font-family: var(--font-mono);
1511 font-weight: 700;
1512 font-size: 14px;
1513 }
1514 .commit-detail-stat-add .commit-detail-stat-mark { color: #34d399; }
1515 .commit-detail-stat-del .commit-detail-stat-mark { color: #f87171; }
1516 .commit-detail-sha-full {
1517 margin-left: auto;
1518 font-family: var(--font-mono);
1519 font-size: 11.5px;
1520 color: var(--text-faint);
1521 letter-spacing: 0.02em;
1522 overflow: hidden;
1523 text-overflow: ellipsis;
1524 white-space: nowrap;
1525 max-width: 100%;
1526 }
1527 .commit-detail-checks {
1528 margin-top: var(--space-3);
1529 padding-top: var(--space-3);
1530 border-top: 1px solid var(--border);
1531 }
1532 .commit-detail-checks-head {
1533 display: flex;
1534 align-items: center;
1535 gap: var(--space-2);
1536 font-size: 13px;
1537 color: var(--text-muted);
1538 margin-bottom: 8px;
1539 }
1540 .commit-detail-checks-head strong { color: var(--text-strong); font-weight: 600; }
1541 .commit-detail-check-state-success { color: #34d399; font-weight: 600; }
1542 .commit-detail-check-state-failure { color: #f87171; font-weight: 600; }
1543 .commit-detail-check-state-pending { color: #d29922; font-weight: 600; }
1544 .commit-detail-check-row { display: flex; flex-wrap: wrap; gap: 6px; }
1545 .commit-detail-check {
1546 font-size: 11px;
1547 padding: 2px 8px;
1548 border-radius: 999px;
1549 color: #fff;
1550 font-weight: 500;
1551 }
398a10cClaude1552 .commit-detail-check a { color: inherit; text-decoration: none; }
1553 .commit-detail-check-success { background: #2ea043; }
1554 .commit-detail-check-pending { background: #d29922; }
1555 .commit-detail-check-failure { background: #da3633; }
1556
1557 /* ───────── blame ───────── */
1558 .blame-head { margin-bottom: var(--space-5); }
1559 .blame-eyebrow {
1560 display: inline-flex;
1561 align-items: center;
1562 gap: 8px;
1563 text-transform: uppercase;
1564 font-family: var(--font-mono);
1565 font-size: 11px;
1566 letter-spacing: 0.16em;
1567 color: var(--text-muted);
1568 font-weight: 600;
1569 margin-bottom: 10px;
1570 }
1571 .blame-eyebrow-dot {
1572 width: 8px; height: 8px;
1573 border-radius: 9999px;
6fd5915Claude1574 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
1575 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
398a10cClaude1576 }
1577 .blame-title {
1578 font-family: var(--font-display);
1579 font-size: clamp(22px, 3vw, 30px);
1580 font-weight: 800;
1581 letter-spacing: -0.025em;
1582 line-height: 1.15;
1583 margin: 0 0 6px;
1584 color: var(--text-strong);
1585 }
1586 .blame-title code {
1587 font-family: var(--font-mono);
1588 font-size: 0.78em;
1589 color: var(--text-strong);
1590 font-weight: 700;
1591 }
1592 .blame-sub {
1593 margin: 0;
1594 font-size: 14px;
1595 color: var(--text-muted);
1596 line-height: 1.5;
1597 max-width: 700px;
1598 }
efb11c5Claude1599 .blame-toolbar {
398a10cClaude1600 display: flex;
1601 justify-content: space-between;
1602 align-items: center;
1603 gap: var(--space-3);
efb11c5Claude1604 margin-bottom: var(--space-3);
398a10cClaude1605 flex-wrap: wrap;
efb11c5Claude1606 font-size: 13px;
1607 }
398a10cClaude1608 .blame-toolbar-actions { display: flex; gap: 6px; }
1609 .blame-card {
1610 background: var(--bg-elevated);
1611 border: 1px solid var(--border);
1612 border-radius: 14px;
1613 overflow: hidden;
1614 position: relative;
1615 }
1616 .blame-card::before {
1617 content: '';
1618 position: absolute;
1619 top: 0; left: 0; right: 0;
1620 height: 2px;
6fd5915Claude1621 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
398a10cClaude1622 opacity: 0.55;
1623 pointer-events: none;
1624 }
efb11c5Claude1625 .blame-header {
1626 display: flex;
1627 align-items: center;
1628 justify-content: space-between;
1629 gap: var(--space-3);
1630 padding: 10px 14px;
1631 background: var(--bg-secondary);
1632 border-bottom: 1px solid var(--border);
1633 }
1634 .blame-header-meta {
1635 display: flex;
1636 align-items: center;
1637 gap: var(--space-2);
1638 min-width: 0;
1639 flex-wrap: wrap;
1640 }
1641 .blame-header-icon { color: var(--accent); font-size: 14px; }
1642 .blame-header-name {
1643 font-family: var(--font-mono);
1644 font-size: 13px;
1645 color: var(--text-strong);
1646 font-weight: 600;
1647 }
1648 .blame-header-tag {
1649 font-size: 10.5px;
1650 text-transform: uppercase;
1651 letter-spacing: 0.08em;
1652 font-family: var(--font-mono);
6fd5915Claude1653 background: rgba(91,110,232,0.12);
efb11c5Claude1654 color: var(--accent);
1655 border-radius: 999px;
1656 padding: 2px 8px;
1657 font-weight: 600;
1658 }
1659 .blame-header-stats {
1660 font-family: var(--font-mono);
1661 font-size: 11.5px;
1662 color: var(--text-muted);
1663 font-variant-numeric: tabular-nums;
1664 border-left: 1px solid var(--border);
1665 padding-left: var(--space-2);
1666 }
1667 .blame-header-actions { display: flex; gap: 6px; flex-shrink: 0; }
398a10cClaude1668 .blame-table {
1669 width: 100%;
1670 border-collapse: collapse;
1671 font-family: var(--font-mono);
1672 font-size: 12.5px;
1673 line-height: 1.6;
1674 }
1675 .blame-table tr { border-bottom: 1px solid transparent; }
1676 .blame-table tr.blame-row-first { border-top: 1px solid var(--border); }
1677 .blame-table tr:first-child.blame-row-first { border-top: 0; }
1678 .blame-table tr:hover .blame-line-content { background: rgba(255,255,255,0.025); }
1679 .blame-gutter {
1680 width: 220px;
1681 min-width: 220px;
1682 padding: 0 12px;
1683 vertical-align: top;
1684 background: rgba(255,255,255,0.012);
1685 border-right: 1px solid var(--border-subtle);
1686 font-variant-numeric: tabular-nums;
1687 color: var(--text-muted);
1688 font-size: 11px;
1689 white-space: nowrap;
1690 overflow: hidden;
1691 text-overflow: ellipsis;
1692 padding-top: 2px;
1693 padding-bottom: 2px;
1694 }
1695 .blame-gutter-inner {
1696 display: inline-flex;
1697 align-items: center;
1698 gap: 7px;
1699 max-width: 100%;
1700 overflow: hidden;
1701 }
1702 .blame-gutter-sha {
1703 font-family: var(--font-mono);
1704 font-size: 10.5px;
1705 font-weight: 600;
1706 color: #c4b5fd;
6fd5915Claude1707 background: rgba(91,110,232,0.10);
398a10cClaude1708 padding: 1px 7px;
1709 border-radius: 9999px;
6fd5915Claude1710 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.22);
398a10cClaude1711 text-decoration: none;
1712 letter-spacing: 0.04em;
1713 flex-shrink: 0;
1714 transition: background 120ms ease, box-shadow 120ms ease, color 120ms ease;
1715 }
1716 .blame-gutter-sha:hover {
6fd5915Claude1717 background: rgba(91,110,232,0.22);
398a10cClaude1718 color: #ddd6fe;
6fd5915Claude1719 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.50);
398a10cClaude1720 text-decoration: none;
1721 }
1722 .blame-gutter-author {
1723 color: var(--text);
1724 overflow: hidden;
1725 text-overflow: ellipsis;
1726 white-space: nowrap;
1727 font-size: 11px;
1728 font-family: var(--font-sans, inherit);
1729 }
1730 .blame-line-num {
1731 width: 1%;
1732 min-width: 50px;
1733 padding: 0 12px;
1734 text-align: right;
1735 color: var(--text-faint);
1736 user-select: none;
1737 border-right: 1px solid var(--border-subtle);
1738 font-variant-numeric: tabular-nums;
1739 }
1740 .blame-line-content {
1741 padding: 0 14px;
1742 white-space: pre;
1743 color: var(--text);
1744 transition: background 120ms ease;
1745 }
efb11c5Claude1746
1747 /* ───────── search ───────── */
1748 .search-hero {
1749 position: relative;
1750 margin-bottom: var(--space-4);
1751 padding: var(--space-5) var(--space-6);
1752 background: var(--bg-elevated);
1753 border: 1px solid var(--border);
1754 border-radius: 16px;
1755 overflow: hidden;
1756 }
1757 .search-eyebrow {
1758 font-size: 12px;
1759 font-family: var(--font-mono);
1760 color: var(--text-muted);
1761 letter-spacing: 0.1em;
1762 text-transform: uppercase;
1763 margin-bottom: var(--space-2);
1764 }
1765 .search-eyebrow strong { color: var(--accent); font-weight: 600; }
1766 .search-title {
1767 font-family: var(--font-display);
1768 font-weight: 800;
1769 letter-spacing: -0.025em;
1770 font-size: clamp(22px, 3vw, 30px);
1771 line-height: 1.15;
1772 margin: 0 0 var(--space-3);
1773 color: var(--text-strong);
1774 }
1775 .search-form {
1776 display: flex;
1777 gap: var(--space-2);
1778 align-items: stretch;
1779 }
1780 .search-input-wrap {
1781 position: relative;
1782 flex: 1;
1783 display: flex;
1784 align-items: center;
1785 }
1786 .search-input-icon {
1787 position: absolute;
1788 left: 12px;
1789 color: var(--text-muted);
1790 font-size: 15px;
1791 pointer-events: none;
1792 }
1793 .search-input {
1794 appearance: none;
1795 width: 100%;
1796 padding: 10px 12px 10px 34px;
1797 background: var(--bg);
1798 border: 1px solid var(--border);
1799 border-radius: 10px;
1800 color: var(--text-strong);
1801 font-size: 14px;
1802 font-family: inherit;
1803 transition: border-color var(--t-fast, 0.15s) ease, box-shadow var(--t-fast, 0.15s) ease;
1804 }
1805 .search-input:focus {
1806 outline: none;
1807 border-color: var(--accent);
6fd5915Claude1808 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
efb11c5Claude1809 }
1810 .search-submit { min-width: 96px; }
1811 .search-results-head {
1812 display: flex;
1813 align-items: baseline;
1814 gap: var(--space-2);
1815 margin-bottom: var(--space-3);
1816 font-size: 13px;
1817 color: var(--text-muted);
1818 }
1819 .search-results-count strong {
1820 color: var(--text-strong);
1821 font-weight: 600;
1822 font-variant-numeric: tabular-nums;
1823 }
1824 .search-results-q { color: var(--text-strong); font-weight: 600; }
1825 .search-results-head code {
1826 font-family: var(--font-mono);
1827 font-size: 12px;
1828 background: var(--bg-secondary);
1829 border: 1px solid var(--border);
1830 border-radius: 5px;
1831 padding: 1px 6px;
1832 color: var(--text);
1833 }
1834 .search-empty {
1835 padding: var(--space-5) var(--space-6);
1836 background: var(--bg-elevated);
1837 border: 1px dashed var(--border);
1838 border-radius: 12px;
1839 color: var(--text-muted);
1840 font-size: 14px;
1841 }
1842 .search-empty strong { color: var(--text-strong); }
1843 .search-results {
1844 display: flex;
1845 flex-direction: column;
1846 gap: var(--space-3);
1847 }
1848 .search-file-head {
1849 display: flex;
1850 align-items: center;
1851 justify-content: space-between;
1852 gap: var(--space-2);
1853 }
1854 .search-file-link {
1855 font-family: var(--font-mono);
1856 font-size: 13px;
1857 color: var(--text-strong);
1858 font-weight: 600;
1859 }
1860 .search-file-link:hover { color: var(--accent); text-decoration: none; }
1861 .search-file-count {
1862 font-family: var(--font-mono);
1863 font-size: 11.5px;
1864 color: var(--text-muted);
1865 font-variant-numeric: tabular-nums;
1866 }
1867`;
1868
fc1817aClaude1869// Home page
06d5ffeClaude1870web.get("/", async (c) => {
1871 const user = c.get("user");
1872
1873 if (user) {
0316dbbClaude1874 return c.redirect("/dashboard");
06d5ffeClaude1875 }
1876
8e9f1d9Claude1877 let stats: { publicRepos?: number; users?: number } | undefined;
52ad8b1Claude1878 let publicStats: PublicStats | null = null;
8e9f1d9Claude1879 try {
1880 const [repoRow] = await db
1881 .select({ n: sql<number>`count(*)::int` })
1882 .from(repositories)
1883 .where(eq(repositories.isPrivate, false));
1884 const [userRow] = await db
1885 .select({ n: sql<number>`count(*)::int` })
1886 .from(users);
1887 stats = {
1888 publicRepos: Number(repoRow?.n ?? 0),
1889 users: Number(userRow?.n ?? 0),
1890 };
1891 } catch {
1892 stats = undefined;
1893 }
1894
52ad8b1Claude1895 // Block L4 — public stats counters (5-min in-memory cache; never throws).
1896 try {
1897 publicStats = await computePublicStats();
1898 } catch {
1899 publicStats = null;
1900 }
1901
534f04aClaude1902 // Block M1 — initial SSR snapshot for the live-now demo feed.
1903 // The helpers in lib/demo-activity.ts never throw, but we still wrap
1904 // in try/catch so a freak module-level explosion can't take down /.
1905 let liveFeed: LandingLiveFeed | null = null;
1906 try {
1907 const [queued, merges, reviewList, reviewCount, feed] = await Promise.all([
1908 listQueuedAiBuildIssues(3),
1909 listRecentAutoMerges(3, 24),
1910 listRecentAiReviews(3, 24),
1911 countAiReviewsSince(24),
1912 listDemoActivityFeed(10),
1913 ]);
1914 liveFeed = {
1915 queued: queued.map((i) => ({
1916 repo: i.repo,
1917 number: i.number,
1918 title: i.title,
1919 createdAt: i.createdAt,
1920 })),
1921 merges: merges.map((m) => ({
1922 repo: m.repo,
1923 number: m.number,
1924 title: m.title,
1925 mergedAt: m.mergedAt,
1926 })),
1927 reviews: reviewList.map((r) => ({
1928 repo: r.repo,
1929 prNumber: r.prNumber,
1930 commentSnippet: r.commentSnippet,
1931 createdAt: r.createdAt,
1932 })),
1933 reviewCount,
1934 feed: feed.map((e) => ({
1935 kind: e.kind,
1936 repo: e.repo,
1937 ref: e.ref,
1938 at: e.at,
1939 })),
1940 };
1941 } catch {
1942 liveFeed = null;
1943 }
1944
29924bcClaude1945 void LandingPage;
f8e5feaccanty labs1946 void Landing2030Page;
1947 return c.html(
1948 "<!DOCTYPE html>" +
1949 String(
1950 <LandingProPage
1951 stats={stats}
1952 publicStats={publicStats}
1953 liveFeed={liveFeed}
1954 />
1955 )
1956 );
fc1817aClaude1957});
1958
06d5ffeClaude1959// New repository form
1960web.get("/new", requireAuth, (c) => {
1961 const user = c.get("user")!;
1962 const error = c.req.query("error");
1963
1964 return c.html(
1965 <Layout title="New repository" user={user}>
efb11c5Claude1966 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
1967 <div class="new-repo-hero">
1968 <div class="new-repo-hero-orb-wrap" aria-hidden="true">
1969 <div class="new-repo-hero-orb" />
1970 </div>
1971 <div class="new-repo-hero-inner">
1972 <div class="new-repo-eyebrow">
1973 <strong>Create</strong> · {user.username}
1974 </div>
1975 <h1 class="new-repo-title">
1976 Spin up a <span class="gradient-text">repository</span>.
1977 </h1>
1978 <p class="new-repo-sub">
1979 Push your first commit, and Gluecron wires up gate checks, AI review,
1980 and auto-merge from the moment your branch lands.
1981 </p>
1982 </div>
1983 </div>
06d5ffeClaude1984 <div class="new-repo-form">
efb11c5Claude1985 {error && (
1986 <div class="new-repo-error" role="alert">
1987 {decodeURIComponent(error)}
1988 </div>
1989 )}
1990 <form method="post" action="/new" class="new-repo-form-grid">
1991 <div class="new-repo-row">
1992 <label class="new-repo-label">Owner</label>
1993 <input
1994 type="text"
1995 value={user.username}
1996 disabled
1997 aria-label="Owner"
1998 class="new-repo-input new-repo-input-disabled"
1999 />
06d5ffeClaude2000 </div>
efb11c5Claude2001 <div class="new-repo-row">
2002 <label class="new-repo-label" for="name">
2003 Repository name
2004 </label>
06d5ffeClaude2005 <input
2006 type="text"
2007 id="name"
2008 name="name"
2009 required
2010 pattern="^[a-zA-Z0-9._-]+$"
2011 placeholder="my-project"
2012 autocomplete="off"
efb11c5Claude2013 class="new-repo-input"
06d5ffeClaude2014 />
efb11c5Claude2015 <p class="new-repo-hint">
2016 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2017 <code>{user.username}/&lt;name&gt;</code>.
2018 </p>
06d5ffeClaude2019 </div>
efb11c5Claude2020 <div class="new-repo-row">
2021 <label class="new-repo-label" for="description">
2022 Description{" "}
2023 <span class="new-repo-label-optional">(optional)</span>
2024 </label>
06d5ffeClaude2025 <input
2026 type="text"
2027 id="description"
2028 name="description"
2029 placeholder="A short description of your repository"
efb11c5Claude2030 class="new-repo-input"
06d5ffeClaude2031 />
2032 </div>
efb11c5Claude2033 <div class="new-repo-row">
2034 <span class="new-repo-label">Visibility</span>
2035 <div class="new-repo-visibility">
2036 <label class="new-repo-vis-card">
2037 <input
2038 type="radio"
2039 name="visibility"
2040 value="public"
2041 checked
2042 class="new-repo-vis-radio"
2043 />
2044 <span class="new-repo-vis-body">
2045 <span class="new-repo-vis-label">Public</span>
2046 <span class="new-repo-vis-desc">
2047 Anyone can see this repository. You choose who can commit.
2048 </span>
2049 </span>
2050 </label>
2051 <label class="new-repo-vis-card">
2052 <input
2053 type="radio"
2054 name="visibility"
2055 value="private"
2056 class="new-repo-vis-radio"
2057 />
2058 <span class="new-repo-vis-body">
2059 <span class="new-repo-vis-label">Private</span>
2060 <span class="new-repo-vis-desc">
2061 Only you (and collaborators you invite) can see this
2062 repository.
2063 </span>
2064 </span>
2065 </label>
2066 </div>
2067 </div>
398a10cClaude2068 <div class="new-repo-row">
2069 <span class="new-repo-label">
2070 Starter content{" "}
2071 <span class="new-repo-label-optional">(cosmetic — your first push wins)</span>
2072 </span>
2073 <div class="new-repo-templates" role="radiogroup" aria-label="Starter content">
2074 <label class="new-repo-template-chip">
2075 <input type="radio" name="starter" value="empty" checked />
2076 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2077 Empty
2078 </label>
2079 <label class="new-repo-template-chip">
2080 <input type="radio" name="starter" value="readme" />
2081 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2082 README
2083 </label>
2084 <label class="new-repo-template-chip">
2085 <input type="radio" name="starter" value="readme-mit" />
2086 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2087 README + MIT
2088 </label>
2089 <label class="new-repo-template-chip">
2090 <input type="radio" name="starter" value="node" />
2091 <span class="new-repo-template-chip-dot" aria-hidden="true" />
2092 Node + .gitignore
2093 </label>
2094 </div>
2095 <p class="new-repo-hint">
2096 Just a UI hint — push your own commits to fill the repo.
2097 </p>
2098 </div>
44f1a02Claude2099 <div class="new-repo-row">
2100 <label class="new-repo-label" for="data_region">
2101 Data region
2102 </label>
2103 <select
2104 id="data_region"
2105 name="data_region"
2106 class="new-repo-input"
2107 style="cursor: pointer;"
2108 >
2109 <option value="us" selected>US (default)</option>
2110 <option value="eu">EU (Frankfurt)</option>
2111 </select>
2112 <p class="new-repo-hint">
2113 EU data residency requires a{" "}
2114 <a href="/pricing" style="color: var(--accent); text-decoration: none;">
2115 Pro plan or higher
2116 </a>
2117 . Repositories cannot be moved between regions after creation.
2118 </p>
2119 </div>
efb11c5Claude2120 <div class="new-repo-callout">
2121 <div class="new-repo-callout-eyebrow">AI-native by default</div>
2122 <p class="new-repo-callout-body">
2123 Every push is gate-checked and reviewed by Claude automatically.
2124 Label an issue <code>ai-build</code> and Gluecron will open the PR
2125 for you.
2126 </p>
2127 </div>
2128 <div class="new-repo-actions">
398a10cClaude2129 <button type="submit" class="btn new-repo-submit">
efb11c5Claude2130 Create repository
2131 </button>
2132 <a href="/dashboard" class="btn new-repo-cancel">
2133 Cancel
2134 </a>
06d5ffeClaude2135 </div>
2136 </form>
2137 </div>
2138 </Layout>
2139 );
2140});
2141
2142web.post("/new", requireAuth, async (c) => {
2143 const user = c.get("user")!;
2144 const body = await c.req.parseBody();
2145 const name = String(body.name || "").trim();
2146 const description = String(body.description || "").trim();
2147 const isPrivate = body.visibility === "private";
44f1a02Claude2148 const dataRegion = body.data_region === "eu" ? "eu" : "us";
06d5ffeClaude2149
2150 if (!name) {
2151 return c.redirect("/new?error=Repository+name+is+required");
2152 }
2153
c63b860Claude2154 // P4 — plan-quota gate. Fail-open inside the helper so a billing
2155 // outage never blocks repo creation.
2156 const { checkRepoCreateAllowed } = await import("../lib/repo-create-gate");
2157 const gate = await checkRepoCreateAllowed(user.id);
2158 if (!gate.ok) {
2159 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
2160 }
2161
06d5ffeClaude2162 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2163 return c.redirect("/new?error=Invalid+repository+name");
2164 }
2165
2166 if (await repoExists(user.username, name)) {
2167 return c.redirect("/new?error=Repository+already+exists");
2168 }
2169
2170 const diskPath = await initBareRepo(user.username, name);
2171
3ef4c9dClaude2172 const [newRepo] = await db
2173 .insert(repositories)
2174 .values({
2175 name,
2176 ownerId: user.id,
2177 description: description || null,
2178 isPrivate,
2179 diskPath,
44f1a02Claude2180 dataRegion,
3ef4c9dClaude2181 })
2182 .returning();
2183
2184 if (newRepo) {
2185 const { bootstrapRepository } = await import("../lib/repo-bootstrap");
2186 await bootstrapRepository({
2187 repositoryId: newRepo.id,
2188 ownerUserId: user.id,
2189 defaultBranch: "main",
2190 });
2191 }
06d5ffeClaude2192
2193 return c.redirect(`/${user.username}/${name}`);
2194});
2195
11c3ab6ccanty labs2196// Daily brief — GET /brief
2197web.get("/brief", (c) => {
2198 const user = c.get("user");
2199 return c.html(<DailyBrief user={user} />);
2200});
2201
2202// Trust report — GET /trust (public)
2203web.get("/trust", (c) => {
2204 return c.html(<TrustReport />);
2205});
2206
2207// Production layers — GET /layers
2208web.get("/layers", (c) => {
2209 const user = c.get("user");
2210 return c.html(<ProductionLayers user={user} />);
2211});
2212
2213// Distribution — GET /distribute
2214web.get("/distribute", (c) => {
2215 const user = c.get("user");
2216 return c.html(<DistributionView user={user} />);
2217});
2218
06d5ffeClaude2219// User profile
fc1817aClaude2220web.get("/:owner", async (c) => {
06d5ffeClaude2221 const { owner: ownerName } = c.req.param();
2222 const user = c.get("user");
2223
2224 // Avoid clashing with fixed routes
2225 if (
2226 ["login", "register", "logout", "new", "settings", "api"].includes(
2227 ownerName
2228 )
2229 ) {
2230 return c.notFound();
2231 }
2232
2233 let ownerUser;
2234 try {
2235 const [found] = await db
2236 .select()
2237 .from(users)
2238 .where(eq(users.username, ownerName))
2239 .limit(1);
2240 ownerUser = found;
2241 } catch {
2242 // DB not available — check if repos exist on disk
2243 ownerUser = null;
2244 }
2245
2246 // Even without DB, show repos if they exist on disk
2247 let repos: any[] = [];
2248 if (ownerUser) {
2249 const allRepos = await db
2250 .select()
2251 .from(repositories)
2252 .where(eq(repositories.ownerId, ownerUser.id))
2253 .orderBy(desc(repositories.updatedAt));
2254
2255 // Show public repos to everyone, private only to owner
2256 repos =
2257 user?.id === ownerUser.id
2258 ? allRepos
2259 : allRepos.filter((r) => !r.isPrivate);
2260 }
2261
7aa8b99Claude2262 // Block J4 — follow counts + viewer's follow state
2263 let followState = {
2264 followers: 0,
2265 following: 0,
2266 viewerFollows: false,
2267 };
2268 if (ownerUser) {
2269 try {
2270 const { followCounts, isFollowing } = await import("../lib/follows");
2271 const counts = await followCounts(ownerUser.id);
2272 followState.followers = counts.followers;
2273 followState.following = counts.following;
2274 if (user && user.id !== ownerUser.id) {
2275 followState.viewerFollows = await isFollowing(user.id, ownerUser.id);
2276 }
2277 } catch {
2278 // DB hiccup — fall back to zeros.
2279 }
2280 }
2281 const canFollow = !!user && !!ownerUser && user.id !== ownerUser.id;
2282
d412586Claude2283 // Block J5 — profile README. Render owner/owner repo's README on the
2284 // profile page (GitHub convention). Tries "<user>/<user>" first, falling
2285 // back to "<user>/.github" for org-style profile repos.
2286 let profileReadmeHtml: string | null = null;
2287 try {
2288 const candidates = [ownerName, ".github"];
2289 for (const rname of candidates) {
2290 if (await repoExists(ownerName, rname)) {
2291 const ref = (await getDefaultBranch(ownerName, rname)) || "main";
2292 const md = await getReadme(ownerName, rname, ref);
2293 if (md) {
2294 profileReadmeHtml = renderMarkdown(md);
2295 break;
2296 }
2297 }
2298 }
2299 } catch {
2300 profileReadmeHtml = null;
2301 }
2302
efb11c5Claude2303 const displayName = ownerUser?.displayName || ownerName;
2304 const memberSince = ownerUser?.createdAt
2305 ? new Date(ownerUser.createdAt as unknown as string | number | Date)
2306 : null;
2307
fc1817aClaude2308 return c.html(
06d5ffeClaude2309 <Layout title={ownerName} user={user}>
efb11c5Claude2310 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
2311 <div class="profile-hero">
2312 <div class="profile-hero-orb-wrap" aria-hidden="true">
2313 <div class="profile-hero-orb" />
06d5ffeClaude2314 </div>
efb11c5Claude2315 <div class="profile-hero-inner">
2316 <div class="profile-hero-avatar" aria-hidden="true">
2317 {displayName[0].toUpperCase()}
2318 </div>
2319 <div class="profile-hero-text">
2320 <div class="profile-eyebrow">
2321 <strong>Developer</strong>
2322 {memberSince && !Number.isNaN(memberSince.getTime()) && (
2323 <>
2324 {" "}· Joined{" "}
2325 {memberSince.toLocaleDateString("en-US", {
2326 month: "short",
2327 year: "numeric",
2328 })}
2329 </>
2330 )}
2331 </div>
2332 <h1 class="profile-name">
2333 <span class="gradient-text">{displayName}</span>
2334 </h1>
2335 <div class="profile-handle">@{ownerName}</div>
2336 {ownerUser?.bio && <p class="profile-bio">{ownerUser.bio}</p>}
2337 <div class="profile-meta">
2338 <a href={`/${ownerName}/followers`} class="profile-meta-link">
2339 <strong>{followState.followers}</strong> follower
2340 {followState.followers === 1 ? "" : "s"}
2341 </a>
2342 <a href={`/${ownerName}/following`} class="profile-meta-link">
2343 <strong>{followState.following}</strong> following
2344 </a>
2345 <a href={`/${ownerName}`} class="profile-meta-link">
2346 <strong>{repos.length}</strong> repo
2347 {repos.length === 1 ? "" : "s"}
2348 </a>
2349 {canFollow && (
2350 <form
2351 method="post"
2352 action={`/${ownerName}/${
2353 followState.viewerFollows ? "unfollow" : "follow"
2354 }`}
2355 class="profile-follow-form"
7aa8b99Claude2356 >
efb11c5Claude2357 <button
2358 type="submit"
2359 class={`btn ${
2360 followState.viewerFollows ? "" : "btn-primary"
2361 } btn-sm`}
2362 >
2363 {followState.viewerFollows ? "Unfollow" : "Follow"}
2364 </button>
2365 </form>
2366 )}
2367 </div>
7aa8b99Claude2368 </div>
06d5ffeClaude2369 </div>
2370 </div>
d412586Claude2371 {profileReadmeHtml && (
efb11c5Claude2372 <div class="profile-readme">
2373 <div class="profile-readme-head">
2374 <span class="profile-readme-icon">{"☰"}</span>
2375 <span>{ownerName}/{ownerName} README.md</span>
2376 </div>
2377 <div
2378 class="markdown-body profile-readme-body"
2379 dangerouslySetInnerHTML={{ __html: profileReadmeHtml }}
2380 />
2381 </div>
d412586Claude2382 )}
efb11c5Claude2383 <div class="profile-section-head">
2384 <h2 class="profile-section-title">Repositories</h2>
2385 <span class="profile-section-count">{repos.length}</span>
2386 </div>
06d5ffeClaude2387 {repos.length === 0 ? (
efb11c5Claude2388 <div class="profile-empty">
2389 <p class="profile-empty-text">
2390 No repositories yet
2391 {user?.id === ownerUser?.id ? "." : ` — ${ownerName} is just getting started.`}
2392 </p>
2393 {user?.id === ownerUser?.id && (
2394 <a href="/new" class="btn btn-primary btn-sm">
2395 + Create your first
2396 </a>
2397 )}
2398 </div>
06d5ffeClaude2399 ) : (
2400 <div class="card-grid">
2401 {repos.map((repo) => (
2402 <RepoCard repo={repo} ownerName={ownerName} />
2403 ))}
2404 </div>
2405 )}
fc1817aClaude2406 </Layout>
2407 );
2408});
2409
06d5ffeClaude2410// Star/unstar a repo
2411web.post("/:owner/:repo/star", requireAuth, async (c) => {
2412 const { owner: ownerName, repo: repoName } = c.req.param();
2413 const user = c.get("user")!;
2414
2415 try {
2416 const [ownerUser] = await db
2417 .select()
2418 .from(users)
2419 .where(eq(users.username, ownerName))
2420 .limit(1);
2421 if (!ownerUser) return c.redirect(`/${ownerName}/${repoName}`);
2422
2423 const [repo] = await db
2424 .select()
2425 .from(repositories)
2426 .where(
2427 and(
2428 eq(repositories.ownerId, ownerUser.id),
2429 eq(repositories.name, repoName)
2430 )
2431 )
2432 .limit(1);
2433 if (!repo) return c.redirect(`/${ownerName}/${repoName}`);
2434
2435 // Toggle star
2436 const [existing] = await db
2437 .select()
2438 .from(stars)
2439 .where(
2440 and(eq(stars.userId, user.id), eq(stars.repositoryId, repo.id))
2441 )
2442 .limit(1);
2443
2444 if (existing) {
2445 await db.delete(stars).where(eq(stars.id, existing.id));
2446 await db
2447 .update(repositories)
2448 .set({ starCount: Math.max(0, repo.starCount - 1) })
2449 .where(eq(repositories.id, repo.id));
2450 } else {
2451 await db.insert(stars).values({
2452 userId: user.id,
2453 repositoryId: repo.id,
2454 });
2455 await db
2456 .update(repositories)
2457 .set({ starCount: repo.starCount + 1 })
2458 .where(eq(repositories.id, repo.id));
2459 }
2460 } catch {
2461 // DB error — ignore
2462 }
2463
2464 return c.redirect(`/${ownerName}/${repoName}`);
2465});
2466
641aa42Claude2467// ---------------------------------------------------------------------------
2468// Onboarding card CSS (injected inline on repo home, scoped to .ob-*)
2469// ---------------------------------------------------------------------------
2470const obCss = `
2471 .ob-card {
2472 position: relative;
2473 margin: 14px 0 18px;
6fd5915Claude2474 background: linear-gradient(135deg, rgba(91,110,232,0.08), rgba(95,143,160,0.05));
2475 border: 1px solid rgba(91,110,232,0.30);
641aa42Claude2476 border-radius: 14px;
2477 overflow: hidden;
2478 }
2479 .ob-card::before {
2480 content: '';
2481 position: absolute;
2482 top: 0; left: 0; right: 0;
2483 height: 2px;
6fd5915Claude2484 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
641aa42Claude2485 pointer-events: none;
2486 }
2487 .ob-header {
2488 padding: 16px 20px 10px;
2489 border-bottom: 1px solid var(--border);
2490 }
2491 .ob-header h3 {
2492 font-size: 16px;
2493 font-weight: 700;
2494 margin: 0 0 4px;
2495 color: var(--text-strong);
2496 }
2497 .ob-header p {
2498 font-size: 13px;
2499 color: var(--text-muted);
2500 margin: 0;
2501 }
2502 .ob-sections {
2503 display: grid;
2504 grid-template-columns: repeat(3, 1fr);
2505 gap: 0;
2506 }
2507 @media (max-width: 760px) {
2508 .ob-sections { grid-template-columns: 1fr; }
2509 }
2510 .ob-section {
2511 padding: 14px 20px;
2512 border-right: 1px solid var(--border);
2513 }
2514 .ob-section:last-child { border-right: none; }
2515 .ob-section h4 {
2516 font-size: 12px;
2517 font-weight: 700;
2518 text-transform: uppercase;
2519 letter-spacing: 0.08em;
2520 color: var(--accent);
2521 margin: 0 0 8px;
2522 }
2523 .ob-preview {
2524 font-family: var(--font-mono);
2525 font-size: 11.5px;
2526 background: var(--bg);
2527 border: 1px solid var(--border);
2528 border-radius: 6px;
2529 padding: 8px 10px;
2530 color: var(--text-muted);
2531 line-height: 1.55;
2532 margin-bottom: 8px;
2533 white-space: pre-wrap;
2534 overflow: hidden;
2535 max-height: 80px;
2536 position: relative;
2537 }
2538 .ob-preview::after {
2539 content: '';
2540 position: absolute;
2541 bottom: 0; left: 0; right: 0;
2542 height: 24px;
2543 background: linear-gradient(transparent, var(--bg));
2544 pointer-events: none;
2545 }
2546 .ob-labels {
2547 display: flex;
2548 flex-wrap: wrap;
2549 gap: 5px;
2550 margin-bottom: 8px;
2551 }
2552 .ob-label-chip {
2553 display: inline-flex;
2554 align-items: center;
2555 padding: 2px 8px;
2556 border-radius: 9999px;
2557 font-size: 11.5px;
2558 font-weight: 600;
2559 line-height: 1.5;
2560 border: 1px solid transparent;
2561 }
2562 .ob-section ul {
2563 margin: 0;
2564 padding: 0 0 0 16px;
2565 font-size: 13px;
2566 color: var(--text-muted);
2567 line-height: 1.7;
2568 }
2569 .ob-section ul li { margin-bottom: 2px; }
2570 .ob-footer {
2571 display: flex;
2572 align-items: center;
2573 justify-content: flex-end;
2574 padding: 10px 20px;
2575 border-top: 1px solid var(--border);
2576 gap: 10px;
2577 }
2578 .ob-dismiss {
2579 appearance: none;
2580 background: transparent;
2581 border: 1px solid var(--border);
2582 color: var(--text-muted);
2583 border-radius: 6px;
2584 padding: 5px 12px;
2585 font-size: 12.5px;
2586 font-family: inherit;
2587 cursor: pointer;
2588 transition: background var(--t-fast), color var(--t-fast);
2589 }
2590 .ob-dismiss:hover { background: var(--bg-hover); color: var(--text); }
2591 .btn-sm {
2592 appearance: none;
2593 background: var(--bg-elevated);
2594 border: 1px solid var(--border);
2595 color: var(--text-strong);
2596 border-radius: 6px;
2597 padding: 5px 12px;
2598 font-size: 12.5px;
2599 font-weight: 600;
2600 font-family: inherit;
2601 cursor: pointer;
2602 text-decoration: none;
2603 display: inline-flex;
2604 align-items: center;
2605 transition: background var(--t-fast);
2606 }
2607 .btn-sm:hover { background: var(--bg-hover); }
2608 .btn-sm.btn-primary {
2609 background: var(--accent);
2610 color: #fff;
2611 border-color: var(--accent);
2612 }
2613 .btn-sm.btn-primary:hover { background: var(--accent-hover); }
2614`;
2615
2616// Onboarding card component — shown to repo owner until dismissed
2617function RepoOnboardingCard({
2618 owner,
2619 repo,
2620 data,
2621}: {
2622 owner: string;
2623 repo: string;
2624 data: typeof repoOnboardingData.$inferSelect;
2625}) {
2626 const labels = (data.suggestedLabels ?? []) as Array<{
2627 name: string;
2628 color: string;
2629 description: string;
2630 }>;
2631 const suggestions = (data.firstCommitSuggestions ?? []) as string[];
2632 const readmePreview = (data.suggestedReadme ?? "").slice(0, 200);
2633
2634 return (
2635 <>
2636 <style dangerouslySetInnerHTML={{ __html: obCss }} />
2637 <div class="ob-card" id="repo-onboarding">
2638 <div class="ob-header">
2639 <h3>Get started with {owner}/{repo}</h3>
2640 <p>
2641 Detected: {data.detectedLanguage ?? "Unknown"}
2642 {data.detectedFramework ? ` / ${data.detectedFramework}` : ""}.
2643 Here&rsquo;s what we suggest to hit the ground running.
2644 </p>
2645 </div>
2646 <div class="ob-sections">
2647 <div class="ob-section">
2648 <h4>Suggested README</h4>
2649 <div class="ob-preview">{readmePreview}</div>
2650 <button
2651 class="btn-sm"
2652 type="button"
2653 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(_){};})()`}
2654 aria-label="Copy suggested README to clipboard"
2655 >
2656 Copy to clipboard
2657 </button>
2658 </div>
2659 <div class="ob-section">
2660 <h4>Suggested labels ({labels.length})</h4>
2661 <div class="ob-labels">
2662 {labels.slice(0, 6).map((l) => (
2663 <span
2664 class="ob-label-chip"
2665 style={`background:#${l.color}22;color:#${l.color};border-color:#${l.color}55`}
2666 title={l.description}
2667 >
2668 {l.name}
2669 </span>
2670 ))}
2671 </div>
2672 <form method="post" action={`/${owner}/${repo}/setup/labels`}>
2673 <button class="btn-sm btn-primary" type="submit">
2674 Create all labels
2675 </button>
2676 </form>
2677 </div>
2678 <div class="ob-section">
2679 <h4>First steps</h4>
2680 <ul>
2681 {suggestions.map((s) => (
2682 <li>{s}</li>
2683 ))}
2684 </ul>
2685 </div>
2686 </div>
2687 <div class="ob-footer">
2688 <form method="post" action={`/${owner}/${repo}/setup/dismiss`}>
2689 <button class="ob-dismiss" type="submit">
2690 Dismiss
2691 </button>
2692 </form>
2693 </div>
2694 <script
2695 dangerouslySetInnerHTML={{
2696 __html: `
2697 (function(){
2698 var card = document.getElementById('repo-onboarding');
2699 if (!card) return;
2700 document.addEventListener('keydown', function(e){
2701 if (e.key === 'Escape' && card && card.style.display !== 'none') {
2702 card.style.display = 'none';
2703 }
2704 });
2705 })();
2706 `,
2707 }}
2708 />
2709 </div>
2710 </>
2711 );
2712}
2713
2714// ---------------------------------------------------------------------------
2715// Setup routes — create suggested labels + dismiss onboarding
2716// ---------------------------------------------------------------------------
2717
2718// POST /:owner/:repo/setup/labels — creates all suggested labels for the repo.
2719// requireAuth + write access. Idempotent (label UPSERT by name).
2720web.post("/:owner/:repo/setup/labels", softAuth, requireAuth, async (c) => {
2721 const { owner, repo } = c.req.param();
2722 const user = c.get("user")!;
2723
2724 // Resolve repo + verify write access
2725 let repoRow: { id: string; ownerId: string } | null = null;
2726 try {
2727 const [ownerUser] = await db
2728 .select({ id: users.id })
2729 .from(users)
2730 .where(eq(users.username, owner))
2731 .limit(1);
2732 if (!ownerUser) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2733 const [r] = await db
2734 .select({ id: repositories.id, ownerId: repositories.ownerId })
2735 .from(repositories)
2736 .where(
2737 and(
2738 eq(repositories.ownerId, ownerUser.id),
2739 eq(repositories.name, repo)
2740 )
2741 )
2742 .limit(1);
2743 repoRow = r ?? null;
2744 } catch {
2745 return c.redirect(`/${owner}/${repo}?error=Database+error`);
2746 }
2747 if (!repoRow) return c.redirect(`/${owner}/${repo}?error=Not+found`);
2748 if (repoRow.ownerId !== user.id) {
2749 return c.redirect(`/${owner}/${repo}?error=Forbidden`);
2750 }
2751
2752 // Fetch the onboarding data
2753 let obRow: (typeof repoOnboardingData.$inferSelect) | null = null;
2754 try {
2755 const [r] = await db
2756 .select()
2757 .from(repoOnboardingData)
2758 .where(eq(repoOnboardingData.repositoryId, repoRow.id))
2759 .limit(1);
2760 obRow = r ?? null;
2761 } catch {
2762 return c.redirect(`/${owner}/${repo}?error=Onboarding+data+not+found`);
2763 }
2764 if (!obRow) {
2765 return c.redirect(`/${owner}/${repo}?toast=info:No+onboarding+data+found`);
2766 }
2767
2768 const suggestedLabels = (obRow.suggestedLabels ?? []) as Array<{
2769 name: string;
2770 color: string;
2771 description: string;
2772 }>;
2773
2774 // Create labels — import the labels table which was already imported
2775 let created = 0;
2776 for (const l of suggestedLabels) {
2777 try {
2778 await db
2779 .insert(labels)
2780 .values({
2781 repositoryId: repoRow.id,
2782 name: l.name,
2783 color: l.color,
2784 description: l.description ?? "",
2785 })
2786 .onConflictDoNothing();
2787 created++;
2788 } catch {
2789 /* skip duplicates */
2790 }
2791 }
2792
2793 // Mark onboarding dismissed
2794 try {
2795 await db
2796 .update(repositories)
2797 .set({ onboardingShown: true })
2798 .where(eq(repositories.id, repoRow.id));
2799 } catch {
2800 /* ignore */
2801 }
2802
2803 return c.redirect(
2804 `/${owner}/${repo}?success=${encodeURIComponent(`Created ${created} labels`)}`
2805 );
2806});
2807
2808// POST /:owner/:repo/setup/dismiss — marks onboarding as shown.
2809web.post("/:owner/:repo/setup/dismiss", softAuth, requireAuth, async (c) => {
2810 const { owner, repo } = c.req.param();
2811 const user = c.get("user")!;
2812
2813 try {
2814 const [ownerUser] = await db
2815 .select({ id: users.id })
2816 .from(users)
2817 .where(eq(users.username, owner))
2818 .limit(1);
2819 if (ownerUser) {
2820 const [r] = await db
2821 .select({ id: repositories.id, ownerId: repositories.ownerId })
2822 .from(repositories)
2823 .where(
2824 and(
2825 eq(repositories.ownerId, ownerUser.id),
2826 eq(repositories.name, repo)
2827 )
2828 )
2829 .limit(1);
2830 if (r && r.ownerId === user.id) {
2831 await db
2832 .update(repositories)
2833 .set({ onboardingShown: true })
2834 .where(eq(repositories.id, r.id));
2835 }
2836 }
2837 } catch {
2838 /* swallow — dismiss should never fail visibly */
2839 }
2840
2841 return c.redirect(`/${owner}/${repo}`);
2842});
2843
11c3ab6ccanty labs2844// Agent workspace — GET /:owner/:repo/workspace
5bb52faccanty labs2845web.get("/:owner/:repo/workspace", async (c) => {
11c3ab6ccanty labs2846 const { owner, repo } = c.req.param();
2847 const user = c.get("user");
5bb52faccanty labs2848 const gate = await assertRepoReadable(c, owner, repo);
2849 if (gate) return gate;
11c3ab6ccanty labs2850 return c.html(<AgentWorkspace owner={owner} repo={repo} user={user} />);
2851});
2852
2853// Org memory — GET /:owner/:repo/memory
5bb52faccanty labs2854web.get("/:owner/:repo/memory", async (c) => {
11c3ab6ccanty labs2855 const { owner, repo } = c.req.param();
2856 const user = c.get("user");
5bb52faccanty labs2857 const gate = await assertRepoReadable(c, owner, repo);
2858 if (gate) return gate;
11c3ab6ccanty labs2859 return c.html(<OrgMemory owner={owner} repo={repo} user={user} />);
2860});
2861
fc1817aClaude2862// Repository overview — file tree at HEAD
2863web.get("/:owner/:repo", async (c) => {
2864 const { owner, repo } = c.req.param();
06d5ffeClaude2865 const user = c.get("user");
fc1817aClaude2866
5bb52faccanty labs2867 // SECURITY: gate private-repo content before any render (incl. skeleton).
2868 const gate = await assertRepoReadable(c, owner, repo);
2869 if (gate) return gate;
2870
f1dc7c7Claude2871 // ── Loading skeleton (flag-gated) ──
2872 // Renders an SSR'd shell with file-tree + README placeholders when
2873 // `?skeleton=1` is set. Lets the user see the page structure before
2874 // git ops finish. Behind a flag for now so we never flash before the
2875 // real content lands.
2876 if (c.req.query("skeleton") === "1") {
2877 return c.html(
2878 <Layout title={`${owner}/${repo}`} user={user}>
2879 <style
2880 dangerouslySetInnerHTML={{
2881 __html: `
2882 .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; }
2883 @keyframes repoSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
2884 @media (prefers-reduced-motion: reduce) { .repo-skel { animation: none; } }
2885 .repo-skel-hero { height: 132px; border-radius: 16px; margin-bottom: var(--space-4); }
2886 .repo-skel-nav { height: 36px; border-radius: 8px; margin-bottom: var(--space-4); }
2887 .repo-skel-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--space-5); align-items: start; }
2888 @media (max-width: 960px) { .repo-skel-grid { grid-template-columns: minmax(0, 1fr); } }
2889 .repo-skel-branch { height: 32px; width: 200px; border-radius: 8px; margin-bottom: 12px; }
2890 .repo-skel-tree { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-5); }
2891 .repo-skel-tree-row { height: 36px; border-radius: 8px; }
2892 .repo-skel-readme { height: 320px; border-radius: 12px; }
2893 .repo-skel-side { display: flex; flex-direction: column; gap: var(--space-4); }
2894 .repo-skel-side-card { height: 180px; border-radius: 12px; }
2895 `,
2896 }}
2897 />
2898 <div class="repo-skel repo-skel-hero" aria-hidden="true" />
2899 <div class="repo-skel repo-skel-nav" aria-hidden="true" />
2900 <div class="repo-skel-grid" aria-hidden="true">
2901 <div>
2902 <div class="repo-skel repo-skel-branch" />
2903 <div class="repo-skel-tree">
2904 {Array.from({ length: 8 }).map(() => (
2905 <div class="repo-skel repo-skel-tree-row" />
2906 ))}
2907 </div>
2908 <div class="repo-skel repo-skel-readme" />
2909 </div>
2910 <aside class="repo-skel-side">
2911 <div class="repo-skel repo-skel-side-card" />
2912 <div class="repo-skel repo-skel-side-card" />
2913 </aside>
2914 </div>
2915 <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">
2916 Loading {owner}/{repo}…
2917 </span>
2918 </Layout>
2919 );
2920 }
2921
8f50ed0Claude2922 // F1 — fire-and-forget traffic tracking. Never awaits; never throws.
2923 trackByName(owner, repo, "view", {
2924 userId: user?.id || null,
2925 path: `/${owner}/${repo}`,
2926 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || null,
2927 userAgent: c.req.header("user-agent") || null,
2928 referer: c.req.header("referer") || null,
a28cedeClaude2929 }).catch((err) => {
2930 console.warn(
2931 `[web] view tracking failed for ${owner}/${repo}:`,
2932 err instanceof Error ? err.message : err
2933 );
2934 });
8f50ed0Claude2935
fc1817aClaude2936 if (!(await repoExists(owner, repo))) {
2937 return c.html(
06d5ffeClaude2938 <Layout title="Not Found" user={user}>
fc1817aClaude2939 <div class="empty-state">
2940 <h2>Repository not found</h2>
2941 <p>
2942 {owner}/{repo} does not exist.
2943 </p>
2944 </div>
2945 </Layout>,
2946 404
2947 );
2948 }
2949
05b973eClaude2950 // Parallelize all independent operations
2951 const [defaultBranch, branches] = await Promise.all([
2952 getDefaultBranch(owner, repo).then((b) => b || "main"),
2953 listBranches(owner, repo),
2954 ]);
2955 const [tree, starInfo] = await Promise.all([
2956 getTree(owner, repo, defaultBranch),
2957 // Star info fetched in parallel with tree
2958 (async () => {
2959 try {
2960 const [ownerUser] = await db
2961 .select()
2962 .from(users)
2963 .where(eq(users.username, owner))
2964 .limit(1);
71cd5ecClaude2965 if (!ownerUser)
2966 return {
2967 starCount: 0,
2968 starred: false,
2969 archived: false,
2970 isTemplate: false,
544d842Claude2971 forkCount: 0,
2972 description: null as string | null,
2973 pushedAt: null as Date | null,
2974 createdAt: null as Date | null,
cb5a796Claude2975 repoId: null as string | null,
2976 repoOwnerId: null as string | null,
71cd5ecClaude2977 };
05b973eClaude2978 const [repoRow] = await db
2979 .select()
2980 .from(repositories)
2981 .where(
2982 and(
2983 eq(repositories.ownerId, ownerUser.id),
2984 eq(repositories.name, repo)
2985 )
06d5ffeClaude2986 )
05b973eClaude2987 .limit(1);
71cd5ecClaude2988 if (!repoRow)
2989 return {
2990 starCount: 0,
2991 starred: false,
2992 archived: false,
2993 isTemplate: false,
544d842Claude2994 forkCount: 0,
2995 description: null as string | null,
2996 pushedAt: null as Date | null,
2997 createdAt: null as Date | null,
cb5a796Claude2998 repoId: null as string | null,
2999 repoOwnerId: null as string | null,
71cd5ecClaude3000 };
05b973eClaude3001 let starred = false;
06d5ffeClaude3002 if (user) {
3003 const [star] = await db
3004 .select()
3005 .from(stars)
3006 .where(
3007 and(
3008 eq(stars.userId, user.id),
3009 eq(stars.repositoryId, repoRow.id)
3010 )
3011 )
3012 .limit(1);
3013 starred = !!star;
3014 }
71cd5ecClaude3015 return {
3016 starCount: repoRow.starCount,
3017 starred,
3018 archived: repoRow.isArchived,
3019 isTemplate: repoRow.isTemplate,
544d842Claude3020 forkCount: repoRow.forkCount,
3021 description: repoRow.description as string | null,
3022 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3023 createdAt: (repoRow.createdAt as Date | null) ?? null,
cb5a796Claude3024 repoId: repoRow.id as string,
3025 repoOwnerId: repoRow.ownerId as string,
71cd5ecClaude3026 };
05b973eClaude3027 } catch {
71cd5ecClaude3028 return {
3029 starCount: 0,
3030 starred: false,
3031 archived: false,
3032 isTemplate: false,
544d842Claude3033 forkCount: 0,
3034 description: null as string | null,
3035 pushedAt: null as Date | null,
3036 createdAt: null as Date | null,
cb5a796Claude3037 repoId: null as string | null,
3038 repoOwnerId: null as string | null,
71cd5ecClaude3039 };
06d5ffeClaude3040 }
05b973eClaude3041 })(),
3042 ]);
544d842Claude3043 const {
3044 starCount,
3045 starred,
3046 archived,
3047 isTemplate,
3048 forkCount,
3049 description,
3050 pushedAt,
3051 createdAt,
cb5a796Claude3052 repoId,
3053 repoOwnerId,
544d842Claude3054 } = starInfo;
3055
91a0204Claude3056 // Health score badge — fire-and-forget, best-effort. If the DB call fails
3057 // or repoId is null (anonymous view of non-DB repo), healthScore stays null
3058 // and the badge simply doesn't render.
3059 let healthScore: HealthScore | null = null;
3060 if (repoId) {
3061 try {
3062 healthScore = await computeHealthScore(repoId);
3063 } catch {
3064 // swallow — badge is optional
3065 }
3066 }
3067
cb5a796Claude3068 // Pending-comments banner data (lazy + best-effort). Only the repo
3069 // owner sees the banner, so non-owner views skip the DB hit entirely.
3070 let repoHomePendingCount = 0;
3071 if (user && repoOwnerId && user.id === repoOwnerId && repoId) {
3072 try {
3073 const { countPendingForRepo } = await import(
3074 "../lib/comment-moderation"
3075 );
3076 repoHomePendingCount = await countPendingForRepo(repoId);
3077 } catch {
3078 /* swallow */
3079 }
3080 }
3081
8c790e0Claude3082 // Push Watch discoverability — fetch the most recent push within 24 h.
3083 // Runs only when we have a repoId (i.e. the repo row was found in the DB).
3084 const recentPush: RecentPush | null = repoId
3085 ? await getRecentPush(repoId)
3086 : null;
3087
ebaae0fClaude3088 // AI stats strip — best-effort, fail-open to zero values.
3089 let aiStats: RepoAiStats = {
3090 mergedCount: 0,
3091 reviewCount: 0,
3092 securityAlertCount: 0,
3093 hoursSaved: "0.0",
3094 };
3095 if (repoId) {
3096 try {
3097 aiStats = await getRepoAiStats(repoId);
3098 } catch {
3099 /* swallow — show zero values */
3100 }
3101 }
3102
641aa42Claude3103 // Onboarding card — shown to repo owner until dismissed. Best-effort;
3104 // if the DB is down the card simply won't appear. Only the owner sees it.
3105 let onboardingRow: (typeof repoOnboardingData.$inferSelect) | null = null;
3106 let showOnboarding = false;
3107 if (repoId && user && repoOwnerId && user.id === repoOwnerId) {
3108 try {
3109 // Check if onboarding_shown is still false (not yet dismissed)
3110 const [repoRow2] = await db
3111 .select({ onboardingShown: repositories.onboardingShown })
3112 .from(repositories)
3113 .where(eq(repositories.id, repoId))
3114 .limit(1);
3115 if (repoRow2 && !repoRow2.onboardingShown) {
3116 const [obRow] = await db
3117 .select()
3118 .from(repoOnboardingData)
3119 .where(eq(repoOnboardingData.repositoryId, repoId))
3120 .limit(1);
3121 onboardingRow = obRow ?? null;
3122 showOnboarding = !!onboardingRow;
3123 }
3124 } catch {
3125 /* swallow — onboarding is optional */
3126 }
3127 }
3128
544d842Claude3129 // Repo-home polish — shared style block (Block 2.A — parallel session 2.A).
3130 // Scoped via .repo-home-* class prefix to prevent bleed into other surfaces.
3131 const repoHomeCss = `
3132 .repo-home-hero {
3133 position: relative;
3134 margin-bottom: var(--space-5);
3135 padding: var(--space-5) var(--space-6);
3136 background: var(--bg-elevated);
3137 border: 1px solid var(--border);
3138 border-radius: 16px;
3139 overflow: hidden;
3140 }
3141 .repo-home-hero::before {
3142 content: '';
3143 position: absolute;
3144 top: 0; left: 0; right: 0;
3145 height: 2px;
6fd5915Claude3146 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3147 opacity: 0.7;
3148 pointer-events: none;
3149 }
3150 .repo-home-hero-orb-wrap {
3151 position: absolute;
3152 inset: -25% -10% auto auto;
3153 width: 360px;
3154 height: 360px;
3155 pointer-events: none;
3156 z-index: 0;
3157 }
3158 .repo-home-hero-orb {
3159 position: absolute;
3160 inset: 0;
6fd5915Claude3161 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
544d842Claude3162 filter: blur(80px);
3163 opacity: 0.7;
3164 animation: repoHomeOrb 14s ease-in-out infinite;
3165 }
3166 @keyframes repoHomeOrb {
3167 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
3168 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
3169 }
3170 @media (prefers-reduced-motion: reduce) {
3171 .repo-home-hero-orb { animation: none; }
3172 }
3173 .repo-home-hero-inner {
3174 position: relative;
3175 z-index: 1;
3176 }
3177 .repo-home-hero .repo-header { margin-bottom: var(--space-3); }
3178 .repo-home-eyebrow {
3179 font-size: 12px;
3180 font-family: var(--font-mono);
3181 color: var(--text-muted);
3182 letter-spacing: 0.1em;
3183 text-transform: uppercase;
3184 margin-bottom: var(--space-2);
3185 }
3186 .repo-home-eyebrow strong { color: var(--accent); font-weight: 600; }
3187 .repo-home-description {
3188 font-size: 15px;
3189 line-height: 1.55;
3190 color: var(--text);
3191 margin: 0;
3192 max-width: 720px;
3193 }
3194 .repo-home-description-empty {
3195 font-size: 14px;
3196 color: var(--text-muted);
3197 font-style: italic;
3198 margin: 0;
3199 }
3200 .repo-home-stat-row {
3201 display: flex;
3202 flex-wrap: wrap;
3203 gap: var(--space-4);
3204 margin-top: var(--space-3);
3205 font-size: 13px;
3206 color: var(--text-muted);
3207 }
3208 .repo-home-stat {
3209 display: inline-flex;
3210 align-items: center;
3211 gap: 6px;
3212 }
3213 .repo-home-stat strong {
3214 color: var(--text-strong);
3215 font-weight: 600;
3216 font-variant-numeric: tabular-nums;
3217 }
3218 .repo-home-stat .repo-home-stat-icon {
3219 color: var(--text-faint);
3220 font-size: 14px;
3221 line-height: 1;
3222 }
3223 .repo-home-stat a {
3224 color: var(--text-muted);
3225 transition: color var(--t-fast) var(--ease);
3226 }
3227 .repo-home-stat a:hover { color: var(--accent); text-decoration: none; }
3228
3229 /* Two-column layout: file tree + sidebar */
3230 .repo-home-grid {
3231 display: grid;
3232 grid-template-columns: minmax(0, 1fr) 280px;
3233 gap: var(--space-5);
3234 align-items: start;
3235 }
3236 @media (max-width: 960px) {
3237 .repo-home-grid { grid-template-columns: minmax(0, 1fr); }
3238 }
3239 .repo-home-main { min-width: 0; }
3240
3241 /* Sidebar card */
3242 .repo-home-side {
3243 display: flex;
3244 flex-direction: column;
3245 gap: var(--space-4);
3246 }
3247 .repo-home-side-card {
3248 background: var(--bg-elevated);
3249 border: 1px solid var(--border);
3250 border-radius: 12px;
3251 padding: var(--space-4);
3252 }
3253 .repo-home-side-title {
3254 font-size: 11px;
3255 font-family: var(--font-mono);
3256 letter-spacing: 0.12em;
3257 text-transform: uppercase;
3258 color: var(--text-muted);
3259 margin: 0 0 var(--space-3);
3260 font-weight: 600;
3261 }
3262 .repo-home-side-row {
3263 display: flex;
3264 justify-content: space-between;
3265 align-items: center;
3266 gap: var(--space-2);
3267 font-size: 13px;
3268 padding: 6px 0;
3269 border-top: 1px solid var(--border);
3270 }
3271 .repo-home-side-row:first-of-type { border-top: 0; padding-top: 0; }
3272 .repo-home-side-key {
3273 color: var(--text-muted);
3274 display: inline-flex;
3275 align-items: center;
3276 gap: 6px;
3277 }
3278 .repo-home-side-val {
3279 color: var(--text-strong);
3280 font-weight: 500;
3281 font-variant-numeric: tabular-nums;
3282 max-width: 60%;
3283 text-align: right;
3284 overflow: hidden;
3285 text-overflow: ellipsis;
3286 white-space: nowrap;
3287 }
3288 .repo-home-side-val a { color: var(--text-strong); }
3289 .repo-home-side-val a:hover { color: var(--accent); text-decoration: none; }
3290
3291 /* Clone / Code tabs */
3292 .repo-home-clone {
3293 background: var(--bg-elevated);
3294 border: 1px solid var(--border);
3295 border-radius: 12px;
3296 overflow: hidden;
3297 }
3298 .repo-home-clone-tabs {
3299 display: flex;
3300 gap: 0;
3301 background: var(--bg-secondary);
3302 border-bottom: 1px solid var(--border);
3303 padding: 0 var(--space-2);
3304 }
3305 .repo-home-clone-tab {
3306 appearance: none;
3307 background: transparent;
3308 border: 0;
3309 border-bottom: 2px solid transparent;
3310 padding: 9px 12px;
3311 font-size: 12px;
3312 font-weight: 500;
3313 color: var(--text-muted);
3314 cursor: pointer;
3315 transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3316 font-family: inherit;
3317 margin-bottom: -1px;
3318 }
3319 .repo-home-clone-tab:hover { color: var(--text-strong); }
3320 .repo-home-clone-tab[aria-selected="true"] {
3321 color: var(--text-strong);
3322 border-bottom-color: var(--accent);
3323 }
3324 .repo-home-clone-body {
3325 padding: var(--space-3);
3326 display: flex;
3327 align-items: center;
3328 gap: var(--space-2);
3329 }
3330 .repo-home-clone-input {
3331 flex: 1;
3332 min-width: 0;
3333 font-family: var(--font-mono);
3334 font-size: 12px;
3335 background: var(--bg);
3336 border: 1px solid var(--border);
3337 border-radius: 8px;
3338 padding: 8px 10px;
3339 color: var(--text-strong);
3340 overflow: hidden;
3341 text-overflow: ellipsis;
3342 white-space: nowrap;
3343 }
3344 .repo-home-clone-copy {
3345 appearance: none;
3346 background: var(--bg-secondary);
3347 border: 1px solid var(--border);
3348 color: var(--text-strong);
3349 border-radius: 8px;
3350 padding: 8px 12px;
3351 font-size: 12px;
3352 font-weight: 600;
3353 cursor: pointer;
3354 transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
3355 font-family: inherit;
3356 }
3357 .repo-home-clone-copy:hover { background: var(--bg-hover); border-color: var(--border-strong, var(--border)); }
3358 .repo-home-clone-pane { display: none; }
3359 .repo-home-clone-pane[data-active="true"] { display: flex; }
3360
3361 /* README card */
3362 .repo-home-readme {
3363 margin-top: var(--space-5);
3364 background: var(--bg-elevated);
3365 border: 1px solid var(--border);
3366 border-radius: 12px;
3367 overflow: hidden;
3368 }
3369 .repo-home-readme-head {
3370 display: flex;
3371 align-items: center;
3372 gap: 8px;
3373 padding: 10px 16px;
3374 background: var(--bg-secondary);
3375 border-bottom: 1px solid var(--border);
3376 font-size: 13px;
3377 color: var(--text-muted);
3378 }
3379 .repo-home-readme-head .repo-home-readme-icon {
3380 color: var(--accent);
3381 font-size: 14px;
3382 }
3383 .repo-home-readme-body {
3384 padding: var(--space-5) var(--space-6);
3385 }
3386
3387 /* Empty-state CTA */
3388 .repo-home-empty {
3389 position: relative;
3390 margin-top: var(--space-4);
3391 background: var(--bg-elevated);
3392 border: 1px solid var(--border);
3393 border-radius: 16px;
3394 padding: var(--space-6);
3395 overflow: hidden;
3396 }
3397 .repo-home-empty::before {
3398 content: '';
3399 position: absolute;
3400 top: 0; left: 0; right: 0;
3401 height: 2px;
6fd5915Claude3402 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
544d842Claude3403 opacity: 0.7;
3404 pointer-events: none;
3405 }
3406 .repo-home-empty-eyebrow {
3407 font-size: 12px;
3408 font-family: var(--font-mono);
3409 color: var(--accent);
3410 letter-spacing: 0.12em;
3411 text-transform: uppercase;
3412 margin-bottom: var(--space-2);
3413 font-weight: 600;
3414 }
3415 .repo-home-empty-title {
3416 font-family: var(--font-display);
3417 font-weight: 800;
3418 letter-spacing: -0.025em;
3419 font-size: clamp(22px, 3vw, 30px);
3420 line-height: 1.1;
3421 margin: 0 0 var(--space-2);
3422 color: var(--text-strong);
3423 }
3424 .repo-home-empty-sub {
3425 color: var(--text-muted);
3426 font-size: 14px;
3427 line-height: 1.55;
3428 max-width: 640px;
3429 margin: 0 0 var(--space-4);
3430 }
3431 .repo-home-empty-snippet {
3432 background: var(--bg);
3433 border: 1px solid var(--border);
3434 border-radius: 10px;
3435 padding: var(--space-3) var(--space-4);
3436 font-family: var(--font-mono);
3437 font-size: 12.5px;
3438 line-height: 1.7;
3439 color: var(--text-strong);
3440 overflow-x: auto;
3441 white-space: pre;
3442 margin: 0;
3443 }
3444 .repo-home-empty-snippet .repo-home-cmt { color: var(--text-faint); }
3445 .repo-home-empty-snippet .repo-home-cmd { color: var(--accent); }
3446
91a0204Claude3447 /* Health score badge */
3448 .repo-health-badge {
3449 display: inline-flex;
3450 align-items: center;
3451 gap: 5px;
3452 padding: 3px 10px;
3453 border-radius: 999px;
3454 font-size: 11.5px;
3455 font-weight: 700;
3456 font-family: var(--font-mono);
3457 text-decoration: none;
3458 border: 1px solid transparent;
3459 transition: filter 120ms ease, opacity 120ms ease;
3460 vertical-align: middle;
3461 }
3462 .repo-health-badge:hover { filter: brightness(1.15); text-decoration: none; opacity: 0.9; }
3463 .repo-health-badge-elite {
3464 background: rgba(52,211,153,0.15);
3465 color: #6ee7b7;
3466 border-color: rgba(52,211,153,0.30);
3467 }
3468 .repo-health-badge-strong {
3469 background: rgba(96,165,250,0.15);
3470 color: #93c5fd;
3471 border-color: rgba(96,165,250,0.30);
3472 }
3473 .repo-health-badge-improving {
3474 background: rgba(251,191,36,0.15);
3475 color: #fde68a;
3476 border-color: rgba(251,191,36,0.30);
3477 }
3478 .repo-health-badge-needs-attention {
3479 background: rgba(248,113,113,0.15);
3480 color: #fca5a5;
3481 border-color: rgba(248,113,113,0.30);
3482 }
3483
3484 /* Three-option empty-state panel */
3485 .repo-empty-options {
3486 display: grid;
3487 grid-template-columns: repeat(3, 1fr);
3488 gap: var(--space-3);
3489 margin-top: var(--space-5);
3490 }
3491 @media (max-width: 760px) {
3492 .repo-empty-options { grid-template-columns: 1fr; }
3493 }
3494 .repo-empty-option {
3495 position: relative;
3496 background: var(--bg-elevated);
3497 border: 1px solid var(--border);
3498 border-radius: 14px;
3499 padding: var(--space-4) var(--space-4);
3500 display: flex;
3501 flex-direction: column;
3502 gap: var(--space-2);
3503 overflow: hidden;
3504 transition: border-color 140ms ease, background 140ms ease;
3505 }
3506 .repo-empty-option:hover {
6fd5915Claude3507 border-color: rgba(91,110,232,0.45);
3508 background: rgba(91,110,232,0.04);
91a0204Claude3509 }
3510 .repo-empty-option::before {
3511 content: '';
3512 position: absolute;
3513 top: 0; left: 0; right: 0;
3514 height: 2px;
6fd5915Claude3515 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3516 opacity: 0.55;
3517 pointer-events: none;
3518 }
3519 .repo-empty-option-label {
3520 font-size: 10px;
3521 font-family: var(--font-mono);
3522 font-weight: 700;
3523 letter-spacing: 0.12em;
3524 text-transform: uppercase;
3525 color: var(--accent);
3526 margin-bottom: 2px;
3527 }
3528 .repo-empty-option-title {
3529 font-family: var(--font-display);
3530 font-weight: 700;
3531 font-size: 16px;
3532 letter-spacing: -0.01em;
3533 color: var(--text-strong);
3534 margin: 0;
3535 }
3536 .repo-empty-option-sub {
3537 font-size: 13px;
3538 color: var(--text-muted);
3539 line-height: 1.5;
3540 margin: 0;
3541 flex: 1;
3542 }
3543 .repo-empty-option-snippet {
3544 background: var(--bg);
3545 border: 1px solid var(--border);
3546 border-radius: 8px;
3547 padding: var(--space-2) var(--space-3);
3548 font-family: var(--font-mono);
3549 font-size: 11.5px;
3550 line-height: 1.75;
3551 color: var(--text-strong);
3552 overflow-x: auto;
3553 white-space: pre;
3554 margin: var(--space-1) 0 0;
3555 }
3556 .repo-empty-option-snippet .reo-cmt { color: var(--text-faint); }
3557 .repo-empty-option-snippet .reo-cmd { color: var(--accent); }
3558 .repo-empty-option-cta {
3559 display: inline-flex;
3560 align-items: center;
3561 gap: 6px;
3562 margin-top: auto;
3563 padding-top: var(--space-2);
3564 font-size: 13px;
3565 font-weight: 600;
3566 color: var(--accent);
3567 text-decoration: none;
3568 transition: color 120ms ease;
3569 }
3570 .repo-empty-option-cta:hover { color: var(--text-strong); text-decoration: none; }
3571
544d842Claude3572 @media (max-width: 720px) {
3573 .repo-home-hero { padding: var(--space-4) var(--space-4); }
3574 .repo-home-clone-body { flex-direction: column; align-items: stretch; }
3575 .repo-home-clone-copy { width: 100%; }
f1dc7c7Claude3576 .repo-home-stat-row { gap: var(--space-2) var(--space-3); font-size: 12.5px; }
3577 .repo-home-side-val { max-width: 55%; }
3578 .repo-home-clone-tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; }
3579 .repo-home-clone-tab { min-height: 44px; padding: 11px 14px; }
3580 .repo-home-side-card { padding: var(--space-3); }
544d842Claude3581 }
ebaae0fClaude3582
3583 /* AI stats strip */
3584 .repo-ai-stats-strip {
3585 display: flex;
3586 align-items: center;
3587 gap: 6px;
3588 margin-top: 12px;
3589 margin-bottom: 4px;
3590 font-size: 12px;
3591 color: var(--text-muted);
3592 flex-wrap: wrap;
3593 }
3594 .repo-ai-stats-strip a {
3595 color: var(--text-muted);
3596 text-decoration: underline;
3597 text-decoration-color: transparent;
3598 transition: color 120ms ease, text-decoration-color 120ms ease;
3599 }
3600 .repo-ai-stats-strip a:hover {
3601 color: var(--accent);
3602 text-decoration-color: currentColor;
3603 }
3604 .repo-ai-stats-sep {
3605 opacity: 0.4;
3606 user-select: none;
3607 }
544d842Claude3608 `;
3609 const cloneHttpsUrl = `${config.appBaseUrl}/${owner}/${repo}.git`;
60323c5Claude3610 // SSH URL — port-aware:
3611 // Standard port 22 → git@host:owner/repo.git
3612 // Non-standard port → ssh://git@host:PORT/owner/repo.git
544d842Claude3613 let cloneSshUrl = `git@gluecron.com:${owner}/${repo}.git`;
3614 try {
3615 const host = new URL(config.appBaseUrl).hostname;
60323c5Claude3616 const sshHost = (host && host !== "localhost" && host !== "127.0.0.1")
3617 ? host
3618 : "localhost";
3619 const sshPort = config.sshPort;
3620 if (sshPort === 22) {
3621 cloneSshUrl = `git@${sshHost}:${owner}/${repo}.git`;
3622 } else {
3623 cloneSshUrl = `ssh://git@${sshHost}:${sshPort}/${owner}/${repo}.git`;
544d842Claude3624 }
3625 } catch {
3626 // Fall through to default.
3627 }
3628 const cloneCliCmd = `gluecron clone ${owner}/${repo}`;
3629 const formatRelative = (date: Date | null): string => {
3630 if (!date) return "never";
3631 const ms = Date.now() - date.getTime();
3632 const s = Math.max(0, Math.round(ms / 1000));
3633 if (s < 60) return "just now";
3634 const m = Math.round(s / 60);
3635 if (m < 60) return `${m} min ago`;
3636 const h = Math.round(m / 60);
3637 if (h < 24) return `${h}h ago`;
3638 const d = Math.round(h / 24);
3639 if (d < 30) return `${d}d ago`;
3640 const mo = Math.round(d / 30);
3641 if (mo < 12) return `${mo}mo ago`;
3642 const y = Math.round(d / 365);
3643 return `${y}y ago`;
3644 };
06d5ffeClaude3645
fc1817aClaude3646 if (tree.length === 0) {
5acce80Claude3647 const repoOgDesc = description
3648 ? `${owner}/${repo} on Gluecron — ${description}`
3649 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
fc1817aClaude3650 return c.html(
5acce80Claude3651 <Layout
3652 title={`${owner}/${repo}`}
3653 user={user}
3654 description={repoOgDesc}
3655 ogTitle={`${owner}/${repo} — Gluecron`}
3656 ogDescription={repoOgDesc}
3657 twitterCard="summary"
3658 >
544d842Claude3659 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
91a0204Claude3660 <style dangerouslySetInnerHTML={{ __html: `
3661 .empty-options-grid {
3662 display: grid;
3663 grid-template-columns: repeat(3, 1fr);
3664 gap: var(--space-4);
3665 margin-top: var(--space-4);
3666 }
3667 @media (max-width: 800px) {
3668 .empty-options-grid { grid-template-columns: 1fr; }
3669 }
3670 .empty-option-card {
3671 position: relative;
3672 background: var(--bg-elevated);
3673 border: 1px solid var(--border);
3674 border-radius: 14px;
3675 padding: var(--space-5);
3676 display: flex;
3677 flex-direction: column;
3678 gap: var(--space-3);
3679 overflow: hidden;
3680 transition: border-color 140ms ease, box-shadow 140ms ease;
3681 }
3682 .empty-option-card:hover {
6fd5915Claude3683 border-color: rgba(91,110,232,0.45);
3684 box-shadow: 0 8px 24px -8px rgba(91,110,232,0.18);
91a0204Claude3685 }
3686 .empty-option-card::before {
3687 content: '';
3688 position: absolute;
3689 top: 0; left: 0; right: 0;
3690 height: 2px;
6fd5915Claude3691 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
91a0204Claude3692 opacity: 0.55;
3693 pointer-events: none;
3694 }
3695 .empty-option-label {
3696 font-size: 10.5px;
3697 font-family: var(--font-mono);
3698 text-transform: uppercase;
3699 letter-spacing: 0.14em;
3700 color: var(--accent);
3701 font-weight: 700;
3702 }
3703 .empty-option-title {
3704 font-family: var(--font-display);
3705 font-weight: 700;
3706 font-size: 17px;
3707 letter-spacing: -0.01em;
3708 color: var(--text-strong);
3709 margin: 0;
3710 line-height: 1.25;
3711 }
3712 .empty-option-body {
3713 font-size: 13px;
3714 color: var(--text-muted);
3715 line-height: 1.55;
3716 margin: 0;
3717 flex: 1;
3718 }
3719 .empty-option-snippet {
3720 background: var(--bg);
3721 border: 1px solid var(--border);
3722 border-radius: 8px;
3723 padding: var(--space-3) var(--space-4);
3724 font-family: var(--font-mono);
3725 font-size: 11.5px;
3726 line-height: 1.75;
3727 color: var(--text-strong);
3728 overflow-x: auto;
3729 white-space: pre;
3730 margin: 0;
3731 }
3732 .empty-option-snippet .ec { color: var(--text-faint); }
3733 .empty-option-snippet .em { color: var(--accent); }
3734 .empty-option-cta {
3735 display: inline-flex;
3736 align-items: center;
3737 gap: 6px;
3738 padding: 8px 16px;
3739 font-size: 13px;
3740 font-weight: 600;
3741 color: var(--text-strong);
3742 background: var(--bg-secondary);
3743 border: 1px solid var(--border);
3744 border-radius: 9999px;
3745 text-decoration: none;
3746 align-self: flex-start;
3747 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
3748 }
3749 .empty-option-cta:hover {
6fd5915Claude3750 border-color: rgba(91,110,232,0.55);
3751 background: rgba(91,110,232,0.08);
91a0204Claude3752 color: var(--accent);
3753 text-decoration: none;
3754 }
3755 .empty-option-cta-accent {
6fd5915Claude3756 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
91a0204Claude3757 border-color: transparent;
3758 color: #fff;
3759 }
3760 .empty-option-cta-accent:hover {
3761 background: linear-gradient(135deg, #7c5df0, #28b4c8);
3762 border-color: transparent;
3763 color: #fff;
6fd5915Claude3764 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.55);
91a0204Claude3765 }
3766 ` }} />
544d842Claude3767 <div class="repo-home-hero">
3768 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3769 <div class="repo-home-hero-orb" />
3770 </div>
3771 <div class="repo-home-hero-inner">
3772 <div class="repo-home-eyebrow">
3773 <strong>Repository</strong> · {owner}
3774 </div>
91a0204Claude3775 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3776 <RepoHeader
3777 owner={owner}
3778 repo={repo}
3779 starCount={starCount}
3780 starred={starred}
3781 forkCount={forkCount}
3782 currentUser={user?.username}
3783 archived={archived}
3784 isTemplate={isTemplate}
8c790e0Claude3785 recentPush={recentPush}
91a0204Claude3786 />
3787 {healthScore && (() => {
3788 const gradeLabel: Record<string, string> = {
3789 elite: "Elite", strong: "Strong",
3790 improving: "Improving", "needs-attention": "Needs Attention",
3791 };
3792 return (
3793 <a
3794 href={`/${owner}/${repo}/insights/health`}
3795 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3796 title={`Health score: ${healthScore!.total}/100`}
3797 >
3798 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3799 </a>
3800 );
3801 })()}
3802 </div>
544d842Claude3803 {description ? (
3804 <p class="repo-home-description">{description}</p>
3805 ) : (
3806 <p class="repo-home-description-empty">
3807 No description yet — push a README to tell the world what this
3808 ships.
3809 </p>
3810 )}
3811 </div>
3812 </div>
fc1817aClaude3813 <RepoNav owner={owner} repo={repo} active="code" />
91a0204Claude3814 <div style="margin-top:var(--space-4)">
3815 <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)">
3816 Getting started
3817 </div>
544d842Claude3818 <h2 class="repo-home-empty-title">
91a0204Claude3819 <span class="gradient-text">{repo}</span> is ready — make your first move.
544d842Claude3820 </h2>
3821 <p class="repo-home-empty-sub">
91a0204Claude3822 Choose how you want to kick things off. Every push wires up gate
3823 checks and AI review automatically.
544d842Claude3824 </p>
91a0204Claude3825 <div class="empty-options-grid">
3826 <div class="empty-option-card">
3827 <div class="empty-option-label">Option A</div>
3828 <h3 class="empty-option-title">Push your first commit</h3>
3829 <p class="empty-option-body">
3830 Wire up an existing project in seconds. Run these commands from
3831 your project directory.
3832 </p>
3833 <pre class="empty-option-snippet"><span class="ec"># from your project directory</span>
3834<span class="em">git remote add</span> origin {cloneHttpsUrl}
3835<span class="em">git branch</span> -M main
3836<span class="em">git push</span> -u origin main</pre>
3837 </div>
3838 <div class="empty-option-card">
3839 <div class="empty-option-label">Option B</div>
3840 <h3 class="empty-option-title">Import from GitHub</h3>
3841 <p class="empty-option-body">
3842 Mirror an existing GitHub repository here in one click. Gluecron
3843 syncs the full history and branches.
3844 </p>
3845 <a href="/import" class="empty-option-cta">
3846 Import repository
3847 </a>
3848 </div>
3849 <div class="empty-option-card">
3850 <div class="empty-option-label">Option C</div>
3851 <h3 class="empty-option-title">Try Spec-to-PR</h3>
3852 <p class="empty-option-body">
3853 Let AI write your first feature. Describe what you want to build
3854 and Gluecron opens a pull request with the code.
3855 </p>
3856 <a href={`/${owner}/${repo}/specs`} class="empty-option-cta empty-option-cta-accent">
3857 Let AI write your first feature
3858 </a>
3859 </div>
3860 </div>
fc1817aClaude3861 </div>
3862 </Layout>
3863 );
3864 }
3865
3866 const readme = await getReadme(owner, repo, defaultBranch);
3867
544d842Claude3868 // Sidebar facts — derived from data we already have.
3869 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
3870 const dirCount = tree.filter((e: any) => e.type === "tree").length;
3871
5acce80Claude3872 const repoOgDesc = description
3873 ? `${owner}/${repo} on Gluecron — ${description}`
3874 : `${owner}/${repo} on Gluecron — AI-native git hosting with push-time gates and auto-merge.`;
3875
fc1817aClaude3876 return c.html(
5acce80Claude3877 <Layout
3878 title={`${owner}/${repo}`}
3879 user={user}
3880 description={repoOgDesc}
3881 ogTitle={`${owner}/${repo} — Gluecron`}
3882 ogDescription={repoOgDesc}
3883 twitterCard="summary"
3884 >
544d842Claude3885 <style dangerouslySetInnerHTML={{ __html: repoHomeCss }} />
641aa42Claude3886 {/* Repo-context commands for the command palette (Feature 2) */}
3887 <script
3888 id="cmdk-repo-context"
3889 dangerouslySetInnerHTML={{
3890 __html: `window.__CMDK_REPO_COMMANDS = ${JSON.stringify([
3891 { label: `New issue in ${repo}`, href: `/${owner}/${repo}/issues/new`, kw: "create add bug" },
3892 { label: `New pull request in ${repo}`, href: `/${owner}/${repo}/pulls/new`, kw: "pr branch merge" },
3893 { label: `Browse code — ${owner}/${repo}`, href: `/${owner}/${repo}`, kw: "files tree" },
3894 { label: `View commits — ${owner}/${repo}`, href: `/${owner}/${repo}/commits`, kw: "history log" },
3895 { label: `Search code — ${owner}/${repo}`, href: `/${owner}/${repo}/search`, kw: "grep find" },
3896 { label: `AI explain codebase — ${owner}/${repo}`, href: `/${owner}/${repo}/explain`, kw: "understand" },
3897 { label: `Debt map — ${owner}/${repo}`, href: `/${owner}/${repo}/debt-map`, kw: "technical debt" },
3898 { label: `Repo settings — ${owner}/${repo}`, href: `/${owner}/${repo}/settings`, kw: "config" },
3899 { label: `Issues — ${owner}/${repo}`, href: `/${owner}/${repo}/issues`, kw: "bugs tasks" },
3900 { label: `Pull requests — ${owner}/${repo}`, href: `/${owner}/${repo}/pulls`, kw: "prs reviews" },
3901 ])};`,
3902 }}
3903 />
544d842Claude3904 <div class="repo-home-hero">
3905 <div class="repo-home-hero-orb-wrap" aria-hidden="true">
3906 <div class="repo-home-hero-orb" />
3907 </div>
3908 <div class="repo-home-hero-inner">
3909 <div class="repo-home-eyebrow">
3910 <strong>Repository</strong> · {owner}
3911 </div>
91a0204Claude3912 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
3913 <RepoHeader
3914 owner={owner}
3915 repo={repo}
3916 starCount={starCount}
3917 starred={starred}
3918 forkCount={forkCount}
3919 currentUser={user?.username}
3920 archived={archived}
3921 isTemplate={isTemplate}
8c790e0Claude3922 recentPush={recentPush}
91a0204Claude3923 />
3924 {healthScore && (() => {
3925 const gradeLabel: Record<string, string> = {
3926 elite: "Elite", strong: "Strong",
3927 improving: "Improving", "needs-attention": "Needs Attention",
3928 };
3929 return (
3930 <a
3931 href={`/${owner}/${repo}/insights/health`}
3932 class={`repo-health-badge repo-health-badge-${healthScore!.grade}`}
3933 title={`Health score: ${healthScore!.total}/100`}
3934 >
3935 {gradeLabel[healthScore!.grade] ?? healthScore!.grade}
3936 </a>
3937 );
3938 })()}
3939 </div>
544d842Claude3940 {description ? (
3941 <p class="repo-home-description">{description}</p>
3942 ) : (
3943 <p class="repo-home-description-empty">
3944 No description yet.
3945 </p>
3946 )}
3947 <div class="repo-home-stat-row" aria-label="Repository stats">
3948 <span class="repo-home-stat" title="Default branch">
3949 <span class="repo-home-stat-icon">{"⎇"}</span>
3950 <strong>{defaultBranch}</strong>
3951 </span>
3952 <a
3953 href={`/${owner}/${repo}/commits/${defaultBranch}`}
3954 class="repo-home-stat"
3955 title="Browse all branches"
3956 >
3957 <span class="repo-home-stat-icon">{"⊢"}</span>
3958 <strong>{branches.length}</strong>{" "}
3959 branch{branches.length === 1 ? "" : "es"}
3960 </a>
3961 <span class="repo-home-stat" title="Top-level entries">
3962 <span class="repo-home-stat-icon">{"■"}</span>
3963 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
3964 {dirCount > 0 && (
3965 <>
3966 {" · "}
3967 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
3968 </>
3969 )}
3970 </span>
3971 {pushedAt && (
3972 <span class="repo-home-stat" title={`Last push: ${pushedAt.toISOString()}`}>
3973 <span class="repo-home-stat-icon">{"↻"}</span>
3974 Updated <strong>{formatRelative(pushedAt)}</strong>
3975 </span>
3976 )}
3977 </div>
3978 </div>
3979 </div>
71cd5ecClaude3980 {isTemplate && user && user.username !== owner && (
3981 <div
3982 class="panel"
dc26881CC LABS App3983 style="margin-bottom:var(--space-4);padding:var(--space-3);display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)"
71cd5ecClaude3984 >
3985 <div style="font-size:13px">
3986 <strong>Template repository.</strong> Create a new repository from
3987 this template's files.
3988 </div>
3989 <form
001af43Claude3990 method="post"
71cd5ecClaude3991 action={`/${owner}/${repo}/use-template`}
dc26881CC LABS App3992 style="display:flex;gap:var(--space-2);align-items:center"
71cd5ecClaude3993 >
3994 <input
3995 type="text"
3996 name="name"
3997 placeholder="new-repo-name"
3998 required
2c3ba6ecopilot-swe-agent[bot]3999 aria-label="New repository name"
71cd5ecClaude4000 style="width:200px"
4001 />
4002 <button type="submit" class="btn btn-primary">
4003 Use this template
4004 </button>
4005 </form>
4006 </div>
4007 )}
fc1817aClaude4008 <RepoNav owner={owner} repo={repo} active="code" />
cb5a796Claude4009 <RepoHomePendingBanner
4010 owner={owner}
4011 repo={repo}
4012 count={repoHomePendingCount}
4013 />
641aa42Claude4014 {showOnboarding && onboardingRow && (
4015 <RepoOnboardingCard
4016 owner={owner}
4017 repo={repo}
4018 data={onboardingRow}
4019 />
4020 )}
c6018a5Claude4021 {/* ─── Per-repo AI surfaces — RepoNav is locked, so the discovery
4022 row sits just below the nav as a slim CTA strip. Scoped under
4023 `.repo-ai-cta-` so the styles can't bleed onto other pages. ─── */}
4024 <style
4025 dangerouslySetInnerHTML={{
4026 __html: `
4027 .repo-ai-cta-row {
4028 display: flex;
4029 flex-wrap: wrap;
4030 gap: 8px;
4031 margin: 12px 0 18px;
4032 padding: 10px 14px;
6fd5915Claude4033 background: linear-gradient(135deg, rgba(91,110,232,0.06), rgba(95,143,160,0.04));
c6018a5Claude4034 border: 1px solid var(--border);
4035 border-radius: 10px;
4036 position: relative;
4037 overflow: hidden;
4038 }
4039 .repo-ai-cta-row::before {
4040 content: '';
4041 position: absolute;
4042 top: 0; left: 0; right: 0;
4043 height: 1px;
6fd5915Claude4044 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.45) 30%, rgba(95,143,160,0.45) 70%, transparent 100%);
c6018a5Claude4045 opacity: 0.7;
4046 pointer-events: none;
4047 }
4048 .repo-ai-cta-label {
4049 font-size: 11px;
4050 font-weight: 600;
4051 letter-spacing: 0.06em;
4052 text-transform: uppercase;
4053 color: var(--accent);
4054 align-self: center;
4055 padding-right: 8px;
4056 border-right: 1px solid var(--border);
4057 margin-right: 4px;
4058 }
4059 .repo-ai-cta {
4060 display: inline-flex;
4061 align-items: center;
4062 gap: 6px;
4063 padding: 5px 10px;
4064 font-size: 12.5px;
4065 font-weight: 500;
4066 color: var(--text);
4067 background: var(--bg);
4068 border: 1px solid var(--border);
4069 border-radius: 7px;
4070 text-decoration: none;
4071 transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
4072 }
4073 .repo-ai-cta:hover {
6fd5915Claude4074 border-color: rgba(91,110,232,0.45);
c6018a5Claude4075 color: var(--text-strong);
6fd5915Claude4076 background: rgba(91,110,232,0.06);
c6018a5Claude4077 text-decoration: none;
4078 }
4079 .repo-ai-cta-icon {
4080 opacity: 0.75;
4081 font-size: 12px;
4082 }
4083 @media (max-width: 640px) {
4084 .repo-ai-cta-row { flex-direction: column; align-items: stretch; }
4085 .repo-ai-cta-label { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-right: 0; }
4086 }
4087 `,
4088 }}
4089 />
4090 <nav class="repo-ai-cta-row" aria-label="AI surfaces for this repository">
4091 <span class="repo-ai-cta-label">AI surfaces</span>
4092 <a class="repo-ai-cta" href={`/${owner}/${repo}/chat`} title="Rubber-duck chat grounded in this repo">
4093 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F4AC}"}</span>
4094 Chat
4095 </a>
4096 <a class="repo-ai-cta" href={`/${owner}/${repo}/previews`} title="Ephemeral preview URLs per branch">
4097 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F30D}"}</span>
4098 Previews
4099 </a>
4100 <a class="repo-ai-cta" href={`/${owner}/${repo}/migrations/propose`} title="AI proposes the Drizzle migration for a schema change">
4101 <span class="repo-ai-cta-icon" aria-hidden="true">{"⛁"}</span>
4102 Migrations
4103 </a>
53c9249Claude4104 <a class="repo-ai-cta" href={`/${owner}/${repo}/search?mode=semantic`} title="AI-powered semantic code search — ask in plain English">
c6018a5Claude4105 <span class="repo-ai-cta-icon" aria-hidden="true">{"✨"}</span>
53c9249Claude4106 AI Search
c6018a5Claude4107 </a>
4108 <a class="repo-ai-cta" href={`/${owner}/${repo}/releases/new`} title="Draft release notes with AI">
4109 <span class="repo-ai-cta-icon" aria-hidden="true">{"\u{1F3F7}"}</span>
4110 AI release notes
4111 </a>
3646bfeClaude4112 <a class="repo-ai-cta" href={`/${owner}/${repo}/dev`} title="Open a hosted VS Code dev environment in the browser">
4113 <span class="repo-ai-cta-icon" aria-hidden="true">{"💻"}</span>
4114 Dev environment
4115 </a>
c6018a5Claude4116 </nav>
544d842Claude4117 <div class="repo-home-grid">
4118 <div class="repo-home-main">
4119 <BranchSwitcher
4120 owner={owner}
4121 repo={repo}
4122 currentRef={defaultBranch}
4123 branches={branches}
4124 pathType="tree"
4125 />
4126 <FileTable
4127 entries={tree}
4128 owner={owner}
4129 repo={repo}
4130 ref={defaultBranch}
4131 path=""
4132 />
ebaae0fClaude4133 {/* AI stats strip — one-line summary of AI value for this repo */}
4134 {(aiStats.mergedCount > 0 || aiStats.reviewCount > 0 || aiStats.securityAlertCount > 0) ? (
4135 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4136 <span>{"⚡"}</span>
4137 {aiStats.mergedCount > 0 ? (
4138 <a href={`/${owner}/${repo}/pulls?state=merged`}>
4139 AI merged {aiStats.mergedCount} PR{aiStats.mergedCount === 1 ? "" : "s"} this week
4140 </a>
4141 ) : (
4142 <span>0 AI merges this week</span>
4143 )}
4144 <span class="repo-ai-stats-sep">{"·"}</span>
4145 <span>Saved ~{aiStats.hoursSaved} hrs</span>
4146 <span class="repo-ai-stats-sep">{"·"}</span>
4147 {aiStats.securityAlertCount > 0 ? (
4148 <a href={`/${owner}/${repo}/issues?label=security`}>
4149 {aiStats.securityAlertCount} open security alert{aiStats.securityAlertCount === 1 ? "" : "s"}
4150 </a>
4151 ) : (
4152 <span>0 open security alerts</span>
4153 )}
4154 </div>
4155 ) : (
4156 <div class="repo-ai-stats-strip" aria-label="AI activity summary for this week">
4157 <span>{"⚡"}</span>
4158 <span>AI is watching — no activity this week</span>
4159 </div>
4160 )}
544d842Claude4161 {readme && (() => {
4162 const readmeHtml = renderMarkdown(readme);
4163 return (
4164 <div class="repo-home-readme">
4165 <div class="repo-home-readme-head">
4166 <span class="repo-home-readme-icon">{"☰"}</span>
4167 <span>README.md</span>
4168 </div>
4169 <style>{markdownCss}</style>
4170 <div class="markdown-body repo-home-readme-body">
4171 {html([readmeHtml] as unknown as TemplateStringsArray)}
4172 </div>
4173 </div>
4174 );
4175 })()}
4176 </div>
4177 <aside class="repo-home-side" aria-label="Repository details">
4178 <div class="repo-home-clone">
4179 <div class="repo-home-clone-tabs" role="tablist" aria-label="Clone protocol">
4180 <button
4181 type="button"
4182 class="repo-home-clone-tab"
4183 role="tab"
4184 aria-selected="true"
4185 data-pane="https"
4186 data-repo-home-clone-tab
4187 >
4188 HTTPS
4189 </button>
4190 <button
4191 type="button"
4192 class="repo-home-clone-tab"
4193 role="tab"
4194 aria-selected="false"
4195 data-pane="ssh"
4196 data-repo-home-clone-tab
4197 >
4198 SSH
4199 </button>
4200 <button
4201 type="button"
4202 class="repo-home-clone-tab"
4203 role="tab"
4204 aria-selected="false"
4205 data-pane="cli"
4206 data-repo-home-clone-tab
4207 >
4208 CLI
4209 </button>
4210 </div>
4211 <div
4212 class="repo-home-clone-pane"
4213 data-pane="https"
4214 data-active="true"
4215 role="tabpanel"
4216 >
4217 <div class="repo-home-clone-body">
4218 <input
4219 class="repo-home-clone-input"
4220 type="text"
4221 value={cloneHttpsUrl}
4222 readonly
4223 aria-label="HTTPS clone URL"
4224 data-repo-home-clone-input
4225 />
4226 <button
4227 type="button"
4228 class="repo-home-clone-copy"
4229 data-repo-home-copy={cloneHttpsUrl}
4230 >
4231 Copy
4232 </button>
4233 </div>
4234 </div>
4235 <div
4236 class="repo-home-clone-pane"
4237 data-pane="ssh"
4238 data-active="false"
4239 role="tabpanel"
4240 >
4241 <div class="repo-home-clone-body">
4242 <input
4243 class="repo-home-clone-input"
4244 type="text"
4245 value={cloneSshUrl}
4246 readonly
4247 aria-label="SSH clone URL"
4248 data-repo-home-clone-input
4249 />
4250 <button
4251 type="button"
4252 class="repo-home-clone-copy"
4253 data-repo-home-copy={cloneSshUrl}
4254 >
4255 Copy
4256 </button>
4257 </div>
4258 </div>
4259 <div
4260 class="repo-home-clone-pane"
4261 data-pane="cli"
4262 data-active="false"
4263 role="tabpanel"
4264 >
4265 <div class="repo-home-clone-body">
4266 <input
4267 class="repo-home-clone-input"
4268 type="text"
4269 value={cloneCliCmd}
4270 readonly
4271 aria-label="Gluecron CLI clone command"
4272 data-repo-home-clone-input
4273 />
4274 <button
4275 type="button"
4276 class="repo-home-clone-copy"
4277 data-repo-home-copy={cloneCliCmd}
4278 >
4279 Copy
4280 </button>
4281 </div>
79136bbClaude4282 </div>
fc1817aClaude4283 </div>
544d842Claude4284 <div class="repo-home-side-card">
4285 <h3 class="repo-home-side-title">About</h3>
4286 <div class="repo-home-side-row">
4287 <span class="repo-home-side-key">Default branch</span>
4288 <span class="repo-home-side-val">{defaultBranch}</span>
4289 </div>
4290 <div class="repo-home-side-row">
4291 <span class="repo-home-side-key">Branches</span>
4292 <span class="repo-home-side-val">
4293 <a href={`/${owner}/${repo}/commits/${defaultBranch}`}>
4294 {branches.length}
4295 </a>
4296 </span>
4297 </div>
4298 <div class="repo-home-side-row">
4299 <span class="repo-home-side-key">Stars</span>
4300 <span class="repo-home-side-val">{starCount}</span>
4301 </div>
4302 <div class="repo-home-side-row">
4303 <span class="repo-home-side-key">Forks</span>
4304 <span class="repo-home-side-val">{forkCount}</span>
4305 </div>
4306 {pushedAt && (
4307 <div class="repo-home-side-row">
4308 <span class="repo-home-side-key">Last push</span>
4309 <span class="repo-home-side-val">
4310 {formatRelative(pushedAt)}
4311 </span>
4312 </div>
4313 )}
4314 {createdAt && (
4315 <div class="repo-home-side-row">
4316 <span class="repo-home-side-key">Created</span>
4317 <span class="repo-home-side-val">
4318 {formatRelative(createdAt)}
4319 </span>
4320 </div>
4321 )}
4322 {(archived || isTemplate) && (
4323 <div class="repo-home-side-row">
4324 <span class="repo-home-side-key">State</span>
4325 <span class="repo-home-side-val">
4326 {archived ? "Archived" : "Template"}
4327 </span>
4328 </div>
4329 )}
4330 </div>
f1dc38bClaude4331 {/* Claude AI — quick-access card for authenticated users */}
4332 {user && (
4333 <div class="repo-home-side-card" style="margin-top:12px">
4334 <h3 class="repo-home-side-title" style="margin-bottom:10px">
4335 ✨ Claude AI
4336 </h3>
4337 <a
4338 href={`/${owner}/${repo}/claude`}
4339 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"
4340 >
4341 Claude Code sessions
4342 </a>
4343 <a
4344 href={`/${owner}/${repo}/ask`}
4345 style="display:block;color:#9ca3af;text-align:center;padding:6px 14px;border-radius:6px;text-decoration:none;font-size:13px;border:1px solid #1f2937"
4346 >
4347 Ask AI about this repo
4348 </a>
4349 </div>
4350 )}
544d842Claude4351 </aside>
4352 </div>
4353 <script
4354 dangerouslySetInnerHTML={{
4355 __html: `
4356 (function(){
4357 var tabs = document.querySelectorAll('[data-repo-home-clone-tab]');
4358 tabs.forEach(function(tab){
4359 tab.addEventListener('click', function(){
4360 var target = tab.getAttribute('data-pane');
4361 tabs.forEach(function(t){
4362 t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
4363 });
4364 var panes = document.querySelectorAll('.repo-home-clone-pane');
4365 panes.forEach(function(p){
4366 p.setAttribute('data-active', p.getAttribute('data-pane') === target ? 'true' : 'false');
4367 });
4368 });
4369 });
4370 var copyBtns = document.querySelectorAll('[data-repo-home-copy]');
4371 copyBtns.forEach(function(btn){
4372 btn.addEventListener('click', function(){
4373 var text = btn.getAttribute('data-repo-home-copy') || '';
4374 var done = function(){
4375 var prev = btn.textContent;
4376 btn.textContent = 'Copied';
4377 setTimeout(function(){ btn.textContent = prev; }, 1200);
4378 };
4379 if (navigator.clipboard && navigator.clipboard.writeText) {
4380 navigator.clipboard.writeText(text).then(done, done);
4381 } else {
4382 var ta = document.createElement('textarea');
4383 ta.value = text;
4384 document.body.appendChild(ta);
4385 ta.select();
4386 try { document.execCommand('copy'); } catch (e) {}
4387 document.body.removeChild(ta);
4388 done();
4389 }
4390 });
4391 });
4392 })();
4393 `,
4394 }}
4395 />
fc1817aClaude4396 </Layout>
4397 );
4398});
4399
4400// Browse tree at ref/path
4401web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => {
4402 const { owner, repo } = c.req.param();
06d5ffeClaude4403 const user = c.get("user");
5bb52faccanty labs4404 const gate = await assertRepoReadable(c, owner, repo);
4405 if (gate) return gate;
fc1817aClaude4406 const refAndPath = c.req.param("ref");
4407
4408 const branches = await listBranches(owner, repo);
4409 let ref = "";
4410 let treePath = "";
4411
4412 for (const branch of branches) {
4413 if (refAndPath === branch || refAndPath.startsWith(branch + "/")) {
4414 ref = branch;
4415 treePath = refAndPath.slice(branch.length + 1);
4416 break;
4417 }
4418 }
4419
4420 if (!ref) {
4421 const slashIdx = refAndPath.indexOf("/");
4422 if (slashIdx === -1) {
4423 ref = refAndPath;
4424 } else {
4425 ref = refAndPath.slice(0, slashIdx);
4426 treePath = refAndPath.slice(slashIdx + 1);
4427 }
4428 }
4429
4430 const tree = await getTree(owner, repo, ref, treePath);
efb11c5Claude4431 const fileCount = tree.filter((e: any) => e.type !== "tree").length;
4432 const dirCount = tree.filter((e: any) => e.type === "tree").length;
fc1817aClaude4433
4434 return c.html(
06d5ffeClaude4435 <Layout title={`${treePath || "/"} — ${owner}/${repo}`} user={user}>
efb11c5Claude4436 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4437 <RepoHeader owner={owner} repo={repo} />
4438 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4439 <div class="tree-header">
4440 <div class="tree-header-row">
4441 <BranchSwitcher
4442 owner={owner}
4443 repo={repo}
4444 currentRef={ref}
4445 branches={branches}
4446 pathType="tree"
4447 subPath={treePath}
4448 />
4449 <div class="tree-header-stats">
4450 <span class="tree-stat" title="Entries in this directory">
4451 <strong>{fileCount}</strong> file{fileCount === 1 ? "" : "s"}
4452 {dirCount > 0 && (
4453 <>
4454 {" · "}
4455 <strong>{dirCount}</strong> dir{dirCount === 1 ? "" : "s"}
4456 </>
4457 )}
4458 </span>
4459 <a
4460 href={`/${owner}/${repo}/search`}
4461 class="tree-stat tree-stat-link"
4462 title="Search code in this repository"
4463 >
4464 {"⌕"} Search
4465 </a>
4466 </div>
4467 </div>
4468 <div class="tree-breadcrumb-row">
4469 <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} />
4470 </div>
4471 </div>
fc1817aClaude4472 <FileTable
4473 entries={tree}
4474 owner={owner}
4475 repo={repo}
4476 ref={ref}
4477 path={treePath}
4478 />
4479 </Layout>
4480 );
4481});
4482
06d5ffeClaude4483// View file blob with syntax highlighting
fc1817aClaude4484web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => {
4485 const { owner, repo } = c.req.param();
06d5ffeClaude4486 const user = c.get("user");
5bb52faccanty labs4487 const gate = await assertRepoReadable(c, owner, repo);
4488 if (gate) return gate;
fc1817aClaude4489 const refAndPath = c.req.param("ref");
4490
4491 const branches = await listBranches(owner, repo);
4492 let ref = "";
4493 let filePath = "";
4494
4495 for (const branch of branches) {
4496 if (refAndPath.startsWith(branch + "/")) {
4497 ref = branch;
4498 filePath = refAndPath.slice(branch.length + 1);
4499 break;
4500 }
4501 }
4502
4503 if (!ref) {
4504 const slashIdx = refAndPath.indexOf("/");
4505 if (slashIdx === -1) return c.text("Not found", 404);
4506 ref = refAndPath.slice(0, slashIdx);
4507 filePath = refAndPath.slice(slashIdx + 1);
4508 }
4509
4510 const blob = await getBlob(owner, repo, ref, filePath);
4511 if (!blob) {
4512 return c.html(
06d5ffeClaude4513 <Layout title="Not Found" user={user}>
fc1817aClaude4514 <div class="empty-state">
4515 <h2>File not found</h2>
4516 </div>
4517 </Layout>,
4518 404
4519 );
4520 }
4521
06d5ffeClaude4522 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude4523 const lineCount = blob.isBinary
4524 ? 0
4525 : (blob.content.endsWith("\n")
4526 ? blob.content.split("\n").length - 1
4527 : blob.content.split("\n").length);
4528 const formatBytes = (n: number): string => {
4529 if (n < 1024) return `${n} B`;
4530 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
4531 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
4532 };
fc1817aClaude4533
4534 return c.html(
06d5ffeClaude4535 <Layout title={`${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude4536 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude4537 <RepoHeader owner={owner} repo={repo} />
4538 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude4539 <div class="blob-toolbar">
4540 <BranchSwitcher
4541 owner={owner}
4542 repo={repo}
4543 currentRef={ref}
4544 branches={branches}
4545 pathType="blob"
4546 subPath={filePath}
4547 />
4548 <div class="blob-breadcrumb">
4549 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
4550 </div>
4551 </div>
4552 <div class="blob-view blob-card">
4553 <div class="blob-header blob-header-polished">
4554 <div class="blob-header-meta">
4555 <span class="blob-header-icon" aria-hidden="true">
4556 {"📄"}
4557 </span>
4558 <span class="blob-header-name">{fileName}</span>
4559 <span class="blob-header-size">
4560 {formatBytes(blob.size)}
4561 {!blob.isBinary && (
4562 <>
4563 {" · "}
4564 {lineCount} line{lineCount === 1 ? "" : "s"}
4565 </>
4566 )}
4567 </span>
4568 </div>
4569 <div class="blob-header-actions">
4570 <a
4571 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
4572 class="blob-pill"
4573 >
79136bbClaude4574 Raw
4575 </a>
efb11c5Claude4576 <a
4577 href={`/${owner}/${repo}/blame/${ref}/${filePath}`}
4578 class="blob-pill"
4579 >
79136bbClaude4580 Blame
4581 </a>
efb11c5Claude4582 <a
4583 href={`/${owner}/${repo}/timeline/${ref}/${filePath}`}
4584 class="blob-pill"
4585 >
16b325cClaude4586 History
4587 </a>
0074234Claude4588 {user && (
efb11c5Claude4589 <a
4590 href={`/${owner}/${repo}/edit/${ref}/${filePath}`}
4591 class="blob-pill blob-pill-accent"
4592 >
0074234Claude4593 Edit
4594 </a>
4595 )}
efb11c5Claude4596 </div>
fc1817aClaude4597 </div>
4598 {blob.isBinary ? (
efb11c5Claude4599 <div class="blob-binary">
fc1817aClaude4600 Binary file not shown.
4601 </div>
06d5ffeClaude4602 ) : (() => {
4603 const { html: highlighted, language } = highlightCode(
4604 blob.content,
4605 fileName
4606 );
4607 if (language) {
4608 return (
4609 <HighlightedCode
4610 highlightedHtml={highlighted}
efb11c5Claude4611 lineCount={lineCount}
06d5ffeClaude4612 />
4613 );
4614 }
4615 const lines = blob.content.split("\n");
4616 if (lines[lines.length - 1] === "") lines.pop();
4617 return <PlainCode lines={lines} />;
4618 })()}
fc1817aClaude4619 </div>
4620 </Layout>
4621 );
4622});
4623
398a10cClaude4624// ─── Branches list ────────────────────────────────────────────────────────
4625// Lightweight `git for-each-ref` enrichment so each row shows last-commit
4626// author + relative time + ahead/behind vs the default branch. No DB. All
4627// data comes from git plumbing; failures degrade gracefully (counts omitted).
4628web.get("/:owner/:repo/branches", async (c) => {
4629 const { owner, repo } = c.req.param();
4630 const user = c.get("user");
5bb52faccanty labs4631 const gate = await assertRepoReadable(c, owner, repo);
4632 if (gate) return gate;
398a10cClaude4633
4634 if (!(await repoExists(owner, repo))) return c.notFound();
4635
4636 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4637 const branches = await listBranches(owner, repo);
4638 const repoDir = getRepoPath(owner, repo);
4639
4640 type BranchRow = {
4641 name: string;
4642 isDefault: boolean;
4643 sha: string;
4644 subject: string;
4645 author: string;
4646 date: string;
4647 ahead: number;
4648 behind: number;
4649 };
4650
4651 const runGit = async (args: string[]): Promise<string> => {
4652 try {
4653 const proc = Bun.spawn(["git", ...args], {
4654 cwd: repoDir,
4655 stdout: "pipe",
4656 stderr: "pipe",
4657 });
4658 const out = await new Response(proc.stdout).text();
4659 await proc.exited;
4660 return out;
4661 } catch {
4662 return "";
4663 }
4664 };
4665
4666 const meta = await runGit([
4667 "for-each-ref",
4668 "--sort=-committerdate",
4669 "--format=%(refname:short)%00%(objectname)%00%(subject)%00%(authorname)%00%(committerdate:iso-strict)",
4670 "refs/heads/",
4671 ]);
4672 const metaByName: Record<
4673 string,
4674 { sha: string; subject: string; author: string; date: string }
4675 > = {};
4676 for (const line of meta.split("\n").filter(Boolean)) {
4677 const [name, sha, subject, author, date] = line.split("\0");
4678 metaByName[name] = { sha, subject, author, date };
4679 }
4680
4681 const branchOrder = [...branches].sort((a, b) => {
4682 if (a === defaultBranch) return -1;
4683 if (b === defaultBranch) return 1;
4684 const aDate = metaByName[a]?.date || "";
4685 const bDate = metaByName[b]?.date || "";
4686 return bDate.localeCompare(aDate);
4687 });
4688
4689 const rows: BranchRow[] = [];
4690 for (const name of branchOrder) {
4691 const m = metaByName[name] || { sha: "", subject: "", author: "", date: "" };
4692 let ahead = 0;
4693 let behind = 0;
4694 if (name !== defaultBranch && metaByName[defaultBranch]) {
4695 const out = await runGit([
4696 "rev-list",
4697 "--left-right",
4698 "--count",
4699 `${defaultBranch}...${name}`,
4700 ]);
4701 const parts = out.trim().split(/\s+/);
4702 if (parts.length === 2) {
4703 behind = parseInt(parts[0], 10) || 0;
4704 ahead = parseInt(parts[1], 10) || 0;
4705 }
4706 }
4707 rows.push({
4708 name,
4709 isDefault: name === defaultBranch,
4710 sha: m.sha,
4711 subject: m.subject,
4712 author: m.author,
4713 date: m.date,
4714 ahead,
4715 behind,
4716 });
4717 }
4718
4719 const relative = (iso: string): string => {
4720 if (!iso) return "—";
4721 const d = new Date(iso);
4722 if (Number.isNaN(d.getTime())) return "—";
4723 const diff = Date.now() - d.getTime();
4724 const m = Math.floor(diff / 60000);
4725 if (m < 1) return "just now";
4726 if (m < 60) return `${m} min ago`;
4727 const h = Math.floor(m / 60);
4728 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
4729 const dd = Math.floor(h / 24);
4730 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
4731 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
4732 };
4733
4734 const success = c.req.query("success");
4735 const error = c.req.query("error");
4736
4737 return c.html(
4738 <Layout title={`Branches — ${owner}/${repo}`} user={user}>
4739 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
4740 <RepoHeader owner={owner} repo={repo} />
4741 <RepoNav owner={owner} repo={repo} active="code" />
4742 <div
4743 class="branches-wrap"
eed4684Claude4744 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude4745 >
4746 <header class="branches-head" style="margin-bottom:var(--space-5)">
4747 <div
4748 class="branches-eyebrow"
4749 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"
4750 >
4751 <span
4752 class="branches-eyebrow-dot"
4753 aria-hidden="true"
6fd5915Claude4754 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)"
398a10cClaude4755 />
4756 Repository · Branches
4757 </div>
4758 <h1
4759 class="branches-title"
4760 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)"
4761 >
4762 <span class="gradient-text">{rows.length}</span>{" "}
4763 branch{rows.length === 1 ? "" : "es"}
4764 </h1>
4765 <p
4766 class="branches-sub"
4767 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
4768 >
4769 All work-in-progress lines for{" "}
4770 <code style="font-size:12.5px">{owner}/{repo}</code>. Ahead/behind
4771 counts are relative to{" "}
4772 <code style="font-size:12.5px">{defaultBranch}</code>.
4773 </p>
4774 </header>
4775
4776 {success && (
4777 <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">
4778 {decodeURIComponent(success)}
4779 </div>
4780 )}
4781 {error && (
4782 <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">
4783 {decodeURIComponent(error)}
4784 </div>
4785 )}
4786
4787 {rows.length === 0 ? (
4788 <div class="commits-empty">
4789 <div class="commits-empty-orb" aria-hidden="true" />
4790 <div class="commits-empty-inner">
4791 <div class="commits-empty-icon" aria-hidden="true">
4792 <svg
4793 width="22"
4794 height="22"
4795 viewBox="0 0 24 24"
4796 fill="none"
4797 stroke="currentColor"
4798 stroke-width="2"
4799 stroke-linecap="round"
4800 stroke-linejoin="round"
4801 >
4802 <line x1="6" y1="3" x2="6" y2="15" />
4803 <circle cx="18" cy="6" r="3" />
4804 <circle cx="6" cy="18" r="3" />
4805 <path d="M18 9a9 9 0 0 1-9 9" />
4806 </svg>
4807 </div>
4808 <h3 class="commits-empty-title">No branches yet</h3>
4809 <p class="commits-empty-sub">
4810 Push your first commit to create the default branch.
4811 </p>
4812 </div>
4813 </div>
4814 ) : (
4815 <div class="branches-list">
4816 {rows.map((r) => (
4817 <div class="branches-row">
4818 <div class="branches-row-icon" aria-hidden="true">
4819 <svg
4820 width="14"
4821 height="14"
4822 viewBox="0 0 24 24"
4823 fill="none"
4824 stroke="currentColor"
4825 stroke-width="2"
4826 stroke-linecap="round"
4827 stroke-linejoin="round"
4828 >
4829 <line x1="6" y1="3" x2="6" y2="15" />
4830 <circle cx="18" cy="6" r="3" />
4831 <circle cx="6" cy="18" r="3" />
4832 <path d="M18 9a9 9 0 0 1-9 9" />
4833 </svg>
4834 </div>
4835 <div class="branches-row-main">
4836 <div class="branches-row-name">
4837 <a href={`/${owner}/${repo}/tree/${r.name}`}>{r.name}</a>
4838 {r.isDefault && (
4839 <span
4840 class="branches-row-default"
4841 title="Default branch"
4842 >
4843 Default
4844 </span>
4845 )}
4846 </div>
4847 <div class="branches-row-meta">
4848 {r.subject ? (
4849 <>
4850 <strong>{r.author || "—"}</strong>
4851 <span class="sep">·</span>
4852 <span
4853 title={r.date ? new Date(r.date).toISOString() : ""}
4854 >
4855 updated {relative(r.date)}
4856 </span>
4857 {r.sha && (
4858 <>
4859 <span class="sep">·</span>
4860 <a
4861 href={`/${owner}/${repo}/commit/${r.sha}`}
4862 style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);text-decoration:none"
4863 >
4864 {r.sha.slice(0, 7)}
4865 </a>
4866 </>
4867 )}
4868 </>
4869 ) : (
4870 <span>No commit metadata</span>
4871 )}
4872 </div>
4873 </div>
4874 <div class="branches-row-side">
4875 {!r.isDefault && (r.ahead > 0 || r.behind > 0) && (
4876 <span
4877 class="branches-row-divergence"
4878 title={`${r.ahead} ahead, ${r.behind} behind ${defaultBranch}`}
4879 >
4880 <span class="ahead">{r.ahead} ahead</span>
4881 <span style="opacity:0.4">|</span>
4882 <span class="behind">{r.behind} behind</span>
4883 </span>
4884 )}
4885 <div class="branches-row-actions">
4886 <a
4887 href={`/${owner}/${repo}/commits/${r.name}`}
4888 class="branches-btn"
4889 title="View commits on this branch"
4890 >
4891 Commits
4892 </a>
4893 {!r.isDefault &&
4894 user &&
4895 user.username === owner && (
4896 <form
4897 method="post"
4898 action={`/${owner}/${repo}/branches/${encodeURIComponent(r.name)}/delete`}
4899 style="margin:0"
4900 onsubmit={`return confirm('Delete branch \\'${r.name}\\'? This cannot be undone.')`}
4901 >
4902 <button
4903 type="submit"
4904 class="branches-btn branches-btn-danger"
4905 >
4906 Delete
4907 </button>
4908 </form>
4909 )}
4910 </div>
4911 </div>
4912 </div>
4913 ))}
4914 </div>
4915 )}
4916 </div>
4917 </Layout>
4918 );
4919});
4920
4921// Delete a branch (owner only). Uses `git branch -D` so we can drop refs
4922// that are not merged into the default branch — matches the explicit
4923// confirmation on the row's delete button.
4924web.post("/:owner/:repo/branches/:name/delete", requireAuth, async (c) => {
4925 const { owner, repo } = c.req.param();
4926 const branchName = decodeURIComponent(c.req.param("name"));
4927 const user = c.get("user")!;
4928
4929 // Owner-only check (mirrors collaborators.tsx pattern).
4930 const [ownerRow] = await db
4931 .select()
4932 .from(users)
4933 .where(eq(users.username, owner))
4934 .limit(1);
4935 if (!ownerRow || ownerRow.id !== user.id) {
4936 return c.redirect(
4937 `/${owner}/${repo}/branches?error=Only+the+owner+can+delete+branches`
4938 );
4939 }
4940
4941 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
4942 if (branchName === defaultBranch) {
4943 return c.redirect(
4944 `/${owner}/${repo}/branches?error=Cannot+delete+the+default+branch`
4945 );
4946 }
4947
4948 const branches = await listBranches(owner, repo);
4949 if (!branches.includes(branchName)) {
4950 return c.redirect(
4951 `/${owner}/${repo}/branches?error=Branch+not+found`
4952 );
4953 }
4954
4955 try {
4956 const repoDir = getRepoPath(owner, repo);
4957 const proc = Bun.spawn(["git", "branch", "-D", branchName], {
4958 cwd: repoDir,
4959 stdout: "pipe",
4960 stderr: "pipe",
4961 });
4962 await proc.exited;
4963 if (proc.exitCode !== 0) {
4964 return c.redirect(
4965 `/${owner}/${repo}/branches?error=Delete+failed`
4966 );
4967 }
4968 } catch {
4969 return c.redirect(`/${owner}/${repo}/branches?error=Delete+failed`);
4970 }
4971
4972 return c.redirect(`/${owner}/${repo}/branches?success=Branch+deleted`);
4973});
4974
4975// ─── Tags list ────────────────────────────────────────────────────────────
4976// Pulls from `listTags` (sorted newest first). For each tag we look up an
4977// associated release row so the "View release" CTA can deep-link directly
4978// without making the user hunt for it.
4979web.get("/:owner/:repo/tags", async (c) => {
4980 const { owner, repo } = c.req.param();
4981 const user = c.get("user");
5bb52faccanty labs4982 const gate = await assertRepoReadable(c, owner, repo);
4983 if (gate) return gate;
398a10cClaude4984
4985 if (!(await repoExists(owner, repo))) return c.notFound();
4986
4987 const tags = await listTags(owner, repo);
4988
4989 // Map tags -> releases. Best-effort; releases table may not exist in
4990 // every test setup, so any error falls through with an empty set.
4991 const tagsWithReleases = new Set<string>();
4992 try {
4993 const [ownerRow] = await db
4994 .select()
4995 .from(users)
4996 .where(eq(users.username, owner))
4997 .limit(1);
4998 if (ownerRow) {
4999 const [repoRow] = await db
5000 .select()
5001 .from(repositories)
5002 .where(
5003 and(
5004 eq(repositories.ownerId, ownerRow.id),
5005 eq(repositories.name, repo)
5006 )
5007 )
5008 .limit(1);
5009 if (repoRow) {
5010 // Raw SQL so we don't need to import the releases schema here.
5011 const result = await db.execute(
5012 sql`SELECT tag FROM releases WHERE repository_id = ${repoRow.id}`
5013 );
5014 const rows: any[] = (result as any).rows || (result as any) || [];
5015 for (const row of rows) {
5016 const tag = row?.tag;
5017 if (typeof tag === "string") tagsWithReleases.add(tag);
5018 }
5019 }
5020 }
5021 } catch {
5022 // No releases table or DB error — leave set empty.
5023 }
5024
5025 const relative = (iso: string): string => {
5026 if (!iso) return "—";
5027 const d = new Date(iso);
5028 if (Number.isNaN(d.getTime())) return "—";
5029 const diff = Date.now() - d.getTime();
5030 const m = Math.floor(diff / 60000);
5031 if (m < 1) return "just now";
5032 if (m < 60) return `${m} min ago`;
5033 const h = Math.floor(m / 60);
5034 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5035 const dd = Math.floor(h / 24);
5036 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5037 return d.toLocaleDateString("en-US", {
5038 month: "short",
5039 day: "numeric",
5040 year: "numeric",
5041 });
5042 };
5043
5044 return c.html(
5045 <Layout title={`Tags — ${owner}/${repo}`} user={user}>
5046 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
5047 <RepoHeader owner={owner} repo={repo} />
5048 <RepoNav owner={owner} repo={repo} active="code" />
5049 <div
5050 class="tags-wrap"
eed4684Claude5051 style="max-width:1680px;margin:0 auto;padding:var(--space-5) var(--space-4) var(--space-8)"
398a10cClaude5052 >
5053 <header class="tags-head" style="margin-bottom:var(--space-5)">
5054 <div
5055 class="tags-eyebrow"
5056 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"
5057 >
5058 <span
5059 class="tags-eyebrow-dot"
5060 aria-hidden="true"
6fd5915Claude5061 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)"
398a10cClaude5062 />
5063 Repository · Tags
5064 </div>
5065 <h1
5066 class="tags-title"
5067 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)"
5068 >
5069 <span class="gradient-text">{tags.length}</span>{" "}
5070 tag{tags.length === 1 ? "" : "s"}
5071 </h1>
5072 <p
5073 class="tags-sub"
5074 style="margin:0;font-size:14px;color:var(--text-muted);line-height:1.5;max-width:700px"
5075 >
5076 Named points in the history — typically releases, milestones,
5077 or shipped versions. Click a tag to browse the tree at that
5078 revision.
5079 </p>
5080 </header>
5081
5082 {tags.length === 0 ? (
5083 <div class="commits-empty">
5084 <div class="commits-empty-orb" aria-hidden="true" />
5085 <div class="commits-empty-inner">
5086 <div class="commits-empty-icon" aria-hidden="true">
5087 <svg
5088 width="22"
5089 height="22"
5090 viewBox="0 0 24 24"
5091 fill="none"
5092 stroke="currentColor"
5093 stroke-width="2"
5094 stroke-linecap="round"
5095 stroke-linejoin="round"
5096 >
5097 <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" />
5098 <line x1="7" y1="7" x2="7.01" y2="7" />
5099 </svg>
5100 </div>
5101 <h3 class="commits-empty-title">No tags yet</h3>
5102 <p class="commits-empty-sub">
5103 Tag a commit to mark a release or milestone. From the CLI:{" "}
5104 <code>git tag v0.1.0 &amp;&amp; git push --tags</code>.
5105 </p>
5106 </div>
5107 </div>
5108 ) : (
5109 <div class="tags-list">
5110 {tags.map((t) => {
5111 const hasRelease = tagsWithReleases.has(t.name);
5112 return (
5113 <div class="tags-row">
5114 <div class="tags-row-icon" aria-hidden="true">
5115 <svg
5116 width="14"
5117 height="14"
5118 viewBox="0 0 24 24"
5119 fill="none"
5120 stroke="currentColor"
5121 stroke-width="2"
5122 stroke-linecap="round"
5123 stroke-linejoin="round"
5124 >
5125 <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" />
5126 <line x1="7" y1="7" x2="7.01" y2="7" />
5127 </svg>
5128 </div>
5129 <div class="tags-row-main">
5130 <div class="tags-row-name">
5131 <a
5132 href={`/${owner}/${repo}/tree/${t.name}`}
5133 style="text-decoration:none"
5134 >
5135 <span class="tags-row-version">{t.name}</span>
5136 </a>
5137 </div>
5138 <div class="tags-row-meta">
5139 <span
5140 title={t.date ? new Date(t.date).toISOString() : ""}
5141 >
5142 Tagged {relative(t.date)}
5143 </span>
5144 <span class="sep">·</span>
5145 <a
5146 href={`/${owner}/${repo}/commit/${t.sha}`}
5147 class="tags-row-sha"
5148 title={t.sha}
5149 >
5150 {t.sha.slice(0, 7)}
5151 </a>
5152 </div>
5153 </div>
5154 <div class="tags-row-side">
5155 <a
5156 href={`/${owner}/${repo}/tree/${t.name}`}
5157 class="tags-row-link"
5158 >
5159 Browse files
5160 </a>
5161 {hasRelease && (
5162 <a
5163 href={`/${owner}/${repo}/releases/tag/${t.name}`}
5164 class="tags-row-link"
6fd5915Claude5165 style="color:#67e8f9;border-color:rgba(95,143,160,0.35);background:rgba(95,143,160,0.06)"
398a10cClaude5166 >
5167 View release
5168 </a>
5169 )}
5170 </div>
5171 </div>
5172 );
5173 })}
5174 </div>
5175 )}
5176 </div>
5177 </Layout>
5178 );
5179});
5180
fc1817aClaude5181// Commit log
5182web.get("/:owner/:repo/commits/:ref?", async (c) => {
5183 const { owner, repo } = c.req.param();
06d5ffeClaude5184 const user = c.get("user");
5bb52faccanty labs5185 const gate = await assertRepoReadable(c, owner, repo);
5186 if (gate) return gate;
fc1817aClaude5187 const ref =
5188 c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main";
06d5ffeClaude5189 const branches = await listBranches(owner, repo);
fc1817aClaude5190
5191 const commits = await listCommits(owner, repo, ref, 50);
5192
3951454Claude5193 // Block J3 — batch-fetch cached verification results for the page.
8c790e0Claude5194 // Also resolve repoId here so we can query the recent push for the
5195 // Push Watch live/watch indicator in the header.
3951454Claude5196 let verifications: Record<string, { verified: boolean; reason: string }> = {};
8c790e0Claude5197 let commitsPageRecentPush: RecentPush | null = null;
3951454Claude5198 try {
5199 const [ownerRow] = await db
5200 .select()
5201 .from(users)
5202 .where(eq(users.username, owner))
5203 .limit(1);
5204 if (ownerRow) {
5205 const [repoRow] = await db
5206 .select()
5207 .from(repositories)
5208 .where(
5209 and(
5210 eq(repositories.ownerId, ownerRow.id),
5211 eq(repositories.name, repo)
5212 )
5213 )
5214 .limit(1);
8c790e0Claude5215 if (repoRow) {
5216 // Fetch verifications and recent push in parallel.
5217 const [verRows, rp] = await Promise.all([
5218 commits.length > 0
5219 ? db
5220 .select()
5221 .from(commitVerifications)
5222 .where(
5223 and(
5224 eq(commitVerifications.repositoryId, repoRow.id),
5225 inArray(
5226 commitVerifications.commitSha,
5227 commits.map((c) => c.sha)
5228 )
5229 )
5230 )
5231 : Promise.resolve([]),
5232 getRecentPush(repoRow.id),
5233 ]);
5234 for (const r of verRows) {
3951454Claude5235 verifications[r.commitSha] = {
5236 verified: r.verified,
5237 reason: r.reason,
5238 };
5239 }
8c790e0Claude5240 commitsPageRecentPush = rp;
3951454Claude5241 }
5242 }
5243 } catch {
5244 // DB unavailable — skip the badges gracefully.
5245 }
5246
fc1817aClaude5247 return c.html(
06d5ffeClaude5248 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
efb11c5Claude5249 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
8c790e0Claude5250 <RepoHeader owner={owner} repo={repo} recentPush={commitsPageRecentPush} />
fc1817aClaude5251 <RepoNav owner={owner} repo={repo} active="commits" />
efb11c5Claude5252 <div class="commits-hero">
5253 <div class="commits-hero-orb-wrap" aria-hidden="true">
5254 <div class="commits-hero-orb" />
fc1817aClaude5255 </div>
efb11c5Claude5256 <div class="commits-hero-inner">
5257 <div class="commits-eyebrow">
5258 <strong>History</strong> · {owner}/{repo}
5259 </div>
5260 <h1 class="commits-title">
5261 <span class="gradient-text">{commits.length}</span> recent commit
5262 {commits.length === 1 ? "" : "s"} on{" "}
5263 <span class="commits-branch">{ref}</span>
5264 </h1>
5265 <p class="commits-sub">
5266 Browse the project's history. Click any commit to see the full
5267 diff, AI review notes, and signature status.
5268 </p>
5269 </div>
5270 </div>
5271 <div class="commits-toolbar">
5272 <BranchSwitcher
3951454Claude5273 owner={owner}
5274 repo={repo}
efb11c5Claude5275 currentRef={ref}
5276 branches={branches}
5277 pathType="commits"
3951454Claude5278 />
398a10cClaude5279 <div class="commits-toolbar-actions">
5280 <a href={`/${owner}/${repo}/branches`} class="commits-toolbar-link">
5281 {"⊢"} Branches
5282 </a>
5283 <a href={`/${owner}/${repo}/tags`} class="commits-toolbar-link">
5284 {"#"} Tags
5285 </a>
5286 </div>
efb11c5Claude5287 </div>
5288 {commits.length === 0 ? (
5289 <div class="commits-empty">
398a10cClaude5290 <div class="commits-empty-orb" aria-hidden="true" />
5291 <div class="commits-empty-inner">
5292 <div class="commits-empty-icon" aria-hidden="true">
5293 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5294 <circle cx="12" cy="12" r="4" />
5295 <line x1="1.05" y1="12" x2="7" y2="12" />
5296 <line x1="17.01" y1="12" x2="22.96" y2="12" />
5297 </svg>
5298 </div>
5299 <h3 class="commits-empty-title">No commits yet</h3>
5300 <p class="commits-empty-sub">
5301 This branch is empty. Push your first commit, or use the
5302 web editor to create a file.
5303 </p>
5304 </div>
efb11c5Claude5305 </div>
5306 ) : (
5307 <div class="commits-list-wrap">
398a10cClaude5308 {(() => {
5309 // Group commits by day for the section headers.
5310 const dayLabel = (iso: string): string => {
5311 const d = new Date(iso);
5312 if (Number.isNaN(d.getTime())) return "Unknown";
5313 const today = new Date();
5314 const yesterday = new Date(today);
5315 yesterday.setDate(today.getDate() - 1);
5316 const sameDay = (a: Date, b: Date) =>
5317 a.getFullYear() === b.getFullYear() &&
5318 a.getMonth() === b.getMonth() &&
5319 a.getDate() === b.getDate();
5320 if (sameDay(d, today)) return "Today";
5321 if (sameDay(d, yesterday)) return "Yesterday";
5322 return d.toLocaleDateString("en-US", {
5323 month: "long",
5324 day: "numeric",
5325 year: today.getFullYear() === d.getFullYear() ? undefined : "numeric",
5326 });
5327 };
5328 const relative = (iso: string): string => {
5329 const d = new Date(iso);
5330 if (Number.isNaN(d.getTime())) return "";
5331 const diff = Date.now() - d.getTime();
5332 const m = Math.floor(diff / 60000);
5333 if (m < 1) return "just now";
5334 if (m < 60) return `${m} min ago`;
5335 const h = Math.floor(m / 60);
5336 if (h < 24) return `${h} hr${h === 1 ? "" : "s"} ago`;
5337 const dd = Math.floor(h / 24);
5338 if (dd < 30) return `${dd} day${dd === 1 ? "" : "s"} ago`;
5339 return d.toLocaleDateString("en-US", {
5340 month: "short",
5341 day: "numeric",
5342 });
5343 };
5344 const initial = (name: string): string =>
5345 (name || "?").trim().charAt(0).toUpperCase() || "?";
5346 // Build groups preserving order.
5347 const groups: Array<{ label: string; items: typeof commits }> = [];
5348 let lastLabel = "";
5349 for (const cm of commits) {
5350 const label = dayLabel(cm.date);
5351 if (label !== lastLabel) {
5352 groups.push({ label, items: [] });
5353 lastLabel = label;
5354 }
5355 groups[groups.length - 1].items.push(cm);
5356 }
5357 return groups.map((g) => (
5358 <>
5359 <div class="commits-day-head">
5360 <span class="commits-day-head-dot" aria-hidden="true" />
5361 Commits on {g.label}
5362 </div>
5363 {g.items.map((cm) => {
5364 const v = verifications[cm.sha];
5365 return (
5366 <div class="commits-row">
5367 <div class="commits-avatar" aria-hidden="true">
5368 {initial(cm.author)}
5369 </div>
5370 <div class="commits-row-body">
5371 <div class="commits-row-msg">
5372 <a href={`/${owner}/${repo}/commit/${cm.sha}`}>
5373 {cm.message}
5374 </a>
5375 {v?.verified && (
5376 <span
5377 class="commits-row-verified"
5378 title="Signed with a registered key"
5379 >
5380 Verified
5381 </span>
5382 )}
5383 </div>
5384 <div class="commits-row-meta">
5385 <strong>{cm.author}</strong>
5386 <span class="sep">·</span>
5387 <span
5388 class="commits-row-time"
5389 title={new Date(cm.date).toISOString()}
5390 >
5391 committed {relative(cm.date)}
5392 </span>
5393 {cm.parentShas.length > 1 && (
5394 <>
5395 <span class="sep">·</span>
5396 <span>merge of {cm.parentShas.length} parents</span>
5397 </>
5398 )}
5399 </div>
5400 </div>
5401 <div class="commits-row-side">
5402 <a
5403 href={`/${owner}/${repo}/commit/${cm.sha}`}
5404 class="commits-row-sha"
5405 title={cm.sha}
5406 >
5407 {cm.sha.slice(0, 7)}
5408 </a>
8c790e0Claude5409 <a
5410 href={`/${owner}/${repo}/push/${cm.sha}`}
5411 class="commits-row-watch"
5412 title="Watch gate + deploy results for this push"
5413 aria-label={`Watch push ${cm.sha.slice(0, 7)}`}
5414 >
5415 <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">
5416 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
5417 <circle cx="12" cy="12" r="3" />
5418 </svg>
5419 </a>
398a10cClaude5420 <button
5421 type="button"
5422 class="commits-row-copy"
5423 data-copy-sha={cm.sha}
5424 title="Copy full SHA"
5425 aria-label={`Copy SHA ${cm.sha}`}
5426 >
5427 <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
5428 <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" />
5429 <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" />
5430 </svg>
5431 </button>
5432 </div>
5433 </div>
5434 );
5435 })}
5436 </>
5437 ));
5438 })()}
efb11c5Claude5439 </div>
fc1817aClaude5440 )}
398a10cClaude5441 <script
5442 dangerouslySetInnerHTML={{
5443 __html: `
5444 (function(){
5445 document.addEventListener('click', function(e){
5446 var t = e.target; if (!t) return;
5447 var btn = t.closest && t.closest('[data-copy-sha]');
5448 if (!btn) return;
5449 e.preventDefault();
5450 var sha = btn.getAttribute('data-copy-sha') || '';
5451 if (!navigator.clipboard) return;
5452 navigator.clipboard.writeText(sha).then(function(){
5453 btn.classList.add('is-copied');
5454 setTimeout(function(){ btn.classList.remove('is-copied'); }, 1200);
5455 }).catch(function(){});
5456 });
5457 })();
5458 `,
5459 }}
5460 />
fc1817aClaude5461 </Layout>
5462 );
5463});
5464
5465// Single commit with diff
5466web.get("/:owner/:repo/commit/:sha", async (c) => {
5467 const { owner, repo, sha } = c.req.param();
06d5ffeClaude5468 const user = c.get("user");
5bb52faccanty labs5469 const gate = await assertRepoReadable(c, owner, repo);
5470 if (gate) return gate;
fc1817aClaude5471
05b973eClaude5472 // Fetch commit, full message, and diff in parallel
5473 const [commit, fullMessage, diffResult] = await Promise.all([
5474 getCommit(owner, repo, sha),
5475 getCommitFullMessage(owner, repo, sha),
5476 getDiff(owner, repo, sha),
5477 ]);
fc1817aClaude5478 if (!commit) {
5479 return c.html(
06d5ffeClaude5480 <Layout title="Not Found" user={user}>
fc1817aClaude5481 <div class="empty-state">
5482 <h2>Commit not found</h2>
5483 </div>
5484 </Layout>,
5485 404
5486 );
5487 }
5488
3951454Claude5489 // Block J3 — try to verify this commit's signature.
5490 let verification:
5491 | { verified: boolean; reason: string; signatureType: string | null }
5492 | null = null;
0cdfd89Claude5493 // Block J8 — external CI commit statuses rollup.
5494 let statusCombined:
5495 | {
5496 state: "pending" | "success" | "failure";
5497 total: number;
5498 contexts: Array<{
5499 context: string;
5500 state: string;
5501 description: string | null;
5502 targetUrl: string | null;
5503 }>;
5504 }
5505 | null = null;
3951454Claude5506 try {
5507 const [ownerRow] = await db
5508 .select()
5509 .from(users)
5510 .where(eq(users.username, owner))
5511 .limit(1);
5512 if (ownerRow) {
5513 const [repoRow] = await db
5514 .select()
5515 .from(repositories)
5516 .where(
5517 and(
5518 eq(repositories.ownerId, ownerRow.id),
5519 eq(repositories.name, repo)
5520 )
5521 )
5522 .limit(1);
5523 if (repoRow) {
5524 const { verifyCommit } = await import("../lib/signatures");
5525 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
5526 verification = {
5527 verified: v.verified,
5528 reason: v.reason,
5529 signatureType: v.signatureType,
5530 };
0cdfd89Claude5531 try {
5532 const { combinedStatus } = await import("../lib/commit-statuses");
5533 const combined = await combinedStatus(repoRow.id, commit.sha);
5534 if (combined.total > 0) {
5535 statusCombined = {
5536 state: combined.state as any,
5537 total: combined.total,
5538 contexts: combined.contexts.map((c) => ({
5539 context: c.context,
5540 state: c.state,
5541 description: c.description,
5542 targetUrl: c.targetUrl,
5543 })),
5544 };
5545 }
5546 } catch {
5547 statusCombined = null;
5548 }
3951454Claude5549 }
5550 }
5551 } catch {
5552 verification = null;
5553 }
5554
05b973eClaude5555 const { files, raw } = diffResult;
fc1817aClaude5556
efb11c5Claude5557 // Diff stats: count additions / deletions across all files for the
5558 // header summary bar. Computed here from the parsed diff so we don't
5559 // touch the DiffView component.
5560 let additions = 0;
5561 let deletions = 0;
5562 for (const f of files) {
5563 const hunks = (f as any).hunks as Array<any> | undefined;
5564 if (Array.isArray(hunks)) {
5565 for (const h of hunks) {
5566 const lines = (h?.lines || []) as Array<any>;
5567 for (const ln of lines) {
5568 const t = ln?.type || ln?.kind;
5569 if (t === "add" || t === "added" || t === "+") additions += 1;
5570 else if (t === "del" || t === "deleted" || t === "delete" || t === "-")
5571 deletions += 1;
5572 }
5573 }
5574 }
5575 }
5576 // Fall back: scan raw if file-level counting yielded zero (it's just a
5577 // header polish — never let a parsing miss break the page).
5578 if (additions === 0 && deletions === 0 && typeof raw === "string") {
5579 for (const line of raw.split("\n")) {
5580 if (line.startsWith("+") && !line.startsWith("+++")) additions += 1;
5581 else if (line.startsWith("-") && !line.startsWith("---")) deletions += 1;
5582 }
5583 }
5584 const fileCount = files.length;
5585
fc1817aClaude5586 return c.html(
06d5ffeClaude5587 <Layout title={`${commit.message} — ${owner}/${repo}`} user={user}>
efb11c5Claude5588 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
fc1817aClaude5589 <RepoHeader owner={owner} repo={repo} />
efb11c5Claude5590 <div class="commit-detail-card">
5591 <div class="commit-detail-eyebrow">
5592 <strong>Commit</strong>
5593 <span class="commit-detail-sha-pill" title={commit.sha}>
5594 {commit.sha.slice(0, 7)}
5595 </span>
3951454Claude5596 {verification && verification.reason !== "unsigned" && (
5597 <span
efb11c5Claude5598 class={`commit-detail-verify ${
3951454Claude5599 verification.verified
efb11c5Claude5600 ? "commit-detail-verify-ok"
5601 : "commit-detail-verify-warn"
3951454Claude5602 }`}
5603 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
5604 >
5605 {verification.verified ? "Verified" : verification.reason}
5606 </span>
5607 )}
fc1817aClaude5608 </div>
efb11c5Claude5609 <h1 class="commit-detail-title">{commit.message}</h1>
5610 {fullMessage !== commit.message && (
5611 <pre class="commit-detail-body">{fullMessage}</pre>
5612 )}
5613 <div class="commit-detail-meta">
5614 <span class="commit-detail-author">
5615 <strong>{commit.author}</strong> committed on{" "}
5616 {new Date(commit.date).toLocaleDateString("en-US", {
5617 month: "long",
5618 day: "numeric",
5619 year: "numeric",
5620 })}
5621 </span>
fc1817aClaude5622 {commit.parentShas.length > 0 && (
efb11c5Claude5623 <span class="commit-detail-parents">
5624 {commit.parentShas.length === 1 ? "Parent" : "Parents"}:{" "}
5625 {commit.parentShas.map((p, idx) => (
5626 <>
5627 {idx > 0 && " "}
5628 <a
5629 href={`/${owner}/${repo}/commit/${p}`}
5630 class="commit-detail-sha-link"
5631 >
5632 {p.slice(0, 7)}
5633 </a>
5634 </>
fc1817aClaude5635 ))}
5636 </span>
5637 )}
5638 </div>
efb11c5Claude5639 <div class="commit-detail-stats">
5640 <span class="commit-detail-stat">
5641 <strong>{fileCount}</strong>{" "}
5642 file{fileCount === 1 ? "" : "s"} changed
5643 </span>
5644 <span class="commit-detail-stat commit-detail-stat-add">
5645 <span class="commit-detail-stat-mark">+</span>
5646 <strong>{additions}</strong>
5647 </span>
5648 <span class="commit-detail-stat commit-detail-stat-del">
5649 <span class="commit-detail-stat-mark">−</span>
5650 <strong>{deletions}</strong>
5651 </span>
5652 <span class="commit-detail-sha-full" title="Full SHA">
5653 {commit.sha}
5654 </span>
5655 </div>
0cdfd89Claude5656 {statusCombined && (
efb11c5Claude5657 <div class="commit-detail-checks">
5658 <div class="commit-detail-checks-head">
5659 <strong>Checks</strong>
5660 <span class="commit-detail-checks-summary">
5661 {statusCombined.total} total ·{" "}
5662 <span
5663 class={`commit-detail-check-state commit-detail-check-state-${statusCombined.state}`}
5664 >
5665 {statusCombined.state}
5666 </span>
0cdfd89Claude5667 </span>
efb11c5Claude5668 </div>
5669 <div class="commit-detail-check-row">
0cdfd89Claude5670 {statusCombined.contexts.map((cx) => (
5671 <span
efb11c5Claude5672 class={`commit-detail-check commit-detail-check-${cx.state}`}
0cdfd89Claude5673 title={cx.description || cx.context}
5674 >
5675 {cx.targetUrl ? (
efb11c5Claude5676 <a href={cx.targetUrl} rel="noopener">
0cdfd89Claude5677 {cx.context}: {cx.state}
5678 </a>
5679 ) : (
5680 <>
5681 {cx.context}: {cx.state}
5682 </>
5683 )}
5684 </span>
5685 ))}
5686 </div>
5687 </div>
5688 )}
fc1817aClaude5689 </div>
ea9ed4cClaude5690 <DiffView
5691 raw={raw}
5692 files={files}
5693 viewFileBase={`/${owner}/${repo}/blob/${commit.sha}`}
5694 />
fc1817aClaude5695 </Layout>
5696 );
5697});
5698
79136bbClaude5699// Raw file download
5700web.get("/:owner/:repo/raw/:ref{.+$}", async (c) => {
5701 const { owner, repo } = c.req.param();
5bb52faccanty labs5702 const gate = await assertRepoReadable(c, owner, repo);
5703 if (gate) return gate;
79136bbClaude5704 const refAndPath = c.req.param("ref");
5705
5706 const branches = await listBranches(owner, repo);
5707 let ref = "";
5708 let filePath = "";
5709
5710 for (const branch of branches) {
5711 if (refAndPath.startsWith(branch + "/")) {
5712 ref = branch;
5713 filePath = refAndPath.slice(branch.length + 1);
5714 break;
5715 }
5716 }
5717
5718 if (!ref) {
5719 const slashIdx = refAndPath.indexOf("/");
5720 if (slashIdx === -1) return c.text("Not found", 404);
5721 ref = refAndPath.slice(0, slashIdx);
5722 filePath = refAndPath.slice(slashIdx + 1);
5723 }
5724
5725 const data = await getRawBlob(owner, repo, ref, filePath);
5726 if (!data) return c.text("Not found", 404);
5727
5728 const fileName = filePath.split("/").pop() || "file";
772a24fClaude5729 return new Response(data as BodyInit, {
79136bbClaude5730 headers: {
5731 "Content-Type": "application/octet-stream",
5732 "Content-Disposition": `attachment; filename="${fileName}"`,
05b973eClaude5733 "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
79136bbClaude5734 },
5735 });
5736});
5737
5738// Blame view
5739web.get("/:owner/:repo/blame/:ref{.+$}", async (c) => {
5740 const { owner, repo } = c.req.param();
5741 const user = c.get("user");
5bb52faccanty labs5742 const gate = await assertRepoReadable(c, owner, repo);
5743 if (gate) return gate;
79136bbClaude5744 const refAndPath = c.req.param("ref");
5745
5746 const branches = await listBranches(owner, repo);
5747 let ref = "";
5748 let filePath = "";
5749
5750 for (const branch of branches) {
5751 if (refAndPath.startsWith(branch + "/")) {
5752 ref = branch;
5753 filePath = refAndPath.slice(branch.length + 1);
5754 break;
5755 }
5756 }
5757
5758 if (!ref) {
5759 const slashIdx = refAndPath.indexOf("/");
5760 if (slashIdx === -1) return c.text("Not found", 404);
5761 ref = refAndPath.slice(0, slashIdx);
5762 filePath = refAndPath.slice(slashIdx + 1);
5763 }
5764
5765 const blameLines = await getBlame(owner, repo, ref, filePath);
5766 if (blameLines.length === 0) {
5767 return c.html(
5768 <Layout title="Not Found" user={user}>
5769 <div class="empty-state">
5770 <h2>File not found</h2>
5771 </div>
5772 </Layout>,
5773 404
5774 );
5775 }
5776
5777 const fileName = filePath.split("/").pop() || filePath;
efb11c5Claude5778 // Unique contributors (by author) tracked once for the header chip.
5779 const blameAuthors = new Set<string>();
5780 for (const ln of blameLines) blameAuthors.add(ln.author);
79136bbClaude5781
5782 return c.html(
5783 <Layout title={`Blame: ${filePath} — ${owner}/${repo}`} user={user}>
efb11c5Claude5784 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss }} />
79136bbClaude5785 <RepoHeader owner={owner} repo={repo} />
5786 <RepoNav owner={owner} repo={repo} active="code" />
398a10cClaude5787 <header class="blame-head">
5788 <div class="blame-eyebrow">
5789 <span class="blame-eyebrow-dot" aria-hidden="true" />
5790 Blame · Line-by-line history
5791 </div>
5792 <h1 class="blame-title">
5793 <code>{fileName}</code>
5794 </h1>
5795 <p class="blame-sub">
5796 Each line is annotated with the commit that last touched it.
5797 Click any SHA to jump to that commit and see the surrounding
5798 change.
5799 </p>
5800 </header>
efb11c5Claude5801 <div class="blame-toolbar">
5802 <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} />
5803 </div>
398a10cClaude5804 <div class="blame-card">
5805 <div class="blame-header">
efb11c5Claude5806 <div class="blame-header-meta">
5807 <span class="blame-header-icon" aria-hidden="true">{"⎙"}</span>
5808 <span class="blame-header-name">{fileName}</span>
5809 <span class="blame-header-tag">Blame</span>
5810 <span class="blame-header-stats">
5811 {blameLines.length} line{blameLines.length === 1 ? "" : "s"} ·{" "}
5812 {blameAuthors.size} contributor
5813 {blameAuthors.size === 1 ? "" : "s"}
5814 </span>
5815 </div>
5816 <div class="blame-header-actions">
5817 <a
5818 href={`/${owner}/${repo}/blob/${ref}/${filePath}`}
5819 class="blob-pill"
5820 >
5821 Normal view
5822 </a>
5823 <a
5824 href={`/${owner}/${repo}/raw/${ref}/${filePath}`}
5825 class="blob-pill"
5826 >
5827 Raw
5828 </a>
5829 </div>
79136bbClaude5830 </div>
398a10cClaude5831 <div style="overflow-x:auto">
5832 <table class="blame-table">
79136bbClaude5833 <tbody>
5834 {blameLines.map((line, i) => {
5835 const showInfo =
5836 i === 0 || blameLines[i - 1].sha !== line.sha;
5837 return (
398a10cClaude5838 <tr class={showInfo ? "blame-row-first" : ""}>
5839 <td class="blame-gutter">
79136bbClaude5840 {showInfo && (
398a10cClaude5841 <span class="blame-gutter-inner">
79136bbClaude5842 <a
5843 href={`/${owner}/${repo}/commit/${line.sha}`}
398a10cClaude5844 class="blame-gutter-sha"
5845 title={`Commit ${line.sha}`}
79136bbClaude5846 >
5847 {line.sha.slice(0, 7)}
398a10cClaude5848 </a>
5849 <span class="blame-gutter-author" title={line.author}>
5850 {line.author}
5851 </span>
5852 </span>
79136bbClaude5853 )}
5854 </td>
398a10cClaude5855 <td class="blame-line-num">{line.lineNum}</td>
5856 <td class="blame-line-content">{line.content}</td>
79136bbClaude5857 </tr>
5858 );
5859 })}
5860 </tbody>
5861 </table>
5862 </div>
5863 </div>
5864 </Layout>
5865 );
5866});
5867
53c9249Claude5868// Search — keyword + optional Claude semantic mode
79136bbClaude5869web.get("/:owner/:repo/search", async (c) => {
5870 const { owner, repo } = c.req.param();
5871 const user = c.get("user");
5bb52faccanty labs5872 const gate = await assertRepoReadable(c, owner, repo);
5873 if (gate) return gate;
79136bbClaude5874 const q = c.req.query("q") || "";
53c9249Claude5875 const aiAvailable = isAiAvailable();
5876 // Default to semantic when Claude is available and no explicit mode set.
5877 const modeParam = c.req.query("mode");
5878 const mode: "semantic" | "keyword" =
5879 modeParam === "keyword"
5880 ? "keyword"
5881 : modeParam === "semantic"
5882 ? "semantic"
5883 : aiAvailable
5884 ? "semantic"
5885 : "keyword";
5886 const isSemantic = mode === "semantic" && aiAvailable;
79136bbClaude5887
5888 if (!(await repoExists(owner, repo))) return c.notFound();
5889
5890 const defaultBranch = (await getDefaultBranch(owner, repo)) || "main";
53c9249Claude5891
5892 // Keyword results (always available as fallback / when in keyword mode)
5893 let keywordResults: Array<{ file: string; lineNum: number; line: string }> = [];
5894 // Semantic results (Claude-powered)
5895 type SemanticHit = import("../lib/claude-semantic-search").SemanticSearchResult;
5896 let semanticHits: SemanticHit[] = [];
5897 let semanticMode: "semantic" | "keyword" = "semantic";
5898 let quotaExceeded = false;
79136bbClaude5899
5900 if (q.trim()) {
53c9249Claude5901 if (isSemantic) {
5902 // Resolve repo DB id for caching
5903 const [ownerRow] = await db
5904 .select({ id: users.id })
5905 .from(users)
5906 .where(eq(users.username, owner))
5907 .limit(1);
5908 const [repoRow] = ownerRow
5909 ? await db
5910 .select({ id: repositories.id })
5911 .from(repositories)
5912 .where(
5913 and(
5914 eq(repositories.ownerId, ownerRow.id),
5915 eq(repositories.name, repo)
5916 )
5917 )
5918 .limit(1)
5919 : [];
5920
5921 const rateLimitKey = user
5922 ? `user:${user.id}`
5923 : (c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "anon");
5924
5925 const searchResult = await claudeSemanticSearch(
5926 owner,
5927 repo,
5928 repoRow?.id ?? `${owner}/${repo}`,
5929 q.trim(),
5930 { branch: defaultBranch, rateLimitKey }
5931 );
5932 semanticHits = searchResult.results;
5933 semanticMode = searchResult.mode;
5934 quotaExceeded = searchResult.quotaExceeded;
5935 } else {
5936 keywordResults = await searchCode(owner, repo, defaultBranch, q.trim());
5937 }
79136bbClaude5938 }
5939
53c9249Claude5940 const aiSearchCss = `
5941 /* ─── Semantic search mode toggle ─── */
5942 .search-mode-bar {
5943 display: flex;
5944 align-items: center;
5945 gap: 8px;
5946 margin-bottom: 14px;
5947 flex-wrap: wrap;
5948 }
5949 .search-mode-label {
5950 font-size: 12.5px;
5951 color: var(--text-muted);
5952 font-weight: 500;
5953 }
5954 .search-mode-toggle {
5955 display: inline-flex;
5956 background: var(--bg-elevated);
5957 border: 1px solid var(--border);
5958 border-radius: 9999px;
5959 padding: 3px;
5960 gap: 2px;
5961 }
5962 .search-mode-btn {
5963 display: inline-flex;
5964 align-items: center;
5965 gap: 5px;
5966 padding: 5px 12px;
5967 border-radius: 9999px;
5968 font-size: 12.5px;
5969 font-weight: 500;
5970 color: var(--text-muted);
5971 text-decoration: none;
5972 transition: color 120ms ease, background 120ms ease;
5973 cursor: pointer;
5974 border: none;
5975 background: none;
5976 font: inherit;
5977 }
5978 .search-mode-btn:hover { color: var(--text-strong); text-decoration: none; }
5979 .search-mode-btn.active {
6fd5915Claude5980 background: rgba(91,110,232,0.15);
53c9249Claude5981 color: var(--text-strong);
5982 }
5983 .search-mode-ai-badge {
5984 display: inline-flex;
5985 align-items: center;
5986 gap: 4px;
5987 padding: 2px 8px;
5988 border-radius: 9999px;
5989 font-size: 11px;
5990 font-weight: 600;
6fd5915Claude5991 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.15));
53c9249Claude5992 color: #c4b5fd;
6fd5915Claude5993 border: 1px solid rgba(91,110,232,0.30);
53c9249Claude5994 margin-left: 2px;
5995 }
5996 .search-quota-warn {
5997 font-size: 12.5px;
5998 color: var(--text-muted);
5999 padding: 6px 12px;
6000 background: rgba(255,200,0,0.06);
6001 border: 1px solid rgba(255,200,0,0.20);
6002 border-radius: 8px;
6003 }
6004
6005 /* ─── Semantic result cards ─── */
6006 .sem-results { display: flex; flex-direction: column; gap: 10px; }
6007 .sem-result {
6008 padding: 14px 16px;
6009 background: var(--bg-elevated);
6010 border: 1px solid var(--border);
6011 border-radius: 12px;
6012 transition: border-color 120ms ease;
6013 }
6014 .sem-result:hover { border-color: var(--border-strong); }
6015 .sem-result-head {
6016 display: flex;
6017 align-items: flex-start;
6018 justify-content: space-between;
6019 gap: 10px;
6020 flex-wrap: wrap;
6021 margin-bottom: 6px;
6022 }
6023 .sem-result-path {
6024 font-family: var(--font-mono);
6025 font-size: 13px;
6026 font-weight: 600;
6027 color: var(--text-strong);
6028 text-decoration: none;
6029 word-break: break-all;
6030 }
6031 .sem-result-path:hover { color: #c4b5fd; text-decoration: none; }
6032 .sem-result-conf {
6033 display: inline-flex;
6034 align-items: center;
6035 gap: 4px;
6036 padding: 2px 9px;
6037 border-radius: 9999px;
6038 font-size: 11.5px;
6039 font-weight: 600;
6040 white-space: nowrap;
6041 flex-shrink: 0;
6042 }
6043 .sem-conf-strong { background: rgba(52,211,153,0.12); color: #34d399; border: 1px solid rgba(52,211,153,0.30); }
6fd5915Claude6044 .sem-conf-possible { background: rgba(91,110,232,0.12); color: #5b6ee8; border: 1px solid rgba(91,110,232,0.30); }
53c9249Claude6045 .sem-conf-weak { background: rgba(255,255,255,0.04); color: var(--text-muted); border: 1px solid var(--border); }
6046 .sem-result-reason {
6047 font-size: 12.5px;
6048 color: var(--text-muted);
6049 margin-bottom: 8px;
6050 line-height: 1.5;
6051 }
6052 .sem-result-snippet {
6053 margin: 0;
6054 padding: 10px 12px;
6055 background: rgba(0,0,0,0.22);
6056 border: 1px solid var(--border);
6057 border-radius: 8px;
6058 font-family: var(--font-mono);
6059 font-size: 12px;
6060 line-height: 1.55;
6061 color: var(--text);
6062 overflow-x: auto;
6063 white-space: pre-wrap;
6064 word-break: break-word;
6065 max-height: 240px;
6066 overflow-y: auto;
6067 }
6068 `;
6069
6070 const semanticBaseUrl = `/${owner}/${repo}/search?mode=semantic`;
6071 const keywordBaseUrl = `/${owner}/${repo}/search?mode=keyword`;
6072
79136bbClaude6073 return c.html(
6074 <Layout title={`Search — ${owner}/${repo}`} user={user}>
53c9249Claude6075 <style dangerouslySetInnerHTML={{ __html: codeBrowseCss + aiSearchCss }} />
79136bbClaude6076 <RepoHeader owner={owner} repo={repo} />
6077 <RepoNav owner={owner} repo={repo} active="code" />
efb11c5Claude6078 <div class="search-hero">
6079 <div class="search-eyebrow">
6080 <strong>Search</strong> · {owner}/{repo}
6081 </div>
6082 <h1 class="search-title">
53c9249Claude6083 {isSemantic ? (
6084 <>{"Ask "}<span class="gradient-text">{repo}</span>{" anything."}</>
6085 ) : (
6086 <>{"Find any line in "}<span class="gradient-text">{repo}</span></>
6087 )}
efb11c5Claude6088 </h1>
6089 <form
6090 method="get"
6091 action={`/${owner}/${repo}/search`}
6092 class="search-form"
6093 role="search"
6094 >
53c9249Claude6095 <input type="hidden" name="mode" value={mode} />
efb11c5Claude6096 <div class="search-input-wrap">
6097 <span class="search-input-icon" aria-hidden="true">{"⌕"}</span>
6098 <input
6099 type="text"
6100 name="q"
6101 value={q}
53c9249Claude6102 placeholder={
6103 isSemantic
6104 ? "Ask a question or describe what you're looking for…"
6105 : "Search code on the default branch…"
6106 }
efb11c5Claude6107 aria-label="Search code"
6108 class="search-input"
6109 autocomplete="off"
6110 autofocus
6111 />
6112 </div>
6113 <button type="submit" class="btn btn-primary search-submit">
79136bbClaude6114 Search
6115 </button>
efb11c5Claude6116 </form>
6117 </div>
53c9249Claude6118
6119 {/* Mode toggle */}
6120 <div class="search-mode-bar">
6121 <span class="search-mode-label">Mode:</span>
6122 <div class="search-mode-toggle" role="group" aria-label="Search mode">
6123 {aiAvailable ? (
6124 <a
6125 href={q ? `${semanticBaseUrl}&q=${encodeURIComponent(q)}` : semanticBaseUrl}
6126 class={`search-mode-btn${isSemantic ? " active" : ""}`}
6127 aria-pressed={isSemantic ? "true" : "false"}
6128 >
6129 {"✨"} Semantic AI
6130 <span class="search-mode-ai-badge">AI-powered</span>
6131 </a>
6132 ) : null}
6133 <a
6134 href={q ? `${keywordBaseUrl}&q=${encodeURIComponent(q)}` : keywordBaseUrl}
6135 class={`search-mode-btn${!isSemantic ? " active" : ""}`}
6136 aria-pressed={!isSemantic ? "true" : "false"}
6137 >
6138 {"⌕"} Keyword
6139 </a>
6140 </div>
6141 {isSemantic && aiAvailable && (
6142 <span style="font-size:12px;color:var(--text-muted)">
6143 Ask in plain English — finds code by meaning, not just exact words
efb11c5Claude6144 </span>
53c9249Claude6145 )}
6146 {quotaExceeded && (
6147 <span class="search-quota-warn">
6148 Semantic search quota reached — showing keyword results
efb11c5Claude6149 </span>
53c9249Claude6150 )}
6151 </div>
6152
6153 {/* ─── Semantic results ─── */}
6154 {isSemantic && q && (
6155 <>
6156 {semanticMode === "keyword" && !quotaExceeded && semanticHits.length > 0 && (
6157 <div class="search-quota-warn" style="margin-bottom:10px">
6158 Falling back to keyword search — Claude found no relevant files.
6159 </div>
6160 )}
6161 {semanticHits.length === 0 ? (
6162 <div class="search-empty">
6163 <p>
6164 No matches for <strong>"{q}"</strong>.{" "}
6165 Try a different phrasing or{" "}
6166 <a href={`${keywordBaseUrl}&q=${encodeURIComponent(q)}`}>
6167 switch to keyword search
6168 </a>.
6169 </p>
6170 </div>
6171 ) : (
6172 <>
6173 <div class="search-results-head">
6174 <span class="search-results-count">
6175 <strong>{semanticHits.length}</strong> result
6176 {semanticHits.length !== 1 ? "s" : ""}
6177 </span>
6178 <span class="search-results-query">
6179 {semanticMode === "semantic" ? "AI-ranked files for" : "keyword matches for"}{" "}
6180 <span class="search-results-q">"{q}"</span>
6181 </span>
6182 </div>
6183 <div class="sem-results">
6184 {semanticHits.map((h) => {
6185 const confClass =
6186 h.confidence > 0.7
6187 ? "sem-conf-strong"
6188 : h.confidence > 0.4
6189 ? "sem-conf-possible"
6190 : "sem-conf-weak";
6191 const confLabel =
6192 h.confidence > 0.7
6193 ? "Strong match"
6194 : h.confidence > 0.4
6195 ? "Possible match"
6196 : "Weak match";
6197 const href = `/${owner}/${repo}/blob/${defaultBranch}/${h.file}${h.lineNumber ? `#L${h.lineNumber}` : ""}`;
6198 const preview =
6199 h.snippet.length > 800
6200 ? h.snippet.slice(0, 800) + "\n…"
6201 : h.snippet;
6202 return (
6203 <div class="sem-result">
6204 <div class="sem-result-head">
6205 <a href={href} class="sem-result-path">
6206 {h.file}
6207 {h.lineNumber ? (
6208 <span style="color:var(--text-muted);font-weight:500">
6209 :{h.lineNumber}
6210 </span>
6211 ) : null}
6212 </a>
6213 <span class={`sem-result-conf ${confClass}`}>
6214 {confLabel}
6215 </span>
6216 </div>
6217 {h.reason && (
6218 <p class="sem-result-reason">{h.reason}</p>
6219 )}
6220 {preview && (
6221 <pre class="sem-result-snippet">{preview}</pre>
6222 )}
6223 </div>
6224 );
6225 })}
6226 </div>
6227 </>
6228 )}
6229 </>
79136bbClaude6230 )}
53c9249Claude6231
6232 {/* ─── Keyword results ─── */}
6233 {!isSemantic && q && (
6234 <>
6235 <div class="search-results-head">
6236 <span class="search-results-count">
6237 <strong>{keywordResults.length}</strong> result
6238 {keywordResults.length !== 1 ? "s" : ""}
6239 </span>
6240 <span class="search-results-query">
6241 for <span class="search-results-q">"{q}"</span> on{" "}
6242 <code>{defaultBranch}</code>
6243 </span>
6244 </div>
6245 {keywordResults.length === 0 ? (
6246 <div class="search-empty">
6247 <p>
6248 No matches for <strong>"{q}"</strong>. Try a shorter query or{" "}
6249 {aiAvailable && (
6250 <a href={`${semanticBaseUrl}&q=${encodeURIComponent(q)}`}>
6251 try AI semantic search
79136bbClaude6252 </a>
53c9249Claude6253 )}.
6254 </p>
6255 </div>
6256 ) : (
6257 <div class="search-results">
6258 {(() => {
6259 const grouped: Record<
6260 string,
6261 Array<{ lineNum: number; line: string }>
6262 > = {};
6263 for (const r of keywordResults) {
6264 if (!grouped[r.file]) grouped[r.file] = [];
6265 grouped[r.file].push({ lineNum: r.lineNum, line: r.line });
6266 }
6267 return Object.entries(grouped).map(([file, matches]) => (
6268 <div class="search-file diff-file">
6269 <div class="search-file-head diff-file-header">
6270 <a
6271 href={`/${owner}/${repo}/blob/${defaultBranch}/${file}`}
6272 class="search-file-link"
6273 >
6274 {file}
6275 </a>
6276 <span class="search-file-count">
6277 {matches.length} match{matches.length === 1 ? "" : "es"}
6278 </span>
6279 </div>
6280 <div class="blob-code">
6281 <table>
6282 <tbody>
6283 {matches.map((m) => (
6284 <tr>
6285 <td class="line-num">{m.lineNum}</td>
6286 <td class="line-content">{m.line}</td>
6287 </tr>
6288 ))}
6289 </tbody>
6290 </table>
6291 </div>
6292 </div>
6293 ));
6294 })()}
6295 </div>
6296 )}
6297 </>
6298 )}
79136bbClaude6299 </Layout>
6300 );
6301});
6302
fc1817aClaude6303export default web;