mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
c72890d59d
commit
af421cc9a3
4 changed files with 215 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
148
gitnexus-web/src/components/RepoLanding.tsx
Normal file
148
gitnexus-web/src/components/RepoLanding.tsx
Normal 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 & private repos · Cloned locally by the server · No data leaves
|
||||
your machine
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue