diff --git a/gitnexus-web/e2e/repo-switching.spec.ts b/gitnexus-web/e2e/repo-switching.spec.ts new file mode 100644 index 000000000..4cf6bf8c8 --- /dev/null +++ b/gitnexus-web/e2e/repo-switching.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for the repo-switching and false-404 fixes. + * + * Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts). + * The 503 hold-queue test uses route interception to simulate a slow analysis. + */ + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; + +let firstRepoName: string; + +test.beforeAll(async () => { + if (process.env.E2E) { + try { + const res = await fetch(`${BACKEND_URL}/api/repos`); + const repos = await res.json(); + firstRepoName = repos[0]?.name ?? ''; + } catch { + firstRepoName = ''; + } + return; + } + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos'); + return; + } + firstRepoName = repos[0].name; + } + } catch { + test.skip(true, 'servers not available'); + } +}); + +// ── 1. Hold-queue: 503 → descriptive user message ──────────────────────────── + +test.describe('Hold-queue timeout error', () => { + test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => { + // Intercept only /api/repo (singular) — not /api/repos — to return a 503 + // regex: /api/repo followed by end, ?, or # — NOT /api/repos + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ + error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`, + }), + }), + ); + + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + // UI should show the 503 error message + await expect(page.getByText(/taking longer than expected/i)).toBeVisible({ + timeout: 20_000, + }); + + await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') }); + }); +}); + +// ── 2. ?project= URL persistence ───────────────────────────────────────────── + +test.describe('?project= URL persistence', () => { + test('?project= is set in URL after connecting via ?server=', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + const url = new URL(page.url()); + const project = url.searchParams.get('project'); + expect(project).toBeTruthy(); + // first repo returned by the live backend + if (firstRepoName) expect(project).toBe(firstRepoName); + }); + + test('?project= is still present after F5 reload', async ({ page }) => { + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // After connect, URL has ?server=&project= — F5 re-uses both params + await page.reload(); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + const url = new URL(page.url()); + expect(url.searchParams.get('project')).toBeTruthy(); + }); +}); + +// ── 3. ?project= + ?server= combined auto-connect ──────────────────────────── + +test.describe('?project= auto-connect', () => { + test('navigating with ?server=&project= connects to the correct repo', async ({ + page, + }, testInfo) => { + if (!firstRepoName) test.skip(true, 'no repo name available'); + + await page.goto( + `/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`, + ); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // ?project= in URL should match what we passed in + const url = new URL(page.url()); + expect(url.searchParams.get('project')).toBe(firstRepoName); + + await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') }); + }); +}); + +// ── 4. Windows path normalization ───────────────────────────────────────────── + +test.describe('Windows path normalization', () => { + test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({ + page, + }) => { + const repoName = firstRepoName || 'test-repo'; + const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`; + + // Mock /api/repo to return a Windows backslash path while keeping name correct + await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + // intentionally omit `name` to force path-based extraction + path: windowsPath, + repoPath: windowsPath, + }), + }), + ); + + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); + + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + + // URL ?project= must be the short basename, NOT the full Windows path + const url = new URL(page.url()); + const project = url.searchParams.get('project'); + expect(project).toBeTruthy(); + expect(project).not.toContain('\\'); + expect(project).not.toContain('LENOVO'); + expect(project).toBe(repoName); + }); +}); diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 97af8d14c..2ee3569a8 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -56,16 +56,14 @@ const AppContent = () => { // backend calls (queries, search, grep, readFile) scope to this repo. const repoName = result.repoInfo.name; const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path; + // Normalize both Windows (\) and Unix (/) path separators before splitting const projectName = - repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project'; + result.repoInfo.name || + (repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() || + 'server-project'; setProjectName(projectName); setCurrentRepo(projectName); - // Update URL so F5 / bookmarks preserve which repo is open - const url = new URL(window.location.href); - url.searchParams.set('project', projectName); - window.history.replaceState(null, '', url.toString()); - // Build KnowledgeGraph from server data for visualization const graph = createKnowledgeGraph(); for (const node of result.nodes) { @@ -76,6 +74,11 @@ const AppContent = () => { } setGraph(graph); + // Persist the active project in the URL for bookmarkability and F5 refresh resilience + const urlObj = new URL(window.location.href); + urlObj.searchParams.set('project', projectName); + window.history.replaceState(null, '', urlObj.toString()); + // Transition directly to exploring view setViewMode('exploring'); @@ -99,22 +102,17 @@ const AppContent = () => { ], ); - // Auto-connect when ?server query param is present (bookmarkable shortcut). - // Also reads ?project= to connect to a specific repo. + // Auto-connect when ?server or ?project query param is present (bookmarkable shortcut) const autoConnectRan = useRef(false); useEffect(() => { if (autoConnectRan.current) return; const params = new URLSearchParams(window.location.search); - if (!params.has('server')) return; + const serverUrlParam = params.get('server'); + const projectParam = params.get('project'); + + if (!serverUrlParam && !projectParam) return; autoConnectRan.current = true; - const serverUrl = params.get('server') || window.location.origin; - const projectParam = params.get('project') || undefined; - - // Keep ?server= in the URL so F5 reconnects to the same server. - // autoConnectRan.current prevents re-trigger within the same session. - // handleServerConnect() will add/update ?project= after connecting. - setProgress({ phase: 'extracting', percent: 0, @@ -123,39 +121,45 @@ const AppContent = () => { }); setViewMode('loading'); + const serverUrl = serverUrlParam || window.location.origin; const baseUrl = normalizeServerUrl(serverUrl); - connectToServer( - serverUrl, - (phase, downloaded, total) => { - if (phase === 'validating') { - setProgress({ - phase: 'extracting', - percent: 5, - message: 'Connecting to server...', - detail: 'Validating server', - }); - } else if (phase === 'downloading') { - const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; - const mb = (downloaded / (1024 * 1024)).toFixed(1); - setProgress({ - phase: 'extracting', - percent: pct, - message: 'Downloading graph...', - detail: `${mb} MB downloaded`, - }); - } else if (phase === 'extracting') { - setProgress({ - phase: 'extracting', - percent: 97, - message: 'Processing...', - detail: 'Extracting file contents', - }); - } - }, - undefined, - projectParam, - ) + const tryConnect = async () => { + return await connectToServer( + serverUrl, + (phase, downloaded, total) => { + if (phase === 'validating') { + setProgress({ + phase: 'extracting', + percent: 5, + message: 'Connecting to server...', + detail: 'Validating server', + }); + } else if (phase === 'downloading') { + const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + setProgress({ + phase: 'extracting', + percent: pct, + message: 'Downloading graph...', + detail: `${mb} MB downloaded`, + }); + } else if (phase === 'extracting') { + setProgress({ + phase: 'extracting', + percent: 97, + message: 'Processing...', + detail: 'Extracting file contents', + }); + } + }, + undefined, + projectParam || undefined, + { awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed + ); + }; + + tryConnect() .then(async (result) => { await handleServerConnect(result); setProgress(null); diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index d70847599..1b7a0c5b0 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -231,7 +231,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = repo: projectName, }; - readFile(selectedFilePath, options) + readFile(selectedFilePath, { ...options, repo: projectName || undefined }) .then((result) => { if (!cancelled) { setFileResult(result); diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 2fa5218ba..25c57767f 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -579,6 +579,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { try { const effectiveProjectName = overrideProjectName || projectName || 'project'; + + // Sync repoRef so all agent backend calls target the correct repo. + // initializeAgent can be called from App.tsx (handleServerConnect) which + // never sets repoRef.current directly — without this, queries default to repo[0]. + if (overrideProjectName) { + repoRef.current = overrideProjectName; + } const repo = repoRef.current; // Build backend interface for Graph RAG tools @@ -610,7 +617,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setIsAgentInitializing(false); } }, - [projectName], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], // repoRef is a stable ref — we sync it explicitly on entry; no state deps needed ); const sendChatMessage = useCallback( @@ -1042,6 +1050,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setCodePanelOpen(false); setCodeReferenceFocus(null); + let connectedRepo: BackendRepo | undefined; + let pNameStr = repoName || 'server-project'; + try { const result: ConnectResult = await connectToServer( serverBaseUrl, @@ -1073,44 +1084,28 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { }, undefined, repoName, + { awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed ); // Build graph for visualization const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path; + // Prefer the registry name, then normalize Windows \ and Unix / paths const pName = - repoName || result.repoInfo.name || repoPath?.split('/').pop() || 'server-project'; + repoName || + result.repoInfo.name || + (repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() || + 'server-project'; setProjectName(pName); repoRef.current = pName; - // Update URL so F5 / bookmarks open the correct repo - const url = new URL(window.location.href); - url.searchParams.set('project', pName); - window.history.replaceState(null, '', url.toString()); + connectedRepo = result.repoInfo; + pNameStr = pName; const newGraph = createKnowledgeGraph(); for (const node of result.nodes) newGraph.addNode(node); for (const rel of result.relationships) newGraph.addRelationship(rel); setGraph(newGraph); - - // No fileContents needed — grep/read tools use backend HTTP - - // Initialize agent with backend queries, then start embeddings - try { - if (getActiveProviderConfig()) { - await initializeAgent(pName); - } - setViewMode('exploring'); - startEmbeddingsWithFallback(); - setProgress(null); - } catch (err) { - console.warn('Failed to initialize agent:', err); - setIsAgentReady(false); - agentRef.current = null; - setAgentError('Failed to initialize agent'); - setViewMode('exploring'); - setProgress(null); - } - } catch (err) { + } catch (err: unknown) { console.error('Repo switch failed:', err); setProgress({ phase: 'error', @@ -1124,6 +1119,36 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setViewMode('exploring'); setProgress(null); }, ERROR_RESET_DELAY_MS); + return; // Abort the whole switchRepo process + } + + if (pNameStr) { + // Persist the selected project in the URL so a refresh re-opens it + const urlObj = new URL(window.location.href); + urlObj.searchParams.set('project', pNameStr); + window.history.replaceState(null, '', urlObj.toString()); + } + + // Reset the agent and clear chat history so the AI starts fresh for the new repo + agentRef.current = null; + setIsAgentReady(false); + setChatMessages([]); + + // Re-initialize agent with the new repo's graph context + try { + if (getActiveProviderConfig()) { + await initializeAgent(pNameStr); + } + setViewMode('exploring'); + startEmbeddingsWithFallback(); + setProgress(null); + } catch (err) { + console.warn('Failed to initialize agent:', err); + setIsAgentReady(false); + agentRef.current = null; + setAgentError('Failed to initialize agent'); + setViewMode('exploring'); + setProgress(null); } }, [ @@ -1143,6 +1168,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setCodeReferences, setCodePanelOpen, setCodeReferenceFocus, + setChatMessages, ], ); diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index 49a521949..d7862a63b 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -386,10 +386,22 @@ export const fetchRepos = async (): Promise => { return response.json() as Promise; }; -/** Fetch repo metadata. */ -export const fetchRepoInfo = async (repo?: string): Promise => { +/** Fetch repo metadata. + * Pass `awaitAnalysis: true` when connecting to a repo that may still be cloning/analyzing — + * this enables the backend's hold-queue and uses a 5-minute timeout to match. + * Normal calls (e.g. repo switching between already-indexed repos) use the default 10s timeout. + * + * Must stay in sync with HOLD_QUEUE_TIMEOUT_SECS in gitnexus/src/server/api.ts. + */ +const HOLD_QUEUE_TIMEOUT_MS = 300_000; // 5 minutes — matches backend HOLD_QUEUE_TIMEOUT_SECS + +export const fetchRepoInfo = async ( + repo?: string, + opts?: { awaitAnalysis?: boolean }, +): Promise => { const url = `${_backendUrl}/api/repo${repo ? `?${repoParam(repo)}` : ''}`; - const response = await fetchWithTimeout(url); + const timeout = opts?.awaitAnalysis ? HOLD_QUEUE_TIMEOUT_MS : undefined; + const response = await fetchWithTimeout(url, {}, timeout); await assertOk(response); const data = await response.json(); return { ...data, repoPath: data.repoPath ?? data.path }; @@ -736,18 +748,21 @@ export interface ConnectResult { /** * Connect to a server: validate, fetch repo info, download graph. * Content is NOT included (use readFile/grep for file access). + * Pass `awaitAnalysis: true` when the repo may still be cloning/analyzing — + * this enables the backend hold-queue and a 5-minute fetch timeout. */ export async function connectToServer( url: string, onProgress?: (phase: string, downloaded: number, total: number | null) => void, signal?: AbortSignal, repoName?: string, + opts?: { awaitAnalysis?: boolean }, ): Promise { const baseUrl = normalizeServerUrl(url); setBackendUrl(baseUrl); onProgress?.('validating', 0, null); - const repoInfo = await fetchRepoInfo(repoName); + const repoInfo = await fetchRepoInfo(repoName, { awaitAnalysis: opts?.awaitAnalysis }); onProgress?.('downloading', 0, null); const { nodes, relationships } = await fetchGraph(repoName, { diff --git a/gitnexus/src/server/analyze-job.ts b/gitnexus/src/server/analyze-job.ts index a58cdc497..f7d97e97d 100644 --- a/gitnexus/src/server/analyze-job.ts +++ b/gitnexus/src/server/analyze-job.ts @@ -87,6 +87,11 @@ export class JobManager { return this.jobs.get(id); } + /** Return a snapshot of all tracked jobs for inspection. */ + listJobs(): AnalyzeJob[] { + return Array.from(this.jobs.values()); + } + updateJob( id: string, update: Partial< diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index bb223696a..3d4cf9a6a 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -480,12 +480,84 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => activeRepoPaths.delete(repoPath); }; - // Helper: resolve a repo by name from the global registry, or default to first - const resolveRepo = async (repoName?: string) => { + /** + * Maximum time the hold-queue will wait for an active analysis job to complete. + * Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout. + */ + const HOLD_QUEUE_TIMEOUT_SECS = 300; // 5 minutes + + // Helper: resolve a repo by name from the global registry, or default to first. + // Pass `req` to enable early exit if the client disconnects during the hold-queue wait. + const resolveRepo = async (repoName?: string, isRetry = false, req?: any): Promise => { const repos = await listRegisteredRepos(); - if (repos.length === 0) return null; - if (repoName) return repos.find((r) => r.name === repoName) || null; - return repos[0]; // default to first + let found = null; + + // Normalize: if a full path is passed, extract just the basename. + // e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli" + const normalizedName = repoName ? path.basename(repoName) : undefined; + + if (normalizedName) { + found = + repos.find((r) => r.name === normalizedName) || + repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) || + null; + } else if (repos.length > 0) { + found = repos[0]; // default to first repo + } + + // If not yet in the registry, check whether a background job is actively cloning or + // analyzing this repo. Hold the connection open (up to 5 minutes) until it completes. + // We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job + // whose repo is still missing means the registry sync failed; the fallback below handles it. + if (!found && normalizedName) { + const lower = normalizedName.toLowerCase(); + + // Track client disconnect to cancel the wait early + let clientGone = false; + req?.on('close', () => { + clientGone = true; + }); + + for (const job of jobManager.listJobs()) { + const isMatch = + job.repoName?.toLowerCase() === lower || + (job.repoUrl && path.basename(job.repoUrl).replace('.git', '').toLowerCase() === lower) || + (job.repoPath && path.basename(job.repoPath).toLowerCase() === lower); + + if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) { + if (process.env.DEBUG) { + console.log( + `[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`, + ); + } + for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) { + if (clientGone) return null; // client disconnected — stop polling + const currentJob = jobManager.getJob(job.id); + if (!currentJob || currentJob.status === 'failed') break; + if (currentJob.status === 'complete') { + await backend.init(); + const freshRepos = await listRegisteredRepos(); + return freshRepos.find((r) => r.name === normalizedName) || null; + } + await new Promise((r) => setTimeout(r, 1000)); + } + // Timed out — signal to the caller with a specific message + return { __timedOut: true, repoName: normalizedName }; + } + } + } + + // Emergency fallback: re-sync the registry to handle Windows file-system race conditions + // (e.g. registry file not yet flushed after clone completes). + if (!found && normalizedName && !isRetry) { + if (process.env.DEBUG) { + console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`); + } + await backend.init(); + return await resolveRepo(normalizedName, true, req); + } + + return found; }; // SSE heartbeat — clients connect to detect server liveness instantly. @@ -548,11 +620,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Get repo info app.get('/api/repo', async (req, res) => { try { - const entry = await resolveRepo(requestedRepo(req)); + const entry = await resolveRepo(requestedRepo(req), false, req); if (!entry) { res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' }); return; } + // Timed out waiting for an active analysis job + if (entry.__timedOut) { + res.status(503).json({ + error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`, + }); + return; + } const meta = await loadMeta(entry.storagePath); res.json({ name: entry.name,