diff --git a/graph-ui/src/hooks/useProjects.test.tsx b/graph-ui/src/hooks/useProjects.test.tsx new file mode 100644 index 000000000..1be534763 --- /dev/null +++ b/graph-ui/src/hooks/useProjects.test.tsx @@ -0,0 +1,105 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useProjects } from "./useProjects"; + +const { callToolMock } = vi.hoisted(() => ({ callToolMock: vi.fn() })); + +vi.mock("../api/rpc", () => ({ callTool: callToolMock })); + +function ProjectsHarness() { + const { projects } = useProjects(); + return {projects.length}; +} + +describe("useProjects", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("loads every list_projects page", async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + name: `project-${index + 1}`, + root_path: `/repo/project-${index + 1}`, + indexed_at: "2026-01-01T00:00:00Z", + })); + const finalProject = { + name: "project-51", + root_path: "/repo/project-51", + indexed_at: "2026-01-01T00:00:00Z", + }; + + callToolMock.mockImplementation( + async (name: string, args: Record = {}) => { + if (name === "list_projects") { + if (args.offset === 50) { + return { + projects: [finalProject], + total: 51, + offset: 50, + limit: 50, + returned: 1, + has_more: false, + }; + } + return { + projects: firstPage, + total: 51, + offset: 0, + limit: 50, + returned: 50, + has_more: true, + }; + } + return { + node_labels: [], + edge_types: [], + total_nodes: 0, + total_edges: 0, + }; + }, + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("project-count")).toHaveTextContent("51"); + }); + expect(callToolMock).toHaveBeenCalledWith("list_projects", { + offset: 50, + limit: 50, + }); + }); + + it("renders projects before bounded schema hydration completes", async () => { + const projects = Array.from({ length: 10 }, (_, index) => ({ + name: `project-${index + 1}`, + root_path: `/repo/project-${index + 1}`, + indexed_at: "2026-01-01T00:00:00Z", + })); + + callToolMock.mockImplementation(async (name: string) => { + if (name === "list_projects") { + return { + projects, + total: projects.length, + offset: 0, + limit: 50, + returned: projects.length, + has_more: false, + }; + } + return new Promise(() => {}); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("project-count")).toHaveTextContent("10"); + }); + const schemaCalls = callToolMock.mock.calls.filter(([name]) => name === "get_graph_schema"); + expect(schemaCalls).toHaveLength(4); + }); +}); diff --git a/graph-ui/src/hooks/useProjects.ts b/graph-ui/src/hooks/useProjects.ts index 5deff5215..dcd3777fe 100644 --- a/graph-ui/src/hooks/useProjects.ts +++ b/graph-ui/src/hooks/useProjects.ts @@ -14,6 +14,64 @@ interface UseProjectsResult { refresh: () => void; } +interface ProjectPage { + projects?: Project[]; + offset?: number; + returned?: number; + has_more?: boolean; +} + +const PROJECT_PAGE_SIZE = 50; +const SCHEMA_CONCURRENCY = 4; + +async function fetchAllProjects(): Promise { + const projects: Project[] = []; + let offset = 0; + + while (true) { + const page = await callTool("list_projects", { + offset, + limit: PROJECT_PAGE_SIZE, + }); + const pageProjects = page.projects ?? []; + projects.push(...pageProjects); + + if (!page.has_more) return projects; + + const pageOffset = Number.isSafeInteger(page.offset) ? page.offset! : offset; + const returned = Number.isSafeInteger(page.returned) ? page.returned! : pageProjects.length; + const nextOffset = pageOffset + returned; + if (returned <= 0 || nextOffset <= offset) { + throw new Error("list_projects pagination did not advance"); + } + offset = nextOffset; + } +} + +async function hydrateSchemas(infos: ProjectInfo[]): Promise { + let nextIndex = 0; + + async function worker() { + while (nextIndex < infos.length) { + const index = nextIndex++; + const project = infos[index].project; + try { + infos[index] = { + project, + schema: await callTool("get_graph_schema", { + project: project.name, + }), + }; + } catch { + infos[index] = { project, schema: null }; + } + } + } + + const workerCount = Math.min(SCHEMA_CONCURRENCY, infos.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); +} + export function useProjects(): UseProjectsResult { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); @@ -23,24 +81,12 @@ export function useProjects(): UseProjectsResult { setLoading(true); setError(null); try { - const result = await callTool<{ projects: Project[] }>("list_projects"); - const list = result.projects ?? []; - - /* Fetch schema for each project */ - const infos: ProjectInfo[] = await Promise.all( - list.map(async (p) => { - try { - const schema = await callTool("get_graph_schema", { - project: p.name, - }); - return { project: p, schema }; - } catch { - return { project: p, schema: null }; - } - }), - ); - + const list = await fetchAllProjects(); + const infos: ProjectInfo[] = list.map((project) => ({ project, schema: null })); setProjects(infos); + + await hydrateSchemas(infos); + setProjects([...infos]); } catch (e) { setError(e instanceof Error ? e.message : "Failed to fetch projects"); } finally {