diff --git a/gitnexus-web/e2e/onboarding.spec.ts b/gitnexus-web/e2e/onboarding.spec.ts index 309dc7ef9..da92ffb70 100644 --- a/gitnexus-web/e2e/onboarding.spec.ts +++ b/gitnexus-web/e2e/onboarding.spec.ts @@ -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 diff --git a/gitnexus-web/e2e/server-connect.spec.ts b/gitnexus-web/e2e/server-connect.spec.ts index 5d1c307e3..eb241da10 100644 --- a/gitnexus-web/e2e/server-connect.spec.ts +++ b/gitnexus-web/e2e/server-connect.spec.ts @@ -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 }); }); diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index a0cb0cd37..f268eb656 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -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; @@ -144,75 +150,52 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => { const autoConnectTimerRef = useRef | 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(null); + const [detectedRepos, setDetectedRepos] = useState([]); - // 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 && ( {displayPhase === 'onboarding' && } - {displayPhase === 'analyze' && } + {displayPhase === 'analyze' && } + {displayPhase === 'landing' && ( + + )} {displayPhase === 'success' && } {displayPhase === 'loading' && } diff --git a/gitnexus-web/src/components/RepoLanding.tsx b/gitnexus-web/src/components/RepoLanding.tsx new file mode 100644 index 000000000..e6a44a78e --- /dev/null +++ b/gitnexus-web/src/components/RepoLanding.tsx @@ -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 ( + + ); +} + +// ── RepoLanding ────────────────────────────────────────────────────────────── + +interface RepoLandingProps { + repos: BackendRepo[]; + onSelectRepo: (repoName: string) => void; + onAnalyzeComplete: (repoName: string) => void; +} + +export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => { + return ( +
+ {/* Ambient glows — mirrors OnboardingGuide aesthetic */} +
+
+ + {/* Header */} +
+
+
+ + + GitNexus + +
+ +

+ Choose a repository +

+

+ Select an indexed repository to explore, or analyze a new one. +

+
+
+ + {/* Repo list */} +
+ {repos.map((repo) => ( + onSelectRepo(repo.name)} /> + ))} +
+ + {/* Divider */} +
+
+ + or analyze new + +
+
+ + {/* Analyzer form */} +
+ +
+ + {/* Footer hint */} +

+ Public & private repos · Cloned locally by the server · No data leaves + your machine +

+
+ ); +};