From 3049ef4fbf63f0f438a31a9c0fca0c710ff03c6a Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Sun, 23 Aug 2026 19:51:53 -0400 Subject: [PATCH 1/2] Shrink the graph wire format, warm up the API at boot, and let the Files view hide externals Graph window responses can now use an opt-in indexed edge format that cuts payloads to about 30 percent of their old size. The API warms its graph client right after it starts listening, so the first query no longer pays the connection cost. The Files view gains a toggle to hide unresolved external modules, with honest totals and paging while filtered. Co-Authored-By: Claude Fable 5 --- .../api/src/__tests__/graph-route.test.ts | 230 +++++++++++++++- packages/api/src/__tests__/startup.test.ts | 100 +++++++ packages/api/src/index.ts | 6 + packages/api/src/routes/graph.ts | 144 +++++++++- .../src/components/dashboard/app-shell.tsx | 31 +++ .../dashboard/files-externals-toggle.test.tsx | 253 ++++++++++++++++++ .../src/components/dashboard/graph-canvas.tsx | 12 +- .../components/dashboard/graph-controls.tsx | 16 ++ .../dashboard/graph-window.test.tsx | 182 ++++++++++++- packages/dashboard/src/lib/graph-window.ts | 146 +++++++++- .../src/__tests__/full-graph-window.test.ts | 11 + .../graph-window-contract.integration.test.ts | 79 ++++++ packages/graph/src/queries.ts | 55 ++-- 13 files changed, 1217 insertions(+), 48 deletions(-) create mode 100644 packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx diff --git a/packages/api/src/__tests__/graph-route.test.ts b/packages/api/src/__tests__/graph-route.test.ts index 846feb0..5c98bfd 100644 --- a/packages/api/src/__tests__/graph-route.test.ts +++ b/packages/api/src/__tests__/graph-route.test.ts @@ -302,6 +302,32 @@ describe('GET /api/graph/full unavailable storage', () => { storage: blockedSetupStatus.storage, }); }); + + it('preserves the requested compact edge format while storage is blocked', async () => { + mockedFullGraph.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379')); + mockedGetSetupStatus.mockResolvedValue(blockedSetupStatus); + + const response = await graphRoutes.request('/api/graph/full?limit=100&edgeFormat=indexed-v1'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + nodes: [], + edgeFormat: 'indexed-v1', + edgeTypes: [], + edges: [], + totalNodes: 0, + totalEdges: 0, + windowOrder: 'degree-desc,id-asc', + degreeScope: 'global', + offset: 0, + limit: 100, + returned: 0, + hasMore: false, + nextOffset: null, + truncated: false, + storage: blockedSetupStatus.storage, + }); + }); }); describe('GET /api/graph/files', () => { @@ -351,7 +377,7 @@ describe('GET /api/graph/files', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual(fileGraphResult); - expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0); + expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0, true); }); it.each(['NaN', 'Infinity', '-1', '1.5'])( @@ -367,7 +393,39 @@ describe('GET /api/graph/files', () => { it('forwards a positive file graph offset', async () => { await graphRoutes.request('/api/graph/files?limit=50&offset=3000'); - expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000); + expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000, true); + }); + + it.each([ + ['true', true], + ['false', false], + ])('forwards includeExternals=%s as %s', async (raw, expected) => { + const response = await graphRoutes.request(`/api/graph/files?includeExternals=${raw}`); + + expect(response.status).toBe(200); + expect(getFileGraph).toHaveBeenCalledWith(100, undefined, 0, expected); + }); + + it('rejects repeated includeExternals values', async () => { + const result = await errorFor( + '/api/graph/files?includeExternals=true&includeExternals=true', + ); + + expect(result).toEqual({ + status: 400, + error: 'includeExternals must be supplied once', + }); + expect(getFileGraph).not.toHaveBeenCalled(); + }); + + it.each(['TRUE', 'False', '1', '', 'yes'])('rejects includeExternals=%j', async (value) => { + const result = await errorFor(`/api/graph/files?includeExternals=${encodeURIComponent(value)}`); + + expect(result).toEqual({ + status: 400, + error: 'includeExternals must be true or false', + }); + expect(getFileGraph).not.toHaveBeenCalled(); }); it('does not return the global file graph for an unknown projectId', async () => { @@ -475,6 +533,174 @@ describe('POST /api/graph/induced-edges', () => { }); }); +describe('indexed-v1 edge transport', () => { + const fullGraphResult = { + nodes: [ + { id: 'node-a', label: 'Function', displayName: 'a', degree: 2 }, + { id: 'node-b', label: 'Function', displayName: 'b', degree: 2 }, + { id: 'node-c', label: 'Function', displayName: 'c', degree: 1 }, + ], + edges: [ + { source: 'node-a', target: 'node-b', label: 'CALLS' }, + { source: 'node-b', target: 'node-c', label: 'USES_TYPE' }, + { source: 'node-a', target: 'node-c', label: 'CALLS' }, + ], + totalNodes: 3, + totalEdges: 3, + windowOrder: 'degree-desc,id-asc' as const, + degreeScope: 'global' as const, + offset: 0, + limit: 100, + returned: 3, + hasMore: false, + nextOffset: null, + truncated: false, + }; + const getFileGraph = vi.fn(); + const getInducedEdges = vi.fn(); + const getNodeNeighbors = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedFullGraph.mockResolvedValue(fullGraphResult as never); + mockedGetGraphClient.mockResolvedValue({} as never); + getFileGraph.mockResolvedValue({ + ...fullGraphResult, + nodes: fullGraphResult.nodes.map((node) => ({ + id: node.id, + displayName: node.displayName, + filePath: `/${node.id}.ts`, + symbolCount: 1, + label: 'File', + })), + }); + getInducedEdges.mockResolvedValue(fullGraphResult.edges); + getNodeNeighbors.mockResolvedValue({ + centerId: 'node-a', + nodes: fullGraphResult.nodes, + edges: fullGraphResult.edges, + incomingTruncated: false, + outgoingTruncated: false, + limit: 100, + }); + mockedCreateQueries.mockReturnValue({ getFileGraph, getInducedEdges, getNodeNeighbors } as never); + }); + + it('compacts full graph edges against response nodes and first-seen edge types', async () => { + const legacy = await graphRoutes.request('/api/graph/full'); + const compact = await graphRoutes.request('/api/graph/full?edgeFormat=indexed-v1'); + const legacyBody = await legacy.json(); + const compactBody = await compact.json(); + + expect(compactBody).toEqual({ + ...fullGraphResult, + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + }); + expect(JSON.stringify(compactBody).length).toBeLessThan(JSON.stringify(legacyBody).length); + }); + + it('compacts file graph edges without changing file metadata', async () => { + const response = await graphRoutes.request('/api/graph/files?edgeFormat=indexed-v1'); + const body = await response.json(); + + expect(body).toMatchObject({ + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + totalNodes: 3, + windowOrder: 'degree-desc,id-asc', + }); + expect(getFileGraph).toHaveBeenCalledWith(100, undefined, 0, true); + }); + + it('compacts neighbor edges against the neighbor node table', async () => { + const response = await graphRoutes.request( + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1', + ); + + expect(await response.json()).toEqual({ + centerId: 'node-a', + nodes: fullGraphResult.nodes, + edgeFormat: 'indexed-v1', + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + incomingTruncated: false, + outgoingTruncated: false, + limit: 100, + }); + }); + + it('compacts induced edges using only first-seen returned endpoint ids', async () => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ids: ['node-a', 'node-b', 'node-c', 'unknown'], + edgeFormat: 'indexed-v1', + }), + }); + + expect(await response.json()).toEqual({ + edgeFormat: 'indexed-v1', + nodeIds: ['node-a', 'node-b', 'node-c'], + edgeTypes: ['CALLS', 'USES_TYPE'], + edges: [[0, 1, 0], [1, 2, 1], [0, 2, 0]], + }); + }); + + it.each([ + '/api/graph/full?edgeFormat=other', + '/api/graph/files?edgeFormat=other', + '/api/graph/neighbors?id=node-a&edgeFormat=other', + ])('rejects an unsupported edge format on %s', async (path) => { + const result = await errorFor(path); + + expect(result).toEqual({ status: 400, error: 'edgeFormat must be indexed-v1' }); + }); + + it.each([ + [ + 'full graph', + '/api/graph/full?edgeFormat=indexed-v1', + '/api/graph/full?edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + [ + 'file graph', + '/api/graph/files?edgeFormat=indexed-v1', + '/api/graph/files?edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + [ + 'neighbors', + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1', + '/api/graph/neighbors?id=node-a&edgeFormat=indexed-v1&edgeFormat=indexed-v1', + ], + ])('accepts one edgeFormat and rejects repeated values for %s', async ( + _name, + singlePath, + repeatedPath, + ) => { + const single = await graphRoutes.request(singlePath); + const repeated = await errorFor(repeatedPath); + + expect(single.status).toBe(200); + expect(repeated).toEqual({ status: 400, error: 'edgeFormat must be supplied once' }); + }); + + it('rejects an unsupported induced edge format before querying', async () => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: ['node-a'], edgeFormat: 'other' }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'edgeFormat must be indexed-v1' }); + expect(getInducedEdges).not.toHaveBeenCalled(); + }); +}); + describe('GET /api/graph/neighbors', () => { const getNodeNeighbors = vi.fn(); const neighborResult = { diff --git a/packages/api/src/__tests__/startup.test.ts b/packages/api/src/__tests__/startup.test.ts index 19c342f..daeb532 100644 --- a/packages/api/src/__tests__/startup.test.ts +++ b/packages/api/src/__tests__/startup.test.ts @@ -11,6 +11,28 @@ const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), '../.. const workspaceDirectory = resolve(packageDirectory, '../..'); const temporaryDirectories: string[] = []; +async function reserveUnusedPort(): Promise { + const reservation = createServer(); + await new Promise((resolvePromise, reject) => { + reservation.once('error', reject); + reservation.listen({ port: 0, host: API_BIND_HOST }, resolvePromise); + }); + + const address = reservation.address(); + if (address === null || typeof address === 'string') { + reservation.close(); + throw new Error('Could not reserve an unused local port'); + } + + await new Promise((resolvePromise, reject) => { + reservation.close((error) => { + if (error) reject(error); + else resolvePromise(); + }); + }); + return address.port; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => @@ -20,6 +42,84 @@ afterEach(async () => { }); describe('API server startup', () => { + it('stays available after an unreachable graph backend rejects the post-listen warmup', async () => { + const apiPort = await reserveUnusedPort(); + const unreachableGraphPort = await reserveUnusedPort(); + const stateDirectory = await mkdtemp(join(tmpdir(), 'codegraph-api-warmup-')); + temporaryDirectories.push(stateDirectory); + const child = spawn( + process.execPath, + ['--import', 'tsx', 'packages/api/src/index.ts'], + { + cwd: workspaceDirectory, + env: { + ...process.env, + API_PORT: String(apiPort), + CODEGRAPH_DATA_DIR: stateDirectory, + CODEGRAPH_DB_PATH: join(stateDirectory, 'db'), + CODEGRAPH_DRIVER: 'falkordb', + FALKORDB_URL: `${API_BIND_HOST}:${unreachableGraphPort}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let stdout = ''; + let stderr = ''; + const listeningLine = `CodeGraph API server running on http://localhost:${apiPort}`; + const warmupWarning = + '[codegraph] Graph warmup did not complete; graph requests will retry through storage state.\n'; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + try { + await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + reject(new Error('API server did not emit the graph warmup warning after listening')); + }, 10_000); + const inspectOutput = (): void => { + if (stdout.includes(listeningLine) && stderr.includes(warmupWarning)) { + clearTimeout(timeout); + resolvePromise(); + } + }; + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + inspectOutput(); + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + inspectOutput(); + }); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', (code, signal) => { + clearTimeout(timeout); + reject(new Error(`API server exited during graph warmup: code=${String(code)} signal=${String(signal)}`)); + }); + }); + + const healthResponse = await fetch(`http://${API_BIND_HOST}:${apiPort}/health`); + expect(healthResponse.status).toBe(200); + expect(child.exitCode).toBeNull(); + expect(child.signalCode).toBeNull(); + expect(stdout).toContain(listeningLine); + expect(stderr).toBe(warmupWarning); + expect(stderr).not.toContain('UnhandledPromiseRejection'); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + await new Promise((resolvePromise) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolvePromise(); + return; + } + child.once('exit', () => resolvePromise()); + }); + } + }, 15_000); + it('reports an occupied API bind address without an unhandled stack trace', async () => { const blockingServer = createServer(); await new Promise((resolvePromise, reject) => { diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7cbf2d5..ecd8b29 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -6,6 +6,7 @@ */ import { createAdaptorServer } from '@hono/node-server'; +import { getGraphClient } from '@codegraph/core'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { readFile } from 'node:fs/promises'; @@ -115,6 +116,11 @@ server.listen(port, API_BIND_HOST, () => { server.close(); return; } + void getGraphClient() + .then((client) => client.roQuery('RETURN 1 AS warmup')) + .catch(() => { + console.warn('[codegraph] Graph warmup did not complete; graph requests will retry through storage state.'); + }); console.log(`CodeGraph API server running on http://localhost:${info.port}`); console.log(` Config: ${loadedEnvFile ?? 'process environment only (no .env found)'}`); console.log( diff --git a/packages/api/src/routes/graph.ts b/packages/api/src/routes/graph.ts index 76460ee..f653182 100644 --- a/packages/api/src/routes/graph.ts +++ b/packages/api/src/routes/graph.ts @@ -19,6 +19,14 @@ type BoundedIntegerResult = | { valid: true; value?: number } | { valid: false; error: string }; +type IndexedEdgeFormat = 'indexed-v1'; +type CompactEdgeTuple = [sourceNodeIndex: number, targetNodeIndex: number, edgeTypeIndex: number]; +type EdgeTransport = { source: string; target: string; label: string }; + +type EdgeFormatResult = + | { valid: true; value?: IndexedEdgeFormat } + | { valid: false; error: string }; + function boundedPositiveInteger( raw: string | undefined, name: string, @@ -41,6 +49,22 @@ function nonNegativeInteger(raw: string | undefined, name: string): BoundedInteg return { valid: true, value }; } +function parseEdgeFormat(raw: unknown): EdgeFormatResult { + if (raw === undefined) return { valid: true }; + if (raw === 'indexed-v1') return { valid: true, value: raw }; + return { valid: false, error: 'edgeFormat must be indexed-v1' }; +} + +function parseIncludeExternals(raw: string | undefined): boolean | null { + if (raw === undefined || raw === 'true') return true; + if (raw === 'false') return false; + return null; +} + +function hasRepeatedQueryValue(values: readonly string[] | undefined): boolean { + return values !== undefined && values.length > 1; +} + async function resolveProjectRootPath(projectId: string): Promise { const client = await getGraphClient(); const projectResult = await client.roQuery<{ rootPath: string | null }>( @@ -51,9 +75,9 @@ async function resolveProjectRootPath(projectId: string): Promise } function projectFullGraphResponse; + edges: EdgeTransport[]; }>(data: T): Omit & { - edges: Array<{ source: string; target: string; label: string }>; + edges: EdgeTransport[]; } { return { ...data, @@ -61,11 +85,88 @@ function projectFullGraphResponse, value: string): number { + const index = indexes.get(value); + if (index === undefined) throw new Error(`Compact edge endpoint is absent from its node table: ${value}`); + return index; +} + +function compactEdges( + edges: readonly EdgeTransport[], + nodeIds: readonly string[], +): { edgeTypes: string[]; edges: CompactEdgeTuple[] } { + const nodeIndexes = new Map(nodeIds.map((id, index) => [id, index])); + const edgeTypes: string[] = []; + const edgeTypeIndexes = new Map(); + const compact = edges.map((edge): CompactEdgeTuple => { + let edgeTypeIndex = edgeTypeIndexes.get(edge.label); + if (edgeTypeIndex === undefined) { + edgeTypeIndex = edgeTypes.length; + edgeTypes.push(edge.label); + edgeTypeIndexes.set(edge.label, edgeTypeIndex); + } + return [ + requiredTableIndex(nodeIndexes, edge.source), + requiredTableIndex(nodeIndexes, edge.target), + edgeTypeIndex, + ]; + }); + return { edgeTypes, edges: compact }; +} + +function projectIndexedNodeEdges; + edges: EdgeTransport[]; +}>(data: T): Omit & { + edgeFormat: IndexedEdgeFormat; + edgeTypes: string[]; + edges: CompactEdgeTuple[]; +} { + const compact = compactEdges(data.edges, data.nodes.map((node) => node.id)); + return { + ...data, + edgeFormat: 'indexed-v1', + edgeTypes: compact.edgeTypes, + edges: compact.edges, + }; +} + +function projectIndexedInducedEdges(edges: EdgeTransport[]): { + edgeFormat: IndexedEdgeFormat; + nodeIds: string[]; + edgeTypes: string[]; + edges: CompactEdgeTuple[]; +} { + const nodeIds: string[] = []; + const seenNodeIds = new Set(); + for (const edge of edges) { + for (const nodeId of [edge.source, edge.target]) { + if (seenNodeIds.has(nodeId)) continue; + seenNodeIds.add(nodeId); + nodeIds.push(nodeId); + } + } + const compact = compactEdges(edges, nodeIds); + return { + edgeFormat: 'indexed-v1', + nodeIds, + edgeTypes: compact.edgeTypes, + edges: compact.edges, + }; +} + /** GET /api/graph/full?limit=N&offset=N&projectId=X returns a degree-ordered page with scoped totals. */ graphRoutes.get('/api/graph/full', async (c) => { let limit = 100; let offset = 0; + let edgeFormat: IndexedEdgeFormat | undefined; try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); + edgeFormat = parsedEdgeFormat.value; const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); @@ -78,18 +179,21 @@ graphRoutes.get('/api/graph/full', async (c) => { const rootPath = await resolveProjectRootPath(projectId); if (!rootPath) return c.json({ error: 'Project not found' }, 404); const data = await codeGraphService.getFullGraph(limit, rootPath, offset); - return c.json(projectFullGraphResponse(data)); + const projected = projectFullGraphResponse(data); + return c.json(edgeFormat ? projectIndexedNodeEdges(projected) : projected); } // No project filter — return all const rootPath = c.req.query('rootPath') ?? undefined; const data = await codeGraphService.getFullGraph(limit, rootPath, offset); - return c.json(projectFullGraphResponse(data)); + const projected = projectFullGraphResponse(data); + return c.json(edgeFormat ? projectIndexedNodeEdges(projected) : projected); } catch (error) { const setup = await readBlockedSetupStatus(); if (setup !== null) { return c.json({ nodes: [], + ...(edgeFormat ? { edgeFormat, edgeTypes: [] } : {}), edges: [], totalNodes: 0, totalEdges: 0, @@ -111,6 +215,18 @@ graphRoutes.get('/api/graph/full', async (c) => { /** GET /api/graph/files?projectId=X&limit=N&offset=N - bounded File-to-File IMPORTS graph. */ graphRoutes.get('/api/graph/files', async (c) => { try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + if (hasRepeatedQueryValue(c.req.queries('includeExternals'))) { + return c.json({ error: 'includeExternals must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); + const includeExternals = parseIncludeExternals(c.req.query('includeExternals')); + if (includeExternals === null) { + return c.json({ error: 'includeExternals must be true or false' }, 400); + } const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FILE_GRAPH_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); @@ -127,8 +243,8 @@ graphRoutes.get('/api/graph/files', async (c) => { } const client = await getGraphClient(); - const data = await createQueries(client).getFileGraph(limit, rootPath, offset); - return c.json(data); + const data = await createQueries(client).getFileGraph(limit, rootPath, offset, includeExternals); + return c.json(parsedEdgeFormat.value ? projectIndexedNodeEdges(data) : data); } catch (error) { return c.json({ error: safeErrorMessage('GET /api/graph/files', error, 'Failed to fetch file graph.'), @@ -152,6 +268,8 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { if (body.ids.length > INDUCED_EDGE_IDS_MAX) { return c.json({ error: `ids must contain at most ${INDUCED_EDGE_IDS_MAX} items` }, 400); } + const parsedEdgeFormat = parseEdgeFormat('edgeFormat' in body ? body.edgeFormat : undefined); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); const projectId = c.req.query('projectId'); let rootPath: string | undefined; @@ -163,9 +281,10 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { const client = await getGraphClient(); const edges = await createQueries(client).getInducedEdges(body.ids, rootPath); - return c.json({ - edges: edges.map(({ source, target, label }) => ({ source, target, label })), - }); + const projected = edges.map(({ source, target, label }) => ({ source, target, label })); + return c.json(parsedEdgeFormat.value + ? projectIndexedInducedEdges(projected) + : { edges: projected }); } catch (error) { return c.json({ error: safeErrorMessage( @@ -180,6 +299,11 @@ graphRoutes.post('/api/graph/induced-edges', async (c) => { /** GET /api/graph/neighbors?id=X&limit=N - direct neighbors and their induced graph. */ graphRoutes.get('/api/graph/neighbors', async (c) => { try { + if (hasRepeatedQueryValue(c.req.queries('edgeFormat'))) { + return c.json({ error: 'edgeFormat must be supplied once' }, 400); + } + const parsedEdgeFormat = parseEdgeFormat(c.req.query('edgeFormat')); + if (!parsedEdgeFormat.valid) return c.json({ error: parsedEdgeFormat.error }, 400); const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', NEIGHBOR_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); @@ -189,7 +313,7 @@ graphRoutes.get('/api/graph/neighbors', async (c) => { const client = await getGraphClient(); const data = await createQueries(client).getNodeNeighbors(id, parsedLimit.value ?? 100); if (!data) return c.json({ error: 'Graph node not found' }, 404); - return c.json(data); + return c.json(parsedEdgeFormat.value ? projectIndexedNodeEdges(data) : data); } catch (error) { return c.json({ error: safeErrorMessage('GET /api/graph/neighbors', error, 'Failed to fetch node neighbors.'), diff --git a/packages/dashboard/src/components/dashboard/app-shell.tsx b/packages/dashboard/src/components/dashboard/app-shell.tsx index 92449b0..75ef6c2 100644 --- a/packages/dashboard/src/components/dashboard/app-shell.tsx +++ b/packages/dashboard/src/components/dashboard/app-shell.tsx @@ -17,7 +17,9 @@ import { appendGraphExpansion, DEFAULT_GRAPH_VIEW, fetchGraphNodeDetail, + persistGraphExternalsState, persistGraphViewState, + readGraphExternalsState, readGraphViewState, resetGraphView, type GraphCanvasViewState, @@ -230,6 +232,15 @@ export function AppShell({ return { ...DEFAULT_GRAPH_VIEW, fileScope: null, expansions: [] } } }) + const [includeExternals, setIncludeExternals] = useState(() => { + if (typeof window === 'undefined') return true + try { + return readGraphExternalsState(window.location, window.localStorage) + } catch (error) { + console.warn('Unable to read the saved graph externals preference', error) + return true + } + }) const [expansionRequest, setExpansionRequest] = useState<{ node: GraphNode; sequence: number } | null>(null) const [selectionHistory, setSelectionHistory] = useState(EMPTY_SELECTION_HISTORY) const canvasViewRef = useRef(canvasView) @@ -255,6 +266,19 @@ export function AppShell({ } }, [canvasView]) + useEffect(() => { + try { + persistGraphExternalsState( + includeExternals, + window.location, + window.history, + window.localStorage, + ) + } catch (error) { + console.warn('Unable to persist the graph externals preference', error) + } + }, [includeExternals]) + const handleNodeSelect = useCallback((node: GraphNode | null) => { setSelectionHistory((history) => pushSelectionHistory(history, node, canvasViewRef.current)) }, []) @@ -311,6 +335,11 @@ export function AppShell({ recordCanvasView({ ...canvasView, mode, offset: 0, fileScope: null, expansions: [] }) }, [canvasView, recordCanvasView]) + const handleIncludeExternalsChange = useCallback((value: boolean) => { + setIncludeExternals(value) + recordCanvasView({ ...canvasView, offset: 0, fileScope: null, expansions: [] }) + }, [canvasView, recordCanvasView]) + const handleWindowLimitChange = useCallback((limit: GraphWindowLimit) => { recordCanvasView({ ...canvasView, limit, offset: 0, expansions: [] }) }, [canvasView, recordCanvasView]) @@ -474,11 +503,13 @@ export function AppShell({ projectId={projectId} referenceNodeIds={referenceNodeIds} mode={canvasView.mode} + includeExternals={includeExternals} windowLimit={canvasView.limit} pageOffset={canvasView.offset} fileScope={canvasView.fileScope} restoredExpansions={canvasView.expansions} onModeChange={handleModeChange} + onIncludeExternalsChange={handleIncludeExternalsChange} onWindowLimitChange={handleWindowLimitChange} onPageChange={handlePageChange} expansionRequest={expansionRequest} diff --git a/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx b/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx new file mode 100644 index 0000000..62735ec --- /dev/null +++ b/packages/dashboard/src/components/dashboard/files-externals-toggle.test.tsx @@ -0,0 +1,253 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type cytoscape from 'cytoscape' +import type { GraphWindow } from '@/lib/graph-window' + +const graphWindowMocks = vi.hoisted(() => ({ + fetchGraphWindow: vi.fn(), + fetchGraphInducedEdges: vi.fn(), + readGraphExternalsState: vi.fn(), + persistGraphExternalsState: vi.fn(), +})) + +vi.mock('@/lib/graph-window', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchGraphWindow: graphWindowMocks.fetchGraphWindow, + fetchGraphInducedEdges: graphWindowMocks.fetchGraphInducedEdges, + readGraphExternalsState: ( + ...args: Parameters + ): boolean => { + graphWindowMocks.readGraphExternalsState(...args) + return actual.readGraphExternalsState(...args) + }, + persistGraphExternalsState: ( + ...args: Parameters + ): void => { + graphWindowMocks.persistGraphExternalsState(...args) + actual.persistGraphExternalsState(...args) + }, + } +}) + +vi.mock('cytoscape', async (importOriginal) => { + const actual = await importOriginal() + const defaultExport: unknown = Reflect.get(actual, 'default') + const factory = typeof defaultExport === 'function' + ? defaultExport as typeof actual + : actual + return { + ...actual, + default: (options: cytoscape.CytoscapeOptions): cytoscape.Core => factory({ + ...options, + container: undefined, + headless: true, + style: [], + }), + } +}) + +import { AppShell } from './app-shell' +import { GraphCanvas } from './graph-canvas' +import { GraphControls } from './graph-controls' + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true + +interface GraphWindowRequest { + offset?: number + includeExternals?: boolean +} + +function graphWindowAt(offset: number): GraphWindow { + return { + nodes: [], + edges: [], + totalNodes: 600, + totalEdges: 0, + windowOrder: 'degree-desc,id-asc', + truncation: { incoming: false, outgoing: false }, + offset, + limit: 300, + returned: 0, + hasMore: offset < 300, + nextOffset: offset < 300 ? 300 : null, + } +} + +async function render(element: React.ReactNode): Promise<{ + container: HTMLDivElement + root: Root +}> { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + await act(async () => root.render(element)) + return { container, root } +} + +beforeEach(() => { + window.localStorage.clear() + window.history.replaceState(null, '', '/') + graphWindowMocks.fetchGraphWindow.mockReset() + graphWindowMocks.fetchGraphWindow.mockImplementation( + async (request: GraphWindowRequest) => graphWindowAt(request.offset ?? 0), + ) + graphWindowMocks.fetchGraphInducedEdges.mockReset() + graphWindowMocks.fetchGraphInducedEdges.mockResolvedValue([]) + graphWindowMocks.readGraphExternalsState.mockClear() + graphWindowMocks.persistGraphExternalsState.mockClear() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('Files externals control', () => { + it('renders only for Files with its pressed state and inverts the value on activation', async () => { + const onIncludeExternalsChange = vi.fn() + const filesHtml = renderToStaticMarkup( + , + ) + const symbolsHtml = renderToStaticMarkup( + , + ) + + expect(filesHtml).toMatch(/]*aria-pressed="false"[^>]*aria-label="Show unresolved external modules"/) + expect(filesHtml).toContain('focus-visible:ring') + expect(symbolsHtml).not.toContain('Show unresolved external modules') + + const view = await render( + , + ) + const button = view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + ) + expect(button).toBeInstanceOf(HTMLButtonElement) + await act(async () => button?.click()) + expect(onIncludeExternalsChange).toHaveBeenCalledWith(true) + await act(async () => view.root.unmount()) + }) + + it('restores URL preference over storage, persists changes, and refetches from offset zero', async () => { + window.localStorage.setItem('codegraph.graphIncludeExternals', 'true') + window.history.replaceState( + null, + '', + '/?graphMode=files&graphOffset=300&graphExternals=false', + ) + + const view = await render() + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 300, + includeExternals: false, + }), + )) + }) + expect(graphWindowMocks.readGraphExternalsState).toHaveBeenCalled() + expect(view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + )?.getAttribute('aria-pressed')).toBe('false') + + await act(async () => { + view.container.querySelector( + '[aria-label="Show unresolved external modules"]', + )?.click() + }) + + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 0, + includeExternals: true, + }), + )) + }) + expect(graphWindowMocks.persistGraphExternalsState).toHaveBeenLastCalledWith( + true, + window.location, + window.history, + window.localStorage, + ) + expect(new URL(window.location.href).searchParams.get('graphExternals')).toBe('true') + expect(window.localStorage.getItem('codegraph.graphIncludeExternals')).toBe('true') + await act(async () => view.root.unmount()) + }) + + it('passes includeExternals on initial and Load More graph requests', async () => { + const view = await render( + , + ) + + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 0, + includeExternals: false, + }), + )) + }) + const loadMore = view.container.querySelector('[aria-label^="Load next"]') + expect(loadMore?.disabled).toBe(false) + await act(async () => loadMore?.click()) + await act(async () => { + await vi.waitFor(() => expect(graphWindowMocks.fetchGraphWindow).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'files', + offset: 300, + includeExternals: false, + }), + )) + }) + await act(async () => view.root.unmount()) + }) +}) diff --git a/packages/dashboard/src/components/dashboard/graph-canvas.tsx b/packages/dashboard/src/components/dashboard/graph-canvas.tsx index e89c5f8..791e164 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas.tsx +++ b/packages/dashboard/src/components/dashboard/graph-canvas.tsx @@ -111,10 +111,12 @@ interface GraphCanvasProps { hiddenNodeTypes: Set projectId?: string | null mode?: GraphViewMode + includeExternals?: boolean windowLimit?: GraphWindowLimit pageOffset?: number fileScope?: GraphNode | null onModeChange?: (mode: GraphViewMode) => void + onIncludeExternalsChange?: (value: boolean) => void onWindowLimitChange?: (limit: GraphWindowLimit) => void onPageChange?: (offset: number) => void expansionRequest?: { node: GraphNode; sequence: number } | null @@ -133,10 +135,12 @@ export function GraphCanvas({ hiddenNodeTypes, projectId, mode = 'symbols', + includeExternals = true, windowLimit = 300, pageOffset = 0, fileScope = null, onModeChange, + onIncludeExternalsChange, onWindowLimitChange, onPageChange, expansionRequest = null, @@ -233,6 +237,7 @@ export function GraphCanvas({ mode, limit: windowLimit, offset: pageOffset, + includeExternals, projectId, fileScope, }) @@ -362,7 +367,7 @@ export function GraphCanvas({ graphWindowRef.current = null baseWindowRef.current = null } - }, [apiUrl, fileScope, mode, onNodeSelect, pageOffset, projectId, windowLimit]) + }, [apiUrl, fileScope, includeExternals, mode, onNodeSelect, pageOffset, projectId, windowLimit]) useEffect(() => { if ( @@ -606,6 +611,7 @@ export function GraphCanvas({ mode, limit: windowLimit, offset: current.nextOffset, + includeExternals, projectId, signal: controller.signal, }) @@ -649,7 +655,7 @@ export function GraphCanvas({ setLoadingMore(false) } } - }, [apiUrl, fileScope, mode, projectId, windowLimit]) + }, [apiUrl, fileScope, includeExternals, mode, projectId, windowLimit]) const handleRelayout = useCallback((newLayout?: LayoutName) => { const l = newLayout ?? layout @@ -755,6 +761,8 @@ export function GraphCanvas({ onWindowLimitChange={onWindowLimitChange} mode={mode} onModeChange={onModeChange} + includeExternals={includeExternals} + onIncludeExternalsChange={onIncludeExternalsChange} canReset={graphWindow !== null && baseWindow !== null && (graphWindow !== baseWindow || pageOffset > 0)} pageOffset={graphWindow?.offset ?? pageOffset} pageReturned={graphWindow?.returned ?? nodeCount} diff --git a/packages/dashboard/src/components/dashboard/graph-controls.tsx b/packages/dashboard/src/components/dashboard/graph-controls.tsx index 232983f..adfd46f 100644 --- a/packages/dashboard/src/components/dashboard/graph-controls.tsx +++ b/packages/dashboard/src/components/dashboard/graph-controls.tsx @@ -16,6 +16,8 @@ interface GraphControlsProps { onWindowLimitChange?: (limit: GraphWindowLimit) => void mode?: GraphViewMode onModeChange?: (mode: GraphViewMode) => void + includeExternals?: boolean + onIncludeExternalsChange?: (value: boolean) => void canReset?: boolean pageOffset?: number pageReturned?: number @@ -51,6 +53,8 @@ export function GraphControls({ onWindowLimitChange, mode = 'symbols', onModeChange, + includeExternals = true, + onIncludeExternalsChange, canReset = false, pageOffset = 0, pageReturned = nodeCount, @@ -92,6 +96,18 @@ export function GraphControls({ ))} + {mode === 'files' && ( + + )}