Files
wursor/packages/app/src/hooks/useIndexer.ts
T
2026-05-03 20:34:18 +01:00

166 lines
5.7 KiB
TypeScript

'use client';
// ── useIndexer ────────────────────────────────────────────────────────────────
// Connects the canvas app to the CLI AST indexer server.
//
// On mount it:
// 1. Reads window.__OM_INDEX_URL__ injected by the CLI proxy. If absent,
// the indexer stays 'offline' and the hook is a no-op.
// 2. Fetches GET /health to hydrate projectMeta (framework, tailwind, etc.)
// and sets indexerStatus → 'ready'.
// 3. Opens a GET /events SSE subscription that updates indexerStatus to
// 'indexing' during re-scans and back to 'ready' when done.
//
// Exports fetchComponents(name) and fetchFile(path) for use by the Props tab
// and future Code tab.
import { useEffect, useRef, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import type { ProjectMeta } from '@/store/canvas';
interface ComponentEntry {
name: string;
filePath: string;
line: number;
cssImports: string[];
}
interface IndexerHealthResponse {
status: string;
projectMeta: ProjectMeta;
}
declare global {
interface Window {
__OM_INDEX_URL__?: string;
}
}
export function useIndexer() {
const { setIndexerStatus, setProjectMeta } = useCanvas();
const baseUrlRef = useRef<string | null>(null);
// ── 1. Detect indexer URL and fetch /health ───────────────────────────────
useEffect(() => {
const base = typeof window !== 'undefined' ? (window.__OM_INDEX_URL__ ?? null) : null;
if (!base) {
setIndexerStatus('offline');
return;
}
// Store for use by SSE effect and fetch helpers. Both effects run in the
// same render cycle so the ref is populated before the SSE effect opens.
baseUrlRef.current = base;
let cancelled = false;
async function fetchHealth() {
try {
const res = await fetch(`${base}/health`);
if (!res.ok) throw new Error(`/health ${res.status}`);
const data = await res.json() as IndexerHealthResponse;
if (!cancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
} catch (err) {
if (!cancelled) {
console.warn('[useIndexer] /health failed — indexer offline', err);
setIndexerStatus('offline');
}
}
}
void fetchHealth();
return () => { cancelled = true; };
}, [setIndexerStatus, setProjectMeta]);
// ── 2. SSE subscription for live re-index events ───────────────────────────
useEffect(() => {
// BUG-11 fix: read from ref, not window, for consistency and to avoid
// a second window access after the first effect already resolved the URL.
const base = baseUrlRef.current;
if (!base) return;
const es = new EventSource(`${base}/events`);
// BUG-1 fix: `onopen` fires on EVERY connection, including the very first
// one. We must distinguish initial open (health already fetched by the
// sibling useEffect above) from a *reconnection* after an error. Calling
// /health again on the initial open produces two concurrent fetches racing
// to set projectMeta/indexerStatus, and the second fetch has no cleanup.
let isFirstOpen = true;
// Track whether an onopen-triggered health fetch is in-flight so that the
// effect cleanup can cancel it if the component unmounts before it resolves.
let reopenCancelled = false;
es.addEventListener('INDEX_START', () => {
setIndexerStatus('indexing');
});
es.addEventListener('INDEX_UPDATED', () => {
setIndexerStatus('ready');
});
es.onerror = () => {
// Connection dropped — mark offline. The browser will auto-reconnect;
// onopen will fire again and we will re-sync health at that point.
setIndexerStatus('offline');
};
es.onopen = () => {
if (isFirstOpen) {
// Initial connection: health was already fetched by the sibling effect.
isFirstOpen = false;
return;
}
// Reconnection after an error — re-fetch /health to sync projectMeta
// (the CLI may have been restarted with a different project).
reopenCancelled = false;
void fetch(`${base}/health`)
.then(r => r.json() as Promise<IndexerHealthResponse>)
.then(data => {
if (!reopenCancelled) {
setProjectMeta(data.projectMeta);
setIndexerStatus('ready');
}
})
.catch(() => { if (!reopenCancelled) setIndexerStatus('offline'); });
};
return () => {
es.close();
reopenCancelled = true;
};
}, [setIndexerStatus, setProjectMeta]);
// ── 3. Data-fetching helpers exposed to consuming components ───────────────
/** Fetch all component entries matching a display name from the index. */
const fetchComponents = useCallback(async (name: string): Promise<ComponentEntry[]> => {
const base = baseUrlRef.current;
if (!base) return [];
try {
const res = await fetch(`${base}/components?name=${encodeURIComponent(name)}`);
if (!res.ok) return [];
return await res.json() as ComponentEntry[];
} catch {
return [];
}
}, []);
/** Fetch the raw source text of a file by absolute path. */
const fetchFile = useCallback(async (filePath: string): Promise<string | null> => {
const base = baseUrlRef.current;
if (!base) return null;
try {
const res = await fetch(`${base}/file?path=${encodeURIComponent(filePath)}`);
if (!res.ok) return null;
return await res.text();
} catch {
return null;
}
}, []);
return { fetchComponents, fetchFile };
}