mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
fix: resolve false 404s and stale repo context during multi-repo switching on Windows
This commit is contained in:
parent
153262304c
commit
faf63b4936
6 changed files with 222 additions and 83 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -386,10 +386,22 @@ export const fetchRepos = async (): Promise<BackendRepo[]> => {
|
|||
return response.json() as Promise<BackendRepo[]>;
|
||||
};
|
||||
|
||||
/** Fetch repo metadata. */
|
||||
export const fetchRepoInfo = async (repo?: string): Promise<BackendRepo> => {
|
||||
/** 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<BackendRepo> => {
|
||||
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<ConnectResult> {
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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<any> => {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue