'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(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) .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 => { 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 => { 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 }; }