diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 97af8d14c..83dafb016 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,21 +102,26 @@ 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. + // Clean the "server" param so a refresh won't re-trigger it redundantly, + // but keep the "project" param so the user has a shareable URL. + if (serverUrlParam) { + params.delete('server'); + const newSearch = params.toString(); + const cleanUrl = + window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash; + window.history.replaceState(null, '', cleanUrl); + } setProgress({ phase: 'extracting', @@ -123,39 +131,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 256219eb6..10e87158f 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 }; @@ -670,18 +682,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 00527357a..b8ba83a3b 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -311,12 +311,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. @@ -379,11 +451,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,