feat(web): repo landing screen with selectable repo cards (#607)

* feat(web): add repo landing screen with selectable repo cards

Instead of auto-loading the first indexed repo when the backend is
detected, show a landing screen that lets users choose which repo
to explore or analyze a new one. This addresses the UX gap where
users with multiple indexed repos had no way to pick—they were
always sent to the first one found.

- New RepoLanding component with clickable repo cards (name, stats,
  indexed date) and an embedded RepoAnalyzer for new repos
- DropZone gains a 'landing' phase between server detection and
  graph loading
- Shared connectToRepo handler replaces the old handleAnalyzeComplete
  for both repo selection and post-analysis connection

Made-with: Cursor

* fix(web): update e2e flow for repo landing screen

The new landing screen intentionally stops auto-loading the first indexed
repo, so the existing Playwright tests were still waiting for the explorer
to appear automatically. Update the specs to select a repo from the landing
screen before asserting on the graph, and add a stable test id for repo cards.

Also format DropZone to satisfy the Prettier CI check.

Made-with: Cursor

* fix(e2e): use waitFor instead of instant isVisible for landing card

locator.isVisible() is a non-retrying instant check — the landing card
hadn't rendered yet when it was called, causing the click to be silently
skipped. Switch to waitFor which properly polls until the element appears.

Made-with: Cursor

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
This commit is contained in:
Abhigyan Patwari 2026-04-01 10:34:10 +05:30 committed by GitHub
parent c72890d59d
commit af421cc9a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 215 additions and 54 deletions

View file

@ -15,6 +15,20 @@ import { test, expect } from '@playwright/test';
const BACKEND_URL = 'http://localhost:4747';
async function enterExploringView(page: import('@playwright/test').Page) {
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
}
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
test.describe('Flow 1: Onboarding — no server', () => {
@ -249,10 +263,7 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
});
test('project badge opens repo dropdown', async ({ page }, testInfo) => {
await page.goto('/');
// Wait for auto-connect to finish and exploring view to load
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await enterExploringView(page);
await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
// Click the project badge (has a chevron)
@ -269,8 +280,7 @@ test.describe('Flow 4: Repo dropdown in exploring view', () => {
});
test('analyze option opens inline form', async ({ page }, testInfo) => {
await page.goto('/');
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await enterExploringView(page);
// Open repo dropdown
const badge = page

View file

@ -49,23 +49,33 @@ test.beforeAll(async () => {
});
/**
* Wait for the new auto-connect flow to complete.
* Wait for the server-detection flow to complete.
*
* The app now auto-detects the server via polling and connects without
* any user interaction. We just need to wait for the exploring view.
* The app auto-detects the server, then either:
* - shows the landing screen when indexed repos exist, or
* - goes straight into analyze onboarding when there are zero repos.
*
* For these tests we require at least one indexed repo, so pick the first
* landing card when present and then wait for the exploring view.
*/
async function waitForGraphLoaded(page: import('@playwright/test').Page, testInfo: TestInfo) {
await page.goto('/');
// The app auto-connects: onboarding → success → loading → exploring.
// Wait for the status bar "Ready" indicator which confirms the graph is loaded.
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('graph-loaded.png') });
}
test.describe('Server Connection & Graph Loading', () => {
test('auto-connects and loads graph', async ({ page }, testInfo) => {
test('selects a repo from landing and loads graph', async ({ page }, testInfo) => {
await waitForGraphLoaded(page, testInfo);
await page.screenshot({ path: testInfo.outputPath('graph-loaded-full.png'), fullPage: true });
});

View file

@ -1,9 +1,15 @@
import { useState, useRef, useEffect } from 'react';
import { Loader2, Check, Sparkles } from '@/lib/lucide-icons';
import { connectToServer, fetchRepos, type ConnectResult } from '../services/backend-client';
import {
connectToServer,
fetchRepos,
type ConnectResult,
type BackendRepo,
} from '../services/backend-client';
import { useBackend } from '../hooks/useBackend';
import { OnboardingGuide } from './OnboardingGuide';
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
import { RepoLanding } from './RepoLanding';
interface DropZoneProps {
onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise<void>;
@ -144,75 +150,52 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Connection state
// 'analyze' = server up but zero repos indexed — show URL input
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'success' | 'loading'>(
// 'analyze' = server up but zero repos indexed — show URL input
// 'landing' = server up with indexed repos — show repo picker + analyze
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'landing' | 'success' | 'loading'>(
'onboarding',
);
const [loadingMessage, setLoadingMessage] = useState('');
const abortControllerRef = useRef<AbortController | null>(null);
const [detectedRepos, setDetectedRepos] = useState<BackendRepo[]>([]);
// Auto-connect to the detected server
// Auto-connect to the detected server — fetch repo list and show the
// appropriate screen (landing with repo cards, or analyze for zero repos).
const handleAutoConnect = async () => {
setPhase('loading');
setLoadingMessage('Connecting...');
setError(null);
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
// Check if the server has any indexed repos first
const repos = await fetchRepos();
if (repos.length === 0) {
// Server is up but has no repos — transition to the analyze UI
// instead of showing a generic error string.
setPhase('analyze');
autoConnectRan.current = false;
return;
}
const result = await connectToServer(
detectedBackendUrl,
(p, downloaded, total) => {
if (p === 'validating') {
setLoadingMessage('Validating server...');
} else if (p === 'downloading') {
const mb = (downloaded / (1024 * 1024)).toFixed(1);
const pct = total ? Math.round((downloaded / total) * 100) : null;
setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`);
} else if (p === 'extracting') {
setLoadingMessage('Processing graph...');
}
},
abortController.signal,
);
if (onServerConnect) {
await onServerConnect(result, detectedBackendUrl);
}
// Show landing screen so the user can choose which repo to explore
setDetectedRepos(repos);
setPhase('landing');
} catch (err) {
if ((err as Error).name === 'AbortError') return;
const message = err instanceof Error ? err.message : 'Failed to connect';
setError(message);
// Show error on the loading card — do NOT reset autoConnectRan while
// isConnected is still true, or the auto-connect effect will loop.
// The "server went away" branch handles the reset when isConnected drops.
setPhase('onboarding');
} finally {
abortControllerRef.current = null;
}
};
const handleAutoConnectRef = useRef(handleAutoConnect);
handleAutoConnectRef.current = handleAutoConnect;
// Called by AnalyzeOnboarding when a new repo finishes indexing.
// Connects directly to the newly-analyzed repo by name.
const handleAnalyzeComplete = (repoName: string) => {
// Shared handler: connect to a specific repo by name (used by both repo
// card selection on the landing screen and post-analysis completion).
const connectToRepo = (repoName: string) => {
autoConnectRan.current = true;
setPhase('loading');
setLoadingMessage('Loading graph...');
// Connect to the specific repo that was just analyzed
setError(null);
(async () => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
@ -220,9 +203,12 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const result = await connectToServer(
detectedBackendUrl,
(p, downloaded, total) => {
if (p === 'downloading') {
if (p === 'validating') {
setLoadingMessage('Validating server...');
} else if (p === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 100) : null;
setLoadingMessage(pct ? `Downloading graph... ${pct}%` : 'Downloading graph...');
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`);
} else if (p === 'extracting') {
setLoadingMessage('Processing graph...');
}
@ -236,7 +222,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
} catch (err) {
if ((err as Error).name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Failed to load graph');
setPhase('onboarding');
setPhase(detectedRepos.length > 0 ? 'landing' : 'analyze');
} finally {
abortControllerRef.current = null;
}
@ -314,7 +300,14 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
{displayPhase && (
<Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={handleAnalyzeComplete} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
{displayPhase === 'landing' && (
<RepoLanding
repos={detectedRepos}
onSelectRepo={connectToRepo}
onAnalyzeComplete={connectToRepo}
/>
)}
{displayPhase === 'success' && <SuccessCard />}
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
</Crossfade>

View file

@ -0,0 +1,148 @@
/**
* RepoLanding
*
* Unified landing screen shown when the backend is connected and at least one
* repository is indexed. Displays pre-indexed repos as selectable cards, plus
* an "Analyze a New Repository" section powered by RepoAnalyzer.
*
* Rendering context:
* DropZone (Crossfade, phase="landing")
* RepoLanding
* RepoCard (× N)
* RepoAnalyzer (variant="onboarding")
*/
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
import type { BackendRepo } from '../services/backend-client';
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ── Repo card ────────────────────────────────────────────────────────────────
function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) {
const stats = repo.stats;
return (
<button
onClick={onClick}
data-testid="landing-repo-card"
className="group w-full cursor-pointer rounded-xl border border-border-default bg-elevated p-4 text-left transition-all duration-200 hover:border-accent/40 hover:bg-hover hover:shadow-glow-soft"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-accent" />
<h3 className="truncate text-sm font-semibold text-text-primary transition-colors group-hover:text-accent">
{repo.name}
</h3>
</div>
{repo.indexedAt && (
<p className="mt-1 pl-6 text-xs text-text-muted">
Indexed {formatRelativeTime(repo.indexedAt)}
</p>
)}
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-text-muted opacity-0 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-accent group-hover:opacity-100" />
</div>
{stats && (stats.files || stats.nodes) && (
<div className="mt-3 flex flex-wrap gap-2 pl-6">
{stats.files != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<FileCode className="h-3 w-3" /> {stats.files.toLocaleString()} files
</span>
)}
{stats.nodes != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Layers className="h-3 w-3" /> {stats.nodes.toLocaleString()} symbols
</span>
)}
{stats.processes != null && stats.processes > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Sparkles className="h-3 w-3" /> {stats.processes} flows
</span>
)}
</div>
)}
</button>
);
}
// ── RepoLanding ──────────────────────────────────────────────────────────────
interface RepoLandingProps {
repos: BackendRepo[];
onSelectRepo: (repoName: string) => void;
onAnalyzeComplete: (repoName: string) => void;
}
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
return (
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */}
<div className="relative mb-6">
<div className="text-center">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Choose a repository
</h2>
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
Select an indexed repository to explore, or analyze a new one.
</p>
</div>
</div>
{/* Repo list */}
<div className="relative mb-5 space-y-2">
{repos.map((repo) => (
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
))}
</div>
{/* Divider */}
<div className="mb-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border-subtle" />
<span className="text-[11px] tracking-widest text-text-muted uppercase">
or analyze new
</span>
<div className="h-px flex-1 bg-border-subtle" />
</div>
{/* Analyzer form */}
<div className="relative">
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
</div>
{/* Footer hint */}
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
Public &amp; private repos &middot; Cloned locally by the server &middot; No data leaves
your machine
</p>
</div>
);
};