diff --git a/packages/api/src/__tests__/graph-route.test.ts b/packages/api/src/__tests__/graph-route.test.ts new file mode 100644 index 0000000..a1b557a --- /dev/null +++ b/packages/api/src/__tests__/graph-route.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@codegraph/core', () => ({ + codeGraphService: { + getFullGraph: vi.fn(), + getFileSubgraph: vi.fn(), + getSymbolReferences: vi.fn(), + getDependencyTree: vi.fn(), + }, + getGraphClient: vi.fn(), +})); + +vi.mock('@codegraph/graph', () => ({ + createQueries: vi.fn(), +})); + +import { codeGraphService, getGraphClient } from '@codegraph/core'; +import { createQueries } from '@codegraph/graph'; +import { graphRoutes } from '../routes/graph'; + +const mockedFullGraph = vi.mocked(codeGraphService.getFullGraph); +const mockedReferences = vi.mocked(codeGraphService.getSymbolReferences); +const mockedDependencies = vi.mocked(codeGraphService.getDependencyTree); +const mockedGetGraphClient = vi.mocked(getGraphClient); +const mockedCreateQueries = vi.mocked(createQueries); + +async function errorFor(path: string): Promise<{ status: number; error: string }> { + const response = await graphRoutes.request(path); + const body = (await response.json()) as { error: string }; + return { status: response.status, error: body.error }; +} + +describe('graph route numeric boundaries', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedFullGraph.mockResolvedValue({ nodes: [], edges: [] }); + mockedReferences.mockResolvedValue({ references: [], referencingFiles: [], truncated: false }); + mockedDependencies.mockResolvedValue({ nodes: [], edges: [] }); + }); + + it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])( + 'rejects full graph limit=%s before touching the graph', + async (limit) => { + const result = await errorFor(`/api/graph/full?limit=${limit}`); + + expect(result.status).toBe(400); + expect(result.error).toBe('limit must be a positive integer between 1 and 1000'); + expect(mockedFullGraph).not.toHaveBeenCalled(); + }, + ); + + it('accepts the full graph upper limit', async () => { + const response = await graphRoutes.request('/api/graph/full?limit=1000'); + + expect(response.status).toBe(200); + expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined); + }); + + it.each(['NaN', 'Infinity', '0', '-1', '1.5', '11'])( + 'rejects dependency depth=%s before touching the graph', + async (depth) => { + const result = await errorFor(`/api/graph/dependencies?path=/x/main.ts&depth=${depth}`); + + expect(result.status).toBe(400); + expect(result.error).toBe('depth must be a positive integer between 1 and 10'); + expect(mockedDependencies).not.toHaveBeenCalled(); + }, + ); + + it.each(['NaN', 'Infinity', '0', '-1', '1.5', '1001'])( + 'rejects reference limit=%s before touching the graph', + async (limit) => { + const result = await errorFor(`/api/graph/references?name=run&limit=${limit}`); + + expect(result.status).toBe(400); + expect(result.error).toBe('limit must be a positive integer between 1 and 1000'); + expect(mockedReferences).not.toHaveBeenCalled(); + }, + ); + + it.each(['NaN', 'Infinity', '0', '-1', '1.5', '10000001'])( + 'rejects reference startLine=%s before touching the graph', + async (startLine) => { + const result = await errorFor(`/api/graph/references?name=run&startLine=${startLine}`); + + expect(result.status).toBe(400); + expect(result.error).toBe('startLine must be a positive integer between 1 and 10000000'); + expect(mockedReferences).not.toHaveBeenCalled(); + }, + ); +}); + +describe('GET /api/graph/file-relationships', () => { + const relationshipResult = { + filePath: '/x/main.ts', + containedSymbols: [{ id: 'Function:/x/main.ts:run:5', label: 'Function', displayName: 'run', filePath: '/x/main.ts', data: {} }], + imports: [{ id: 'File:/x/dep.ts', label: 'File', displayName: 'dep.ts', filePath: '/x/dep.ts', data: {} }], + importers: [{ id: 'File:/x/importer.ts', label: 'File', displayName: 'importer.ts', filePath: '/x/importer.ts', data: {} }], + knowledgeEntities: [{ id: 'Entity:Decision:Main entry point', label: 'Entity', displayName: 'Main entry point', data: { text: 'Main entry point', type: 'Decision' } }], + }; + const getFileRelationships = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetGraphClient.mockResolvedValue({} as never); + getFileRelationships.mockResolvedValue(relationshipResult); + mockedCreateQueries.mockReturnValue({ getFileRelationships } as never); + }); + + it('requires path', async () => { + const result = await errorFor('/api/graph/file-relationships'); + + expect(result).toEqual({ status: 400, error: 'path parameter is required' }); + expect(mockedGetGraphClient).not.toHaveBeenCalled(); + }); + + it.each(['NaN', 'Infinity', '0', '-1', '1.5', '501'])( + 'rejects limit=%s before touching the graph', + async (limit) => { + const result = await errorFor(`/api/graph/file-relationships?path=/x/main.ts&limit=${limit}`); + + expect(result.status).toBe(400); + expect(result.error).toBe('limit must be a positive integer between 1 and 500'); + expect(mockedGetGraphClient).not.toHaveBeenCalled(); + }, + ); + + it('returns the frozen categorized response shape', async () => { + const response = await graphRoutes.request('/api/graph/file-relationships?path=/x/main.ts&limit=50'); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(relationshipResult); + expect(getFileRelationships).toHaveBeenCalledWith('/x/main.ts', 50); + }); +}); diff --git a/packages/api/src/__tests__/profile-route.test.ts b/packages/api/src/__tests__/profile-route.test.ts index a1c85ae..f018227 100644 --- a/packages/api/src/__tests__/profile-route.test.ts +++ b/packages/api/src/__tests__/profile-route.test.ts @@ -24,7 +24,7 @@ import { profileRoutes } from '../routes/profile'; const mockedGetGraphStats = vi.mocked(codeGraphService.getGraphStats); const mockedGetGraphClient = vi.mocked(getGraphClient); -describe('GET /api/profile: projectPath validation', () => { +describe('GET /api/profile: boundary validation', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -53,4 +53,15 @@ describe('GET /api/profile: projectPath validation', () => { const res = await profileRoutes.request('/api/profile?projectPath=/abs/path'); expect(res.status).toBe(200); }); + + it.each(['0', '-1', '1.5', 'Infinity', '1001'])( + 'returns 400 for invalid limit %s and never touches the graph', + async (limit) => { + const res = await profileRoutes.request(`/api/profile?limit=${encodeURIComponent(limit)}`); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('limit must be an integer between 1 and 1000'); + expect(mockedGetGraphClient).not.toHaveBeenCalled(); + }, + ); }); diff --git a/packages/api/src/__tests__/search-route.test.ts b/packages/api/src/__tests__/search-route.test.ts index bdb4f19..26761d0 100644 --- a/packages/api/src/__tests__/search-route.test.ts +++ b/packages/api/src/__tests__/search-route.test.ts @@ -65,6 +65,26 @@ async function searchJson(query: string): Promise<{ status: number; body: Record return { status: res.status, body }; } +describe('GET /api/search: limit validation', () => { + beforeEach(() => { + mockedSearch.mockReset(); + mockedGetGraphClient.mockReset(); + mockedGetKnownNodeLabels.mockReset(); + }); + + it.each(['0', '-1', '1.5', '101', 'not-a-number'])( + 'rejects limit=%s with 400 before calling the search service', + async (limit) => { + const { status, body } = await searchJson(`q=test&limit=${encodeURIComponent(limit)}`); + + expect(status).toBe(400); + expect(body).toEqual({ error: 'limit parameter must be an integer between 1 and 100' }); + expect(mockedSearch).not.toHaveBeenCalled(); + expect(mockedGetGraphClient).not.toHaveBeenCalled(); + }, + ); +}); + describe('GET /api/search: types filter emptying the page', () => { beforeEach(() => { mockedSearch.mockReset(); diff --git a/packages/api/src/__tests__/source-route.test.ts b/packages/api/src/__tests__/source-route.test.ts new file mode 100644 index 0000000..c93467f --- /dev/null +++ b/packages/api/src/__tests__/source-route.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@codegraph/core', () => ({ + codeGraphService: { getProjects: vi.fn() }, +})); + +import { codeGraphService } from '@codegraph/core'; +import { sourceRoutes } from '../routes/source'; + +const mockedGetProjects = vi.mocked(codeGraphService.getProjects); + +describe('GET /api/source: numeric boundary validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ['startLine', '0', 'startLine must be an integer between 1 and 1000000'], + ['startLine', '1.5', 'startLine must be an integer between 1 and 1000000'], + ['endLine', '-1', 'endLine must be an integer between 0 and 1000000'], + ['endLine', 'Infinity', 'endLine must be an integer between 0 and 1000000'], + ['context', '-1', 'context must be an integer between 0 and 1000'], + ['context', '1001', 'context must be an integer between 0 and 1000'], + ])('returns 400 for invalid %s=%s before graph access', async (name, value, message) => { + const query = new URLSearchParams({ + path: '/work/project/src/a.ts', + [name]: value, + }); + + const res = await sourceRoutes.request(`/api/source?${query.toString()}`); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe(message); + expect(mockedGetProjects).not.toHaveBeenCalled(); + }); + + it('returns 400 when endLine precedes startLine', async () => { + const query = new URLSearchParams({ + path: '/work/project/src/a.ts', + startLine: '20', + endLine: '10', + }); + + const res = await sourceRoutes.request(`/api/source?${query.toString()}`); + + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('endLine must be 0 or greater than or equal to startLine'); + expect(mockedGetProjects).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/routes/graph.ts b/packages/api/src/routes/graph.ts index 4b82a7c..b78d1f2 100644 --- a/packages/api/src/routes/graph.ts +++ b/packages/api/src/routes/graph.ts @@ -1,13 +1,39 @@ import { Hono } from 'hono'; import { codeGraphService, getGraphClient } from '@codegraph/core'; +import { createQueries } from '@codegraph/graph'; import { safeErrorMessage } from '../safe-error'; export const graphRoutes = new Hono(); +const FULL_GRAPH_LIMIT_MAX = 1000; +const FILE_RELATIONSHIP_LIMIT_MAX = 500; +const REFERENCE_LIMIT_MAX = 1000; +const REFERENCE_START_LINE_MAX = 10_000_000; +const DEPENDENCY_DEPTH_MAX = 10; + +type BoundedIntegerResult = + | { valid: true; value?: number } + | { valid: false; error: string }; + +function boundedPositiveInteger( + raw: string | undefined, + name: string, + max: number, +): BoundedIntegerResult { + if (raw === undefined) return { valid: true }; + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > max) { + return { valid: false, error: `${name} must be a positive integer between 1 and ${max}` }; + } + return { valid: true, value }; +} + /** GET /api/graph/full?limit=N&projectId=X — returns { nodes, edges } optionally filtered by project */ graphRoutes.get('/api/graph/full', async (c) => { try { - const limit = Number(c.req.query('limit') ?? 100); + const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FULL_GRAPH_LIMIT_MAX); + if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); + const limit = parsedLimit.value ?? 100; const projectId = c.req.query('projectId'); // If projectId given, resolve rootPath and filter @@ -37,6 +63,38 @@ graphRoutes.get('/api/graph/full', async (c) => { } }); +/** + * GET /api/graph/file-relationships?path=X&limit=N + * + * Returns the four relationship collections consumed by the File detail panel. + * Each collection is independently bounded to 1..500 items; the default is 100. + */ +graphRoutes.get('/api/graph/file-relationships', async (c) => { + try { + const filePath = c.req.query('path'); + if (!filePath) return c.json({ error: 'path parameter is required' }, 400); + + const parsedLimit = boundedPositiveInteger( + c.req.query('limit'), + 'limit', + FILE_RELATIONSHIP_LIMIT_MAX, + ); + if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); + + const client = await getGraphClient(); + const data = await createQueries(client).getFileRelationships(filePath, parsedLimit.value ?? 100); + return c.json(data); + } catch (error) { + return c.json({ + error: safeErrorMessage( + 'GET /api/graph/file-relationships', + error, + 'Failed to fetch file relationships.', + ), + }, 500); + } +}); + /** GET /api/graph/file?path=X — returns subgraph for a file */ graphRoutes.get('/api/graph/file', async (c) => { try { @@ -64,19 +122,21 @@ graphRoutes.get('/api/graph/references', async (c) => { const name = c.req.query('name'); if (!name) return c.json({ error: 'name parameter is required' }, 400); - const rawLine = c.req.query('startLine'); - const parsedLine = rawLine === undefined ? undefined : Number.parseInt(rawLine, 10); - const startLine = parsedLine !== undefined && Number.isFinite(parsedLine) ? parsedLine : undefined; + const parsedLine = boundedPositiveInteger( + c.req.query('startLine'), + 'startLine', + REFERENCE_START_LINE_MAX, + ); + if (!parsedLine.valid) return c.json({ error: parsedLine.error }, 400); - const rawLimit = c.req.query('limit'); - const parsedLimit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10); - const limit = parsedLimit !== undefined && Number.isFinite(parsedLimit) ? parsedLimit : undefined; + const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', REFERENCE_LIMIT_MAX); + if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); const data = await codeGraphService.getSymbolReferences({ name, filePath: c.req.query('path'), - startLine, - limit, + startLine: parsedLine.value, + limit: parsedLimit.value, }); return c.json(data); } catch (error) { @@ -92,7 +152,9 @@ graphRoutes.get('/api/graph/dependencies', async (c) => { try { const filePath = c.req.query('path'); if (!filePath) return c.json({ error: 'path parameter is required' }, 400); - const depth = Number(c.req.query('depth') ?? 3); + const parsedDepth = boundedPositiveInteger(c.req.query('depth'), 'depth', DEPENDENCY_DEPTH_MAX); + if (!parsedDepth.valid) return c.json({ error: parsedDepth.error }, 400); + const depth = parsedDepth.value ?? 3; const data = await codeGraphService.getDependencyTree(filePath, depth); return c.json(data); } catch (error) { diff --git a/packages/api/src/routes/profile.ts b/packages/api/src/routes/profile.ts index dbbb4b3..10da020 100644 --- a/packages/api/src/routes/profile.ts +++ b/packages/api/src/routes/profile.ts @@ -1,5 +1,5 @@ /** - * GET /api/profile — codebase wake-up endpoint + * GET /api/profile: codebase wake-up endpoint * * Returns a static + dynamic snapshot of the project in <200ms. * Lets agents hydrate their understanding before tool loops kick in. @@ -117,6 +117,21 @@ export function validateProjectPath( return { valid: true }; } +function validateLimit( + rawLimit: string | undefined, +): { valid: true; value?: number } | { valid: false; error: string } { + if (rawLimit === undefined) return { valid: true }; + + const limit = Number(rawLimit); + if (!/^\d+$/.test(rawLimit) || !Number.isFinite(limit) || !Number.isSafeInteger(limit)) { + return { valid: false, error: 'limit must be an integer between 1 and 1000' }; + } + if (limit < 1 || limit > 1_000) { + return { valid: false, error: 'limit must be an integer between 1 and 1000' }; + } + return { valid: true, value: limit }; +} + // ============================================================================ // Core function (also used by MCP codebase.profile action) // ============================================================================ @@ -124,7 +139,7 @@ export function validateProjectPath( /** * Build a codebase profile from a ProfileService. * - * Runs all queries in parallel — targets <200ms against a warm FalkorDB. + * Runs all queries in parallel and targets <200ms against a warm FalkorDB. */ export async function getProfile( service: ProfileService, @@ -251,8 +266,13 @@ profileRoutes.get('/api/profile', async (c) => { return c.json({ error: pathCheck.error }, 400); } - const limitStr = c.req.query('limit'); - const limit = limitStr ? parseInt(limitStr, 10) : undefined; + // Profile sections share one bounded result limit. Validate before graph + // access so malformed requests cannot reach query construction. + const limitCheck = validateLimit(c.req.query('limit')); + if (!limitCheck.valid) { + return c.json({ error: limitCheck.error }, 400); + } + const limit = limitCheck.value; // Dynamic import keeps @codegraph/core out of the test-time module graph const { codeGraphService, getGraphClient } = await import('@codegraph/core'); diff --git a/packages/api/src/routes/search.ts b/packages/api/src/routes/search.ts index c94d5d7..f70d1f4 100644 --- a/packages/api/src/routes/search.ts +++ b/packages/api/src/routes/search.ts @@ -5,6 +5,13 @@ import { safeErrorMessage } from '../safe-error'; export const searchRoutes = new Hono(); +const DEFAULT_SEARCH_LIMIT = 20; +const MIN_SEARCH_LIMIT = 1; +// Keep one request from asking the vector ranker or Cypher fallback for an +// unbounded result window. Dashboard callers currently request 20 or 30. +const MAX_SEARCH_LIMIT = 100; +const SEARCH_LIMIT_ERROR = `limit parameter must be an integer between ${MIN_SEARCH_LIMIT} and ${MAX_SEARCH_LIMIT}`; + /** * What to do about a caller-supplied `types` filter, decided once against * the live label allowlist so the route only has to act on the answer. @@ -154,7 +161,11 @@ searchRoutes.get('/api/search', async (c) => { const query = c.req.query('q'); if (!query) return c.json({ error: 'q parameter is required' }, 400); - const limit = Number(c.req.query('limit') ?? 20); + const rawLimit = c.req.query('limit'); + const limit = rawLimit === undefined ? DEFAULT_SEARCH_LIMIT : Number(rawLimit); + if (!Number.isInteger(limit) || limit < MIN_SEARCH_LIMIT || limit > MAX_SEARCH_LIMIT) { + return c.json({ error: SEARCH_LIMIT_ERROR }, 400); + } const scope = c.req.query('scope'); const types = c.req.query('types'); diff --git a/packages/api/src/routes/source.ts b/packages/api/src/routes/source.ts index 160b434..5f9b84b 100644 --- a/packages/api/src/routes/source.ts +++ b/packages/api/src/routes/source.ts @@ -6,6 +6,29 @@ import { safeErrorMessage } from '../safe-error'; export const sourceRoutes = new Hono(); +type IntegerParamResult = + | { valid: true; value: number } + | { valid: false; error: string }; + +function boundedIntegerParam( + rawValue: string | undefined, + defaultValue: number, + name: string, + min: number, + max: number, +): IntegerParamResult { + if (rawValue === undefined) return { valid: true, value: defaultValue }; + + const value = Number(rawValue); + if (!/^\d+$/.test(rawValue) || !Number.isFinite(value) || !Number.isSafeInteger(value)) { + return { valid: false, error: `${name} must be an integer between ${min} and ${max}` }; + } + if (value < min || value > max) { + return { valid: false, error: `${name} must be an integer between ${min} and ${max}` }; + } + return { valid: true, value }; +} + /** * Every directory the source endpoint may read from: the roots of projects that * are actually in the graph. @@ -31,15 +54,48 @@ async function readableRoots(): Promise { /** GET /api/source?path=X&startLine=N&endLine=N reads source code with context. */ sourceRoutes.get('/api/source', async (c) => { try { + // Source locations are one-based and capped to keep arithmetic and response + // windows predictable. endLine=0 retains the existing whole-file sentinel. + const startLineResult = boundedIntegerParam( + c.req.query('startLine'), + 1, + 'startLine', + 1, + 1_000_000, + ); + if (!startLineResult.valid) { + return c.json({ error: startLineResult.error }, 400); + } + const endLineResult = boundedIntegerParam( + c.req.query('endLine'), + 0, + 'endLine', + 0, + 1_000_000, + ); + if (!endLineResult.valid) { + return c.json({ error: endLineResult.error }, 400); + } + const contextResult = boundedIntegerParam(c.req.query('context'), 5, 'context', 0, 1_000); + if (!contextResult.valid) { + return c.json({ error: contextResult.error }, 400); + } + if (endLineResult.value > 0 && endLineResult.value < startLineResult.value) { + return c.json( + { error: 'endLine must be 0 or greater than or equal to startLine' }, + 400, + ); + } + const decision = authorizeSourcePath(c.req.query('path'), await readableRoots()); if (!decision.ok) { return c.json({ error: decision.message }, decision.status); } const filePath = decision.path; - const startLine = Number(c.req.query('startLine') ?? 1); - const endLine = Number(c.req.query('endLine') ?? 0); - const context = Number(c.req.query('context') ?? 5); // lines of context around the entity + const startLine = startLineResult.value; + const endLine = endLineResult.value; + const context = contextResult.value; const content = await readFile(filePath, 'utf-8'); const allLines = content.split('\n'); diff --git a/packages/core/src/__tests__/service.test.ts b/packages/core/src/__tests__/service.test.ts index 695c2e8..0341d6f 100644 --- a/packages/core/src/__tests__/service.test.ts +++ b/packages/core/src/__tests__/service.test.ts @@ -470,6 +470,43 @@ describe('CodeGraphService', () => { expect(result.edges[0]!.label).toBe('CALLS'); }); + it('returns knowledge Entity neighbors with stable identity and no embeddings', async () => { + mockClient.roQuery.mockResolvedValueOnce({ + data: [ + { + neighbor: { + text: 'Retry policy', + type: 'Decision', + embedding: [0.1, 0.2], + embeddingTextHash: 'secret-payload-hash', + }, + neighborLabels: ['Entity'], + r: { + confidence: 0.9, + embedding: [0.3, 0.4], + embeddingTextHash: 'secret-edge-hash', + }, + rType: 'ABOUT', + }, + ], + metadata: null, + }); + + const result = await codeGraphService.getNeighbors('Function:/src/app.ts:retry:10', 'in'); + const cypher: string = mockClient.roQuery.mock.calls[0][0]; + + expect(cypher).toContain('neighbor.text IS NOT NULL'); + expect(result.nodes[0]).toMatchObject({ + id: 'Entity:Decision:Retry policy', + label: 'Entity', + displayName: 'Retry policy', + }); + expect(result.nodes[0]?.data).not.toHaveProperty('embedding'); + expect(result.nodes[0]?.data).not.toHaveProperty('embeddingTextHash'); + expect(result.edges[0]?.data).not.toHaveProperty('embedding'); + expect(result.edges[0]?.data).not.toHaveProperty('embeddingTextHash'); + }); + it('deduplicates nodes and edges', async () => { const sameNeighbor = { name: 'helper', filePath: '/src/util.ts', startLine: 5 }; mockClient.roQuery.mockResolvedValueOnce({ diff --git a/packages/core/src/services/graph-data-service.ts b/packages/core/src/services/graph-data-service.ts index 8830d38..0d0dc32 100644 --- a/packages/core/src/services/graph-data-service.ts +++ b/packages/core/src/services/graph-data-service.ts @@ -22,6 +22,22 @@ import type { CypherResult, } from './types'; +function projectDashboardValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(projectDashboardValue); + if (value === null || typeof value !== 'object') return value; + + const projected: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === 'embedding' || key === 'embeddingTextHash') continue; + projected[key] = projectDashboardValue(child); + } + return projected; +} + +function projectDashboardProperties(props: Record): Record { + return projectDashboardValue(props) as Record; +} + // ============================================================================ // Graph Stats // ============================================================================ @@ -215,7 +231,7 @@ export async function getEntityWithConnectionsImpl( label: (firstRow.labels[0] ?? 'Unknown') as GraphNode['label'], displayName: (firstRow.n['name'] as string) ?? (firstRow.n['path'] as string) ?? 'unknown', filePath: (firstRow.n['filePath'] as string) ?? (firstRow.n['path'] as string), - data: firstRow.n as unknown as GraphNode['data'], + data: projectDashboardProperties(firstRow.n) as unknown as GraphNode['data'], }; const incomingEdges: GraphEdge[] = []; @@ -232,7 +248,7 @@ export async function getEntityWithConnectionsImpl( source: (row.inNode['name'] as string) ?? (row.inNode['path'] as string) ?? 'unknown', target: id, label: row.inType as GraphEdge['label'], - data: row.inEdge as unknown as GraphEdge['data'], + data: projectDashboardProperties(row.inEdge) as unknown as GraphEdge['data'], }); } } @@ -246,7 +262,7 @@ export async function getEntityWithConnectionsImpl( source: id, target: (row.outNode['name'] as string) ?? (row.outNode['path'] as string) ?? 'unknown', label: row.outType as GraphEdge['label'], - data: row.outEdge as unknown as GraphEdge['data'], + data: projectDashboardProperties(row.outEdge) as unknown as GraphEdge['data'], }); } } @@ -340,7 +356,7 @@ export async function getNodesPaginatedImpl(options: NodesQueryOptions = {}): Pr label: nodeLabel, displayName: (props['name'] as string) ?? (props['filePath'] as string) ?? 'unknown', filePath: (props['filePath'] as string), - data: props as unknown as GraphNode['data'], + data: projectDashboardProperties(props) as unknown as GraphNode['data'], } as GraphNode; }); @@ -428,7 +444,7 @@ export async function getNeighborsImpl( MATCH (center) WHERE ${centerMatch} MATCH ${cypherMatch} - WHERE neighbor.filePath IS NOT NULL OR neighbor.name IS NOT NULL ${edgeTypeFilter} + WHERE neighbor.filePath IS NOT NULL OR neighbor.name IS NOT NULL OR neighbor.text IS NOT NULL ${edgeTypeFilter} RETURN DISTINCT neighbor, ${dialect.labelsExpr('neighbor')} as neighborLabels, r, ${dialect.typeExpr('r')} as rType LIMIT $limit `, { params: queryParams }); @@ -440,7 +456,7 @@ export async function getNeighborsImpl( for (const row of result.data ?? []) { const neighborProps = extractNodeProps(row.neighbor as Record); - const nodeLabel = (row.neighborLabels[0] ?? 'File') as NodeLabel; + const nodeLabel = (row.neighborLabels[0] ?? 'File') as NodeLabel | 'Entity'; const nodeId = generateNodeId(nodeLabel, neighborProps); if (nodeId && !seenNodes.has(nodeId)) { @@ -448,9 +464,9 @@ export async function getNeighborsImpl( nodes.push({ id: nodeId, label: nodeLabel, - displayName: (neighborProps['name'] as string) ?? (neighborProps['path'] as string) ?? 'unknown', + displayName: (neighborProps['name'] as string) ?? (neighborProps['path'] as string) ?? (neighborProps['text'] as string) ?? 'unknown', filePath: (neighborProps['filePath'] as string) ?? (neighborProps['path'] as string), - data: neighborProps as unknown as GraphNode['data'], + data: projectDashboardProperties(neighborProps) as unknown as GraphNode['data'], } as GraphNode); } @@ -462,7 +478,7 @@ export async function getNeighborsImpl( source: direction === 'in' ? nodeId : id, target: direction === 'in' ? id : nodeId, label: row.rType as EdgeLabel, - data: row.r as unknown as GraphEdge['data'], + data: projectDashboardProperties(row.r) as unknown as GraphEdge['data'], } as GraphEdge); } } diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 1080c47..4b10701 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run --config vite.config.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/dashboard/src/App.tsx b/packages/dashboard/src/App.tsx index e19a55d..9e82a42 100644 --- a/packages/dashboard/src/App.tsx +++ b/packages/dashboard/src/App.tsx @@ -9,7 +9,7 @@ import { API_URL } from '@/lib/api' export default function App() { const [activeTab, setActiveTab] = useState('explorer') - const [projectId, setProjectId] = useState(null) + const [project, setProject] = useState<{ id: string; name: string } | null>(null) const [refreshKey, setRefreshKey] = useState(0) const handleProjectParsed = useCallback(() => { @@ -17,7 +17,7 @@ export default function App() { }, []) const handleProjectChange = useCallback((project: { id: string; name: string } | null) => { - setProjectId(project?.id ?? null) + setProject(project) }, []) return ( @@ -41,7 +41,13 @@ export default function App() {
- {activeTab === 'explorer' && } + {activeTab === 'explorer' && ( + + )} {activeTab === 'operations' && }
diff --git a/packages/dashboard/src/components/dashboard/app-shell.tsx b/packages/dashboard/src/components/dashboard/app-shell.tsx index d555fed..ef3e060 100644 --- a/packages/dashboard/src/components/dashboard/app-shell.tsx +++ b/packages/dashboard/src/components/dashboard/app-shell.tsx @@ -2,31 +2,207 @@ import { useState, useCallback, useEffect } from 'react' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { GraphCanvas, type GraphNode } from './graph-canvas' import { GraphLegend } from './graph-legend' -import { SearchPanel } from './search-panel' +import { SearchPanel, type SearchResult } from './search-panel' import { EntityDetail } from './entity-detail' import { QueryPanel } from './query-panel' import { API_URL } from '@/lib/api' import { + canonicalSymbolNodeId, + fetchFileRelationships, fetchReferences, isReferenceable, referenceKey, type SymbolReferences, } from '@/lib/references' +import type { FileRelationshipsState } from './entity-detail' +export interface SelectionHistory { + entries: Array + index: number +} + +export const EMPTY_SELECTION_HISTORY: SelectionHistory = { entries: [], index: -1 } + +function selectionIdentity(node: GraphNode | null): string | null { + return node?.id ?? null +} + +export function pushSelectionHistory( + history: SelectionHistory, + node: GraphNode | null, +): SelectionHistory { + const current = history.index >= 0 ? history.entries[history.index] : undefined + if (current !== undefined && selectionIdentity(current) === selectionIdentity(node)) return history + + const entries = [...history.entries.slice(0, history.index + 1), node] + return { entries, index: entries.length - 1 } +} + +export function moveSelectionHistory( + history: SelectionHistory, + offset: -1 | 1, +): SelectionHistory { + if (history.entries.length === 0) return history + const index = Math.max(0, Math.min(history.entries.length - 1, history.index + offset)) + return index === history.index ? history : { ...history, index } +} + +interface ExplorerBreadcrumb { + level: 'project' | 'file' | 'symbol' + label: string + node: GraphNode | null +} + +function basename(filePath: string): string { + return filePath.split('/').filter(Boolean).at(-1) ?? filePath +} + +export function deriveBreadcrumbs( + projectName: string | undefined, + selectedNode: GraphNode | null, +): ExplorerBreadcrumb[] { + const crumbs: ExplorerBreadcrumb[] = [] + if (projectName) crumbs.push({ level: 'project', label: projectName, node: null }) + if (!selectedNode) return crumbs + + const filePath = typeof selectedNode.properties.filePath === 'string' + ? selectedNode.properties.filePath + : undefined + if (filePath) { + crumbs.push({ + level: 'file', + label: basename(filePath), + node: selectedNode.type === 'File' + ? selectedNode + : { + id: `File:${filePath}`, + label: basename(filePath), + type: 'File', + properties: { name: basename(filePath), filePath }, + }, + }) + } + if (selectedNode.type !== 'File') { + crumbs.push({ level: 'symbol', label: selectedNode.label, node: selectedNode }) + } + return crumbs +} + +export function searchResultToGraphNode(result: SearchResult): GraphNode { + const startLine = typeof result.startLine === 'number' ? result.startLine : null + return { + id: canonicalSymbolNodeId(result.nodeType, { + name: result.name, + filePath: result.filePath, + startLine, + }), + label: result.name, + type: result.nodeType, + properties: result, + } +} + +export function ExplorerNavigation({ + projectName, + selectedNode, + canGoBack, + canGoForward, + onBack, + onForward, + onSelect, +}: { + projectName?: string + selectedNode: GraphNode | null + canGoBack: boolean + canGoForward: boolean + onBack: () => void + onForward: () => void + onSelect: (node: GraphNode | null) => void +}) { + const breadcrumbs = deriveBreadcrumbs(projectName, selectedNode) + + return ( + + ) +} -export function AppShell({ projectId }: { projectId?: string | null }) { - const [selectedNode, setSelectedNode] = useState(null) +export function AppShell({ projectId, projectName }: { projectId?: string | null; projectName?: string }) { + const [selectionHistory, setSelectionHistory] = useState(EMPTY_SELECTION_HISTORY) const [highlightedNames, setHighlightedNames] = useState>(new Set()) const [hiddenEdgeTypes, setHiddenEdgeTypes] = useState>(new Set()) const [hiddenNodeTypes, setHiddenNodeTypes] = useState>(new Set()) const [showQuery, setShowQuery] = useState(false) const [references, setReferences] = useState(null) const [referencesLoading, setReferencesLoading] = useState(false) + const [fileRelationshipsState, setFileRelationshipsState] = useState({ status: 'idle' }) + + const selectedNode = selectionHistory.index >= 0 + ? selectionHistory.entries[selectionHistory.index] ?? null + : null const handleNodeSelect = useCallback((node: GraphNode | null) => { - setSelectedNode(node) + setSelectionHistory((history) => pushSelectionHistory(history, node)) }, []) + const handleBack = useCallback(() => { + setSelectionHistory((history) => moveSelectionHistory(history, -1)) + }, []) + + const handleForward = useCallback(() => { + setSelectionHistory((history) => moveSelectionHistory(history, 1)) + }, []) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return + if (event.key === 'ArrowLeft') { + event.preventDefault() + handleBack() + } else if (event.key === 'ArrowRight') { + event.preventDefault() + handleForward() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleBack, handleForward]) + // One lookup serves both surfaces: the panel lists every reference, the canvas // highlights the ones it happens to have loaded. useEffect(() => { @@ -59,6 +235,32 @@ export function AppShell({ projectId }: { projectId?: string | null }) { return () => controller.abort() }, [selectedNode]) + useEffect(() => { + const filePath = selectedNode?.type === 'File' + && typeof selectedNode.properties.filePath === 'string' + ? selectedNode.properties.filePath + : undefined + if (!filePath) { + setFileRelationshipsState({ status: 'idle' }) + return + } + + const controller = new AbortController() + setFileRelationshipsState({ status: 'loading' }) + fetchFileRelationships(filePath, controller.signal) + .then((data) => setFileRelationshipsState({ status: 'success', data })) + .catch((error: unknown) => { + if (controller.signal.aborted) return + console.error('Failed to load file relationships', error) + setFileRelationshipsState({ + status: 'error', + message: error instanceof Error ? error.message : 'Failed to load file relationships', + }) + }) + + return () => controller.abort() + }, [selectedNode]) + const referenceKeys = new Set( (references?.references ?? []).map((r) => referenceKey(r.filePath, r.name, r.startLine)), ) @@ -96,12 +298,7 @@ export function AppShell({ projectId }: { projectId?: string | null }) { setHighlightedNames(new Set([result.name])) // Open the detail panel too. The search payload already carries // filePath and line numbers, which is everything the panel needs. - setSelectedNode({ - id: `${result.nodeType}:${result.filePath ?? ''}:${result.name}`, - label: result.name, - type: result.nodeType, - properties: result, - }) + handleNodeSelect(searchResultToGraphNode(result)) }} /> @@ -116,6 +313,7 @@ export function AppShell({ projectId }: { projectId?: string | null }) { {/* Toolbar: Query toggle + Legend */} -
- - + 0} + canGoForward={selectionHistory.index >= 0 && selectionHistory.index < selectionHistory.entries.length - 1} + onBack={handleBack} + onForward={handleForward} + onSelect={handleNodeSelect} /> +
+ + +
@@ -146,7 +358,9 @@ export function AppShell({ projectId }: { projectId?: string | null }) { <> - +
+ +
)} @@ -162,6 +376,7 @@ export function AppShell({ projectId }: { projectId?: string | null }) { references={references} referencesLoading={referencesLoading} onSelectReference={handleNodeSelect} + fileRelationshipsState={fileRelationshipsState} /> diff --git a/packages/dashboard/src/components/dashboard/embedding-badge.tsx b/packages/dashboard/src/components/dashboard/embedding-badge.tsx index f63095a..d42b868 100644 --- a/packages/dashboard/src/components/dashboard/embedding-badge.tsx +++ b/packages/dashboard/src/components/dashboard/embedding-badge.tsx @@ -1,10 +1,9 @@ -import { useEffect, useState, useCallback } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { API_URL } from '@/lib/api' import { EMBEDDABLE_LABELS } from '@codegraph/types' - interface EmbeddingLabel { label: string total: number @@ -12,70 +11,116 @@ interface EmbeddingLabel { coverage: number } -export function EmbeddingBadge() { - const [stats, setStats] = useState<{ total: number; embedded: number; pct: number } | null>(null) - const [generating, setGenerating] = useState(false) - const [genResult, setGenResult] = useState(null) +interface EmbeddingSummary { + total: number + embedded: number + pct: number +} - const fetchStats = useCallback(async () => { - try { - const res = await fetch(`${API_URL}/api/embeddings/status`) - if (!res.ok) return - const data = await res.json() - const labels = (data.labels ?? []) as EmbeddingLabel[] - // Only count embeddable node types (code symbols, not git/markdown structure). - // EMBEDDABLE_LABELS is the shared source of truth (packages/types/src/labels.ts). - const embeddable = new Set(EMBEDDABLE_LABELS) - const relevant = labels.filter(l => embeddable.has(l.label)) - const total = relevant.reduce((s, l) => s + l.total, 0) - const embedded = relevant.reduce((s, l) => s + l.withEmbedding, 0) - const pct = total > 0 ? Math.round((embedded / total) * 100) : 0 - setStats({ total, embedded, pct }) - } catch { - // non-fatal +type EmbeddingState = + | { status: 'loading' } + | { status: 'success'; data: EmbeddingSummary } + | { status: 'error'; message: string } + +interface EmbeddingBadgeContentProps { + state: EmbeddingState + generating: boolean + genResult: string | null + onGenerate: () => void + onRetry: () => void +} + +interface FetchResponse { + ok: boolean + status: number + statusText: string + json(): Promise +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function finiteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function parseLabels(value: unknown): EmbeddingLabel[] { + if (!isRecord(value) || !Array.isArray(value.labels)) { + throw new Error('Invalid embedding status response') + } + return value.labels.map((label) => { + if ( + !isRecord(label) + || typeof label.label !== 'string' + || !finiteNumber(label.total) + || !finiteNumber(label.withEmbedding) + || !finiteNumber(label.coverage) + ) { + throw new Error('Invalid embedding status response') } - }, []) + return { + label: label.label, + total: label.total, + withEmbedding: label.withEmbedding, + coverage: label.coverage, + } + }) +} - useEffect(() => { - const initialFetch = window.setTimeout(fetchStats, 0) - const interval = setInterval(fetchStats, 30_000) - return () => { - clearTimeout(initialFetch) - clearInterval(interval) +async function loadEmbeddingSummary( + fetcher: (input: string) => Promise, +): Promise { + try { + const response = await fetcher(`${API_URL}/api/embeddings/status`) + if (!response.ok) { + const statusText = response.statusText ? ` ${response.statusText}` : '' + throw new Error(`HTTP ${response.status}${statusText}`) } - }, [fetchStats]) - const handleGenerate = useCallback(async () => { - setGenerating(true) - setGenResult(null) - try { - const res = await fetch(`${API_URL}/api/embeddings/generate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - const data = await res.json() - if (!res.ok || data.error) { - setGenResult(data.hint ?? data.error ?? 'Failed') - } else { - setGenResult(`${data.embedded} embedded`) - fetchStats() // refresh badge - } - } catch (err) { - setGenResult(err instanceof Error ? err.message : 'Failed') - } finally { - setGenerating(false) - setTimeout(() => setGenResult(null), 5000) + const labels = parseLabels(await response.json()) + const embeddable = new Set(EMBEDDABLE_LABELS) + const relevant = labels.filter((label) => embeddable.has(label.label)) + const total = relevant.reduce((sum, label) => sum + label.total, 0) + const embedded = relevant.reduce((sum, label) => sum + label.withEmbedding, 0) + const pct = total > 0 ? Math.round((embedded / total) * 100) : 0 + return { status: 'success', data: { total, embedded, pct } } + } catch (error) { + return { + status: 'error', + message: error instanceof Error ? error.message : 'Request failed', } - }, [fetchStats]) + } +} + +export function EmbeddingBadgeContent({ + state, + generating, + genResult, + onGenerate, + onRetry, +}: EmbeddingBadgeContentProps) { + if (state.status === 'loading') return null - if (!stats || stats.total === 0) return null + if (state.status === 'error') { + return ( +
+ Embedding status unavailable + +
+ ) + } + + const stats = state.data + if (stats.total === 0) return null const badgeColor = stats.pct >= 90 ? { color: '#34d399', borderColor: 'rgba(16,185,129,0.3)' } : stats.pct >= 50 - ? { color: '#facc15', borderColor: 'rgba(234,179,8,0.3)' } - : { color: '#f87171', borderColor: 'rgba(239,68,68,0.3)' } + ? { color: '#facc15', borderColor: 'rgba(234,179,8,0.3)' } + : { color: '#f87171', borderColor: 'rgba(239,68,68,0.3)' } return (
@@ -86,7 +131,7 @@ export function EmbeddingBadge() {
) } + +export function EmbeddingBadge() { + const [state, setState] = useState({ status: 'loading' }) + const [generating, setGenerating] = useState(false) + const [genResult, setGenResult] = useState(null) + + const fetchStats = useCallback(async () => { + setState(await loadEmbeddingSummary(fetch)) + }, []) + + useEffect(() => { + const initialFetch = window.setTimeout(() => void fetchStats(), 0) + const interval = window.setInterval(() => void fetchStats(), 30_000) + return () => { + clearTimeout(initialFetch) + clearInterval(interval) + } + }, [fetchStats]) + + const handleGenerate = useCallback(async () => { + setGenerating(true) + setGenResult(null) + try { + const response = await fetch(`${API_URL}/api/embeddings/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const data: unknown = await response.json() + if (!isRecord(data)) throw new Error('Invalid embedding generation response') + + if (!response.ok || typeof data.error === 'string') { + const message = typeof data.hint === 'string' + ? data.hint + : typeof data.error === 'string' + ? data.error + : `HTTP ${response.status}` + setGenResult(message) + } else if (finiteNumber(data.embedded)) { + setGenResult(`${data.embedded} embedded`) + await fetchStats() + } else { + throw new Error('Invalid embedding generation response') + } + } catch (error) { + setGenResult(error instanceof Error ? error.message : 'Failed') + } finally { + setGenerating(false) + setTimeout(() => setGenResult(null), 5_000) + } + }, [fetchStats]) + + return ( + void handleGenerate()} + onRetry={() => void fetchStats()} + /> + ) +} diff --git a/packages/dashboard/src/components/dashboard/entity-detail.tsx b/packages/dashboard/src/components/dashboard/entity-detail.tsx index a16d5b0..dee407d 100644 --- a/packages/dashboard/src/components/dashboard/entity-detail.tsx +++ b/packages/dashboard/src/components/dashboard/entity-detail.tsx @@ -1,11 +1,23 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useId, useRef } from 'react' import type { GraphNode } from './graph-canvas' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { NODE_COLORS } from '@/lib/cytoscape-config' import { API_URL } from '@/lib/api' -import type { SymbolReference, SymbolReferences } from '@/lib/references' +import { canonicalSymbolNodeId } from '@/lib/references' +import type { + FileRelationshipNode, + FileRelationships, + SymbolReference, + SymbolReferences, +} from '@/lib/references' + +export type FileRelationshipsState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: FileRelationships } + | { status: 'error'; message: string } interface EntityDetailProps { @@ -13,9 +25,16 @@ interface EntityDetailProps { references?: SymbolReferences | null referencesLoading?: boolean onSelectReference?: (node: GraphNode) => void + fileRelationshipsState?: FileRelationshipsState } -export function EntityDetail({ node, references, referencesLoading, onSelectReference }: EntityDetailProps) { +export function EntityDetail({ + node, + references, + referencesLoading, + onSelectReference, + fileRelationshipsState, +}: EntityDetailProps) { const [copied, setCopied] = useState(false) const handleCopyPath = useCallback(() => { @@ -100,7 +119,7 @@ export function EntityDetail({ node, references, referencesLoading, onSelectRefe L{startLine}–{endLine} - {endLine - startLine + 1} lines + {endLine - startLine + 1} lines )} @@ -162,6 +181,15 @@ export function EntityDetail({ node, references, referencesLoading, onSelectRefe )} + {node.type === 'File' && fileRelationshipsState && fileRelationshipsState.status !== 'idle' && ( +
}> + +
+ )} + {/* Code Preview with syntax highlighting */} {filePath && startLine != null && (
@@ -213,16 +241,20 @@ export function EntityDetail({ node, references, referencesLoading, onSelectRefe function Section({ title, children, defaultCollapsed = false, icon }: { title: string; children: React.ReactNode; defaultCollapsed?: boolean; icon?: React.ReactNode }) { const [open, setOpen] = useState(!defaultCollapsed) + const contentId = useId() return (
- {open &&
{children}
} + {open &&
{children}
}
) } @@ -300,7 +332,7 @@ function MetricsAndProperties({ props }: { props: Record }) { className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] font-medium" style={{ color: m.style.color, backgroundColor: m.style.background, borderColor: m.style.borderColor }} > - {m.label} + {m.label} {m.value} ))} @@ -310,7 +342,7 @@ function MetricsAndProperties({ props }: { props: Record }) { {/* Parameters */} {parsedParams.length > 0 && (
-
Parameters
+
Parameters
{parsedParams.map((p, i) => (
@@ -332,10 +364,10 @@ function MetricsAndProperties({ props }: { props: Record }) {
{otherProps.map(([key, value]) => (
- {key} + {key} {typeof value === 'boolean' - ? {String(value)} + ? {String(value)} : String(value)}
@@ -344,7 +376,7 @@ function MetricsAndProperties({ props }: { props: Record }) { )} {metrics.length === 0 && parsedParams.length === 0 && otherProps.length === 0 && ( -
No additional properties
+
No additional properties
)}
) @@ -438,11 +470,11 @@ function CodePreview({ apiUrl, filePath, startLine, endLine, nodeId }: { }, [highlightedHtml, nodeId]) if (loading) { - return
Loading code...
+ return
Loading code...
} if (!lines || lines.length === 0) { - return
No source code available
+ return
No source code available
} const useHighlighting = highlightedHtml != null && highlightedHtml.length === lines.length @@ -463,7 +495,7 @@ function CodePreview({ apiUrl, filePath, startLine, endLine, nodeId }: { className={`flex ${isEntity ? '' : 'hover:bg-accent/30'}`} > {line.number} @@ -577,7 +609,7 @@ function ReferenceGroup({ label, items, declaringFile, onSelect }: { }) { return (
-

+

{label}

    @@ -586,19 +618,7 @@ function ReferenceGroup({ label, items, declaringFile, onSelect }: {
) } + +export function symbolReferenceToGraphNode(ref: SymbolReference): GraphNode { + return { + id: canonicalSymbolNodeId(ref.nodeType, ref), + label: ref.name, + type: ref.nodeType, + properties: { + name: ref.name, + nodeType: ref.nodeType, + filePath: ref.filePath, + ...(ref.startLine != null ? { startLine: ref.startLine } : {}), + }, + } +} + +export function relationshipNodeToGraphNode(node: FileRelationshipNode): GraphNode { + return { + id: node.id, + label: node.displayName, + type: node.label, + properties: { + ...node.data, + ...(node.filePath !== undefined ? { filePath: node.filePath } : {}), + }, + } +} + +const FILE_RELATIONSHIP_GROUPS: ReadonlyArray<{ + key: keyof Pick + label: string +}> = [ + { key: 'containedSymbols', label: 'Contained symbols' }, + { key: 'imports', label: 'Imports' }, + { key: 'importers', label: 'Importers' }, + { key: 'knowledgeEntities', label: 'Knowledge entities' }, +] + +export function FileRelationshipsContent({ + state, + onSelect, +}: { + state: FileRelationshipsState + onSelect?: (node: GraphNode) => void +}) { + if (state.status === 'idle') return null + if (state.status === 'loading') { + return

Loading file relationships...

+ } + if (state.status === 'error') { + return

{state.message}

+ } + + return ( +
+ {FILE_RELATIONSHIP_GROUPS.map((group) => { + const items = state.data[group.key] + return ( +
+

+ {group.label} +

+ {items.length === 0 ? ( +

Nothing found

+ ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ ) + })} +
+ ) +} diff --git a/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx b/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx new file mode 100644 index 0000000..d70b7a6 --- /dev/null +++ b/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx @@ -0,0 +1,143 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { + EMPTY_SELECTION_HISTORY, + ExplorerNavigation, + deriveBreadcrumbs, + moveSelectionHistory, + pushSelectionHistory, + searchResultToGraphNode, +} from './app-shell' +import { symbolReferenceToGraphNode } from './entity-detail' +import type { GraphNode } from './graph-canvas' +import type { SymbolReference } from '@/lib/references' + +const fileNode: GraphNode = { + id: 'File:/repo/src/main.ts', + label: 'main.ts', + type: 'File', + properties: { filePath: '/repo/src/main.ts', name: 'main.ts' }, +} + +const symbolNode: GraphNode = { + id: 'Function:/repo/src/main.ts:run:5', + label: 'run', + type: 'Function', + properties: { filePath: '/repo/src/main.ts', name: 'run', startLine: 5 }, +} + +describe('explorer selection history', () => { + it('uses canonical graph identity for real search and reference payloads', () => { + const canonicalNode: GraphNode = { + id: 'Function:/repo/src/main.ts:run:5', + label: 'run', + type: 'Function', + properties: { + name: 'run', + filePath: '/repo/src/main.ts', + startLine: 5, + }, + } + const searchResult = { + name: 'run', + nodeType: 'Function', + filePath: '/repo/src/main.ts', + startLine: 5, + endLine: 8, + isExported: true, + } + const referenceRow: SymbolReference = { + name: 'run', + nodeType: 'Function', + filePath: '/repo/src/main.ts', + startLine: 5, + edgeType: 'CALLS', + sameFile: false, + } + + expect(searchResultToGraphNode(searchResult).id).toBe(canonicalNode.id) + expect(symbolReferenceToGraphNode(referenceRow).id).toBe(canonicalNode.id) + + expect(searchResultToGraphNode({ + name: 'run', + nodeType: 'Function', + filePath: '/repo/src/main.ts', + startLine: null, + endLine: null, + isExported: null, + }).id).toBe('Function:/repo/src/main.ts:run:0') + expect(symbolReferenceToGraphNode({ + name: 'run', + nodeType: 'Function', + filePath: '/repo/src/main.ts', + edgeType: 'CALLS', + sameFile: false, + }).id).toBe('Function:/repo/src/main.ts:run:0') + }) + + it('deduplicates consecutive selections and truncates Forward after a new branch', () => { + let history = pushSelectionHistory(EMPTY_SELECTION_HISTORY, fileNode) + history = pushSelectionHistory(history, fileNode) + history = pushSelectionHistory(history, symbolNode) + + expect(history.entries.map((node) => node?.id ?? null)).toEqual([ + 'File:/repo/src/main.ts', + 'Function:/repo/src/main.ts:run:5', + ]) + expect(history.index).toBe(1) + + history = moveSelectionHistory(history, -1) + history = pushSelectionHistory(history, null) + + expect(history.entries.map((node) => node?.id ?? null)).toEqual([ + 'File:/repo/src/main.ts', + null, + ]) + expect(history.index).toBe(1) + }) + + it('moves backward and forward without pushing duplicate entries', () => { + let history = pushSelectionHistory(EMPTY_SELECTION_HISTORY, fileNode) + history = pushSelectionHistory(history, symbolNode) + + history = moveSelectionHistory(history, -1) + expect(history.entries[history.index]?.id).toBe(fileNode.id) + + history = moveSelectionHistory(history, 1) + expect(history.entries[history.index]?.id).toBe(symbolNode.id) + }) +}) + +describe('explorer breadcrumbs', () => { + it('derives project, file, and symbol levels from the current selection', () => { + const crumbs = deriveBreadcrumbs('CodeGraph', symbolNode) + + expect(crumbs.map((crumb) => [crumb.level, crumb.label, crumb.node?.id ?? null])).toEqual([ + ['project', 'CodeGraph', null], + ['file', 'main.ts', 'File:/repo/src/main.ts'], + ['symbol', 'run', symbolNode.id], + ]) + }) + + it('renders Back and Forward disabled states and clickable breadcrumb levels', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('aria-label="Explorer navigation"') + expect(html).toContain('aria-label="Back"') + expect(html).toContain('aria-label="Forward"') + expect(html).toContain('disabled=""') + expect(html).toContain('CodeGraph') + expect(html).toContain('main.ts') + expect(html).toContain('run') + }) +}) diff --git a/packages/dashboard/src/components/dashboard/file-relationships.test.tsx b/packages/dashboard/src/components/dashboard/file-relationships.test.tsx new file mode 100644 index 0000000..325a68e --- /dev/null +++ b/packages/dashboard/src/components/dashboard/file-relationships.test.tsx @@ -0,0 +1,142 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { + EntityDetail, + FileRelationshipsContent, + relationshipNodeToGraphNode, +} from './entity-detail' +import { + fetchFileRelationships, + type FileRelationships, +} from '@/lib/references' + +const relationships: FileRelationships = { + filePath: '/repo/main.ts', + containedSymbols: [{ + id: 'Function:/repo/main.ts:run:4', + label: 'Function', + displayName: 'run', + filePath: '/repo/main.ts', + data: { name: 'run', filePath: '/repo/main.ts', startLine: 4 }, + }], + imports: [{ + id: 'File:/repo/dep.ts', + label: 'File', + displayName: 'dep.ts', + filePath: '/repo/dep.ts', + data: { name: 'dep.ts', filePath: '/repo/dep.ts' }, + }], + importers: [], + knowledgeEntities: [{ + id: 'Entity:Decision:Main entry point', + label: 'Entity', + displayName: 'Main entry point', + data: { text: 'Main entry point', type: 'Decision' }, + }], +} + +describe('file relationship loading', () => { + it('requests the frozen endpoint with an encoded path and validates the response', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(relationships), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + const result = await fetchFileRelationships('/repo/a file.ts', undefined, fetcher) + + expect(fetcher).toHaveBeenCalledWith( + expect.stringContaining('/api/graph/file-relationships?path=%2Frepo%2Fa+file.ts'), + { signal: undefined }, + ) + expect(result).toEqual(relationships) + }) + + it('rejects malformed payloads instead of rendering untrusted response shapes', async () => { + const malformedPayloads = [ + { + filePath: '/repo/main.ts', + containedSymbols: 'not-an-array', + imports: [], + importers: [], + knowledgeEntities: [], + }, + { + filePath: '/repo/main.ts', + containedSymbols: [{ + id: 42, + label: 'Function', + displayName: 'run', + filePath: '/repo/main.ts', + data: { name: 'run', startLine: 4 }, + }], + imports: [], + importers: [], + knowledgeEntities: [], + }, + ] + + for (const payload of malformedPayloads) { + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify(payload), { status: 200 }), + ) + + await expect(fetchFileRelationships('/repo/main.ts', undefined, fetcher)).rejects.toThrow( + 'Invalid file relationships response', + ) + } + }) +}) + +describe('file relationship panel', () => { + it('renders explicit loading and error states', () => { + const loadingHtml = renderToStaticMarkup() + const errorHtml = renderToStaticMarkup( + , + ) + + expect(loadingHtml).toContain('role="status"') + expect(loadingHtml).toContain('Loading file relationships') + expect(errorHtml).toContain('role="alert"') + expect(errorHtml).toContain('API unavailable') + }) + + it('renders all four sections and focusable selection buttons', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('Contained symbols') + expect(html).toContain('Imports') + expect(html).toContain('Importers') + expect(html).toContain('Knowledge entities') + expect(html).toContain('Nothing found') + expect(html.match(/ + ))} +
+ onRelayout(l.value)} + aria-pressed={layout === l.value} + aria-label={`Use ${l.label} layout`} className="h-7 px-2 text-xs" > {l.label} diff --git a/packages/dashboard/src/components/dashboard/graph-explorer.test.tsx b/packages/dashboard/src/components/dashboard/graph-explorer.test.tsx new file mode 100644 index 0000000..38ad37c --- /dev/null +++ b/packages/dashboard/src/components/dashboard/graph-explorer.test.tsx @@ -0,0 +1,111 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { EMBEDDABLE_LABELS } from '@codegraph/types' +import { AppShell } from './app-shell' +import { + GraphCanvas, + planCanvasSelection, + type GraphNode, +} from './graph-canvas' +import { GraphControls } from './graph-controls' +import { GraphLegend, buildNodeLegend } from './graph-legend' + +const selectedNode: GraphNode = { + id: 'Function:/repo/main.ts:run:4', + label: 'run', + type: 'Function', + properties: { name: 'run', filePath: '/repo/main.ts', startLine: 4 }, +} + +describe('graph legend derivation', () => { + it('derives every node label from EMBEDDABLE_LABELS with an explicit style', () => { + const legend = buildNodeLegend(EMBEDDABLE_LABELS) + + expect(legend.map((item) => item.label)).toEqual([...EMBEDDABLE_LABELS]) + expect(legend.every((item) => item.color.length > 0 && item.shape.length > 0)).toBe(true) + }) + + it('fails loudly when a shared label has no dashboard legend style', () => { + expect(() => buildNodeLegend([...EMBEDDABLE_LABELS, 'FutureLabel'])).toThrow( + 'Missing graph legend style for FutureLabel', + ) + }) +}) + +describe('externally selected canvas nodes', () => { + it('adds an unloaded node without inventing edges outside the full-graph window', () => { + const loaded: GraphNode[] = [{ + id: 'File:/repo/main.ts', + label: 'main.ts', + type: 'File', + properties: { filePath: '/repo/main.ts' }, + }] + const plan = planCanvasSelection(loaded, selectedNode) + + expect(plan).toEqual({ + nodeId: selectedNode.id, + nodeToAdd: { + data: { + id: selectedNode.id, + label: selectedNode.label, + type: selectedNode.type, + ...selectedNode.properties, + }, + }, + }) + }) +}) + +describe('graph explorer accessibility', () => { + it('names the Cytoscape region and exposes a keyboard node-selection list', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('role="region"') + expect(html).toContain('aria-label="Code graph visualization"') + expect(html).toContain('aria-label="Graph nodes"') + }) + + it('exposes disclosure and pressed states on legend filters and layout choices', () => { + const legendHtml = renderToStaticMarkup( + , + ) + const controlsHtml = renderToStaticMarkup( + , + ) + + expect(legendHtml).toContain('aria-expanded="true"') + expect(legendHtml).toContain('aria-controls="graph-legend-content"') + expect(legendHtml).toContain('aria-pressed="false"') + expect(legendHtml).toContain('aria-pressed="true"') + expect(controlsHtml).toContain('aria-pressed="true"') + expect(controlsHtml).toContain('aria-pressed="false"') + }) + + it('exposes Query as a controlled disclosure', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('aria-expanded="false"') + expect(html).toContain('aria-controls="query-panel"') + }) +}) diff --git a/packages/dashboard/src/components/dashboard/graph-legend.tsx b/packages/dashboard/src/components/dashboard/graph-legend.tsx index 3d5d8d6..ef69f78 100644 --- a/packages/dashboard/src/components/dashboard/graph-legend.tsx +++ b/packages/dashboard/src/components/dashboard/graph-legend.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { NODE_COLORS, NODE_SHAPES, EDGE_COLORS } from '@/lib/cytoscape-config' +import { EMBEDDABLE_LABELS, type EmbeddableLabel } from '@codegraph/types' +import { EDGE_COLORS } from '@/lib/cytoscape-config' interface GraphLegendProps { hiddenEdgeTypes: Set @@ -8,16 +9,35 @@ interface GraphLegendProps { onToggleNodeType: (nodeType: string) => void } -const NODE_LEGEND = [ - { label: 'File', color: NODE_COLORS.File!, shape: NODE_SHAPES.File! }, - { label: 'Function', color: NODE_COLORS.Function!, shape: NODE_SHAPES.Function! }, - { label: 'Class', color: NODE_COLORS.Class!, shape: NODE_SHAPES.Class! }, - { label: 'Interface', color: NODE_COLORS.Interface!, shape: NODE_SHAPES.Interface!, dashed: true }, - { label: 'Component', color: NODE_COLORS.Component!, shape: NODE_SHAPES.Component! }, - { label: 'Variable', color: NODE_COLORS.Variable!, shape: NODE_SHAPES.Variable! }, - { label: 'Type', color: NODE_COLORS.Type!, shape: NODE_SHAPES.Type! }, - { label: 'Entity', color: NODE_COLORS.Entity!, shape: NODE_SHAPES.Entity! }, -] +type LegendShape = 'ellipse' | 'diamond' | 'round-rectangle' | 'hexagon' + +interface NodeLegendItem { + label: string + color: string + shape: LegendShape + dashed?: boolean +} + +const LEGEND_NODE_STYLES = { + File: { color: '#6366f1', shape: 'round-rectangle' }, + Function: { color: '#10b981', shape: 'ellipse' }, + Class: { color: '#f59e0b', shape: 'diamond' }, + Interface: { color: '#f59e0b', shape: 'diamond', dashed: true }, + Component: { color: '#06b6d4', shape: 'round-rectangle' }, + Variable: { color: '#8b5cf6', shape: 'ellipse' }, + Type: { color: '#ec4899', shape: 'hexagon' }, + Entity: { color: '#f97316', shape: 'round-rectangle' }, +} satisfies Record> + +export function buildNodeLegend(labels: readonly string[]): NodeLegendItem[] { + return labels.map((label) => { + const style = LEGEND_NODE_STYLES[label as EmbeddableLabel] + if (!style) throw new Error(`Missing graph legend style for ${label}`) + return { label, ...style } + }) +} + +const NODE_LEGEND = buildNodeLegend(EMBEDDABLE_LABELS) const EDGE_LEGEND = [ { label: 'Calls', type: 'CALLS' }, @@ -33,7 +53,7 @@ function shapeClass(shape: string) { case 'ellipse': return 'rounded-full' case 'diamond': return 'rotate-45 scale-75' case 'round-rectangle': return 'rounded-sm' - case 'rectangle': return 'rounded-none' + case 'hexagon': return '[clip-path:polygon(25%_0,75%_0,100%_50%,75%_100%,25%_100%,0_50%)]' default: return 'rounded-full' } } @@ -44,7 +64,10 @@ export function GraphLegend({ hiddenEdgeTypes, onToggleEdgeType, hiddenNodeTypes return (
{!collapsed && ( -
+
{/* Nodes (clickable to filter) */}
-
+
Nodes (click to filter)
@@ -66,8 +89,11 @@ export function GraphLegend({ hiddenEdgeTypes, onToggleEdgeType, hiddenNodeTypes const hidden = hiddenNodeTypes.has(item.label) return ( +
+ ) : states.embeddings.data.length === 0 ? ( +

No embedding data available.

+ ) : (
- {embeddings - .filter(e => e.total > 0) - .map((e) => ( -
+ {states.embeddings.data + .filter((embedding) => embedding.total > 0) + .map((embedding) => ( +
- {e.label} + {embedding.label} - {e.withEmbedding}/{e.total} ({e.coverage}%) + {embedding.withEmbedding}/{embedding.total} ({embedding.coverage}%)
))}
- - - )} + )} + + - {/* Knowledge health */} - {knowledgeStats && ( + {states.knowledge.status === 'success' && ( Knowledge Graph Health @@ -169,42 +312,41 @@ export function OperationsTab() {
Low relevance entities - {knowledgeStats.lowRelevanceCount} + {states.knowledge.data.lowRelevanceCount}
Oldest access - {knowledgeStats.oldestAccess - ? new Date(knowledgeStats.oldestAccess).toLocaleDateString() - : 'N/A'} + {states.knowledge.data.oldestAccess + ? new Date(states.knowledge.data.oldestAccess).toLocaleDateString() + : 'No access recorded'}
Newest access - {knowledgeStats.newestAccess - ? new Date(knowledgeStats.newestAccess).toLocaleDateString() - : 'N/A'} + {states.knowledge.data.newestAccess + ? new Date(states.knowledge.data.newestAccess).toLocaleDateString() + : 'No access recorded'}
)} - {/* Largest files */} - {graphStats?.largestFiles && graphStats.largestFiles.length > 0 && ( + {states.graph.status === 'success' && states.graph.data.largestFiles.length > 0 && ( Largest Files (by entity count)
- {graphStats.largestFiles.slice(0, 10).map((f) => ( -
+ {states.graph.data.largestFiles.slice(0, 10).map((file) => ( +
- {f.path.replace(/^.*\/packages\//, 'packages/').replace(/^.*\/apps\//, 'apps/')} + {file.path.replace(/^.*\/packages\//, 'packages/').replace(/^.*\/apps\//, 'apps/')} - {f.entityCount} + {file.entityCount}
))}
@@ -215,3 +357,43 @@ export function OperationsTab() {
) } + +export function OperationsTab() { + const [states, setStates] = useState(INITIAL_STATES) + + const refresh = useCallback(async (resource: keyof OperationsStates): Promise => { + if (resource === 'graph') { + setStates((current) => ({ ...current, graph: { status: 'loading' } })) + const graph = await loadGraphStats(fetch) + setStates((current) => ({ ...current, graph })) + return + } + if (resource === 'knowledge') { + setStates((current) => ({ ...current, knowledge: { status: 'loading' } })) + const knowledge = await loadKnowledgeStats(fetch) + setStates((current) => ({ ...current, knowledge })) + return + } + + setStates((current) => ({ ...current, embeddings: { status: 'loading' } })) + const embeddings = await loadEmbeddingStats(fetch) + setStates((current) => ({ ...current, embeddings })) + }, []) + + useEffect(() => { + void refresh('graph') + void refresh('knowledge') + void refresh('embeddings') + }, [refresh]) + + return ( + void refresh('graph'), + knowledge: () => void refresh('knowledge'), + embeddings: () => void refresh('embeddings'), + }} + /> + ) +} diff --git a/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx b/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx index 3c19375..e3f8ed7 100644 --- a/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx +++ b/packages/dashboard/src/components/dashboard/parse-project-dialog.tsx @@ -21,6 +21,70 @@ interface ParseResult { error?: string } +interface ParseProjectFormProps { + path: string + loading: boolean + result: ParseResult | null + onPathChange: (path: string) => void + onParse: () => void + onCancel: () => void +} + +export function ParseProjectForm({ + path, + loading, + result, + onPathChange, + onParse, + onCancel, +}: ParseProjectFormProps) { + return ( +
+ + onPathChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onParse() + if (event.key === 'Escape') onCancel() + }} + className="h-7 w-64 text-xs" + autoFocus + /> + + + {result && ( + result.success ? ( + + {result.stats?.files} files, {result.stats?.entities} symbols ({((result.stats?.durationMs ?? 0) / 1000).toFixed(1)}s) + + ) : ( + + {result.error} + + ) + )} +
+ ) +} + export function ParseProjectDialog({ apiUrl, onProjectParsed }: ParseProjectDialogProps) { const [open, setOpen] = useState(false) const [path, setPath] = useState('') @@ -73,47 +137,19 @@ export function ParseProjectDialog({ apiUrl, onProjectParsed }: ParseProjectDial ) } + const closeForm = () => { + setOpen(false) + setResult(null) + } + return ( -
- setPath(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleParse() - if (e.key === 'Escape') { setOpen(false); setResult(null) } - }} - className="h-7 w-64 text-xs" - autoFocus - /> - - - {result && ( - result.success ? ( - - {result.stats?.files} files, {result.stats?.entities} symbols ({((result.stats?.durationMs ?? 0) / 1000).toFixed(1)}s) - - ) : ( - - {result.error} - - ) - )} -
+ void handleParse()} + onCancel={closeForm} + /> ) } diff --git a/packages/dashboard/src/components/dashboard/project-selector.tsx b/packages/dashboard/src/components/dashboard/project-selector.tsx index 81d1de5..153b494 100644 --- a/packages/dashboard/src/components/dashboard/project-selector.tsx +++ b/packages/dashboard/src/components/dashboard/project-selector.tsx @@ -1,67 +1,160 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' import { API_URL } from '@/lib/api' - interface Project { id: string name: string rootPath: string | null } +type ProjectState = + | { status: 'loading' } + | { status: 'success'; data: Project[] } + | { status: 'error'; message: string } + interface ProjectSelectorProps { onProjectChange?: (project: Project | null) => void } -export function ProjectSelector({ onProjectChange }: ProjectSelectorProps) { - const [projects, setProjects] = useState([]) - const [selected, setSelected] = useState(null) +interface ProjectSelectorContentProps { + state: ProjectState + selected: string | null + onSelect: (project: Project | null) => void + onRetry: () => void +} - useEffect(() => { - fetch(`${API_URL}/api/projects`) - .then(r => r.ok ? r.json() : { projects: [] }) - .then(data => { - const p = data.projects ?? [] - setProjects(p) - // Auto-select last project (most recently indexed) - if (p.length > 0 && !selected) { - const last = p[p.length - 1] - setSelected(last.id) - onProjectChange?.(last) - } - }) - .catch(() => {}) - }, []) // eslint-disable-line react-hooks/exhaustive-deps - - if (projects.length === 0) return null - - if (projects.length === 1) { +interface FetchResponse { + ok: boolean + status: number + statusText: string + json(): Promise +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function parseProjects(value: unknown): Project[] { + if (!isRecord(value) || !Array.isArray(value.projects)) { + throw new Error('Invalid projects response') + } + + return value.projects.map((project) => { + if ( + !isRecord(project) + || typeof project.id !== 'string' + || typeof project.name !== 'string' + || (project.rootPath !== null && typeof project.rootPath !== 'string') + ) { + throw new Error('Invalid projects response') + } + return { id: project.id, name: project.name, rootPath: project.rootPath } + }) +} + +async function loadProjects( + fetcher: (input: string) => Promise, +): Promise { + try { + const response = await fetcher(`${API_URL}/api/projects`) + if (!response.ok) { + const statusText = response.statusText ? ` ${response.statusText}` : '' + throw new Error(`HTTP ${response.status}${statusText}`) + } + return { status: 'success', data: parseProjects(await response.json()) } + } catch (error) { + return { + status: 'error', + message: error instanceof Error ? error.message : 'Request failed', + } + } +} + +export function ProjectSelectorContent({ + state, + selected, + onSelect, + onRetry, +}: ProjectSelectorContentProps) { + if (state.status === 'loading') return null + + if (state.status === 'error') { + return ( +
+ Projects unavailable + +
+ ) + } + + if (state.data.length === 0) return null + + if (state.data.length === 1) { return (
-
- {projects[0]!.name} +
+ {state.data[0]!.name}
) } return (
-
+
) } + +export function ProjectSelector({ onProjectChange }: ProjectSelectorProps) { + const [state, setState] = useState({ status: 'loading' }) + const [selected, setSelected] = useState(null) + const selectedRef = useRef(null) + + const refresh = useCallback(async (): Promise => { + setState({ status: 'loading' }) + const nextState = await loadProjects(fetch) + setState(nextState) + + if (nextState.status !== 'success' || nextState.data.length === 0) return + const currentProject = nextState.data.find((project) => project.id === selectedRef.current) + const nextProject = currentProject ?? nextState.data[nextState.data.length - 1]! + selectedRef.current = nextProject.id + setSelected(nextProject.id) + onProjectChange?.(nextProject) + }, [onProjectChange]) + + useEffect(() => { + void refresh() + }, [refresh]) + + return ( + { + selectedRef.current = project?.id ?? null + setSelected(project?.id ?? null) + onProjectChange?.(project) + }} + onRetry={() => void refresh()} + /> + ) +} diff --git a/packages/dashboard/src/components/dashboard/query-panel.test.tsx b/packages/dashboard/src/components/dashboard/query-panel.test.tsx new file mode 100644 index 0000000..a55bed9 --- /dev/null +++ b/packages/dashboard/src/components/dashboard/query-panel.test.tsx @@ -0,0 +1,78 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import * as QueryPanelModule from './query-panel' + +interface Deferred { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve: ((value: T) => void) | undefined + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + if (!resolve) throw new Error('Deferred promise was not initialized') + return { promise, resolve } +} + +function jsonResponse(body: Record): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function getQueryManagerFactory() { + return (QueryPanelModule as unknown as { + createQueryRequestManager?: typeof import('./query-panel')['createQueryRequestManager'] + }).createQueryRequestManager +} + +describe('QueryPanel latest execution behavior', () => { + it('keeps the newer execution visible when the older response resolves last', async () => { + const older = deferred() + const newer = deferred() + const signals: AbortSignal[] = [] + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.signal) signals.push(init.signal) + return signals.length === 1 ? older.promise : newer.promise + }) + const visibleResults: unknown[][] = [] + const factory = getQueryManagerFactory() + + expect(factory, 'QueryPanel must expose the request manager it uses').toBeTypeOf('function') + if (!factory) return + + const manager = factory({ + apiUrl: 'http://dashboard.test', + fetchImpl: fetchMock, + onLoading: vi.fn(), + onError: vi.fn(), + onResults: (results) => { + if (results !== null) visibleResults.push(results) + }, + onMeta: vi.fn(), + onDuration: vi.fn(), + }) + + const firstRun = manager.execute('search', 'older') + const secondRun = manager.execute('search', 'newer') + expect(signals[0]?.aborted).toBe(true) + newer.resolve(jsonResponse({ results: [{ name: 'newerResult' }], total: 1, durationMs: 2 })) + await secondRun + older.resolve(jsonResponse({ results: [{ name: 'olderResult' }], total: 1, durationMs: 10 })) + await firstRun + + expect(visibleResults.at(-1)).toEqual([{ name: 'newerResult' }]) + }) +}) + +describe('QueryPanel accessibility', () => { + it('renders a labeled query textarea', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('aria-label="Query"') + expect(html).toContain('role="alert"') + }) +}) diff --git a/packages/dashboard/src/components/dashboard/query-panel.tsx b/packages/dashboard/src/components/dashboard/query-panel.tsx index a65179c..1fdfe5f 100644 --- a/packages/dashboard/src/components/dashboard/query-panel.tsx +++ b/packages/dashboard/src/components/dashboard/query-panel.tsx @@ -17,6 +17,141 @@ interface ResultItem { [key: string]: unknown } +interface QueryRequestManagerOptions { + apiUrl: string + fetchImpl?: typeof fetch + onLoading: (loading: boolean) => void + onError: (error: string | null) => void + onResults: (results: unknown[] | null) => void + onMeta: (meta: Record | null) => void + onDuration: (durationMs: number | null) => void +} + +export interface QueryRequestManager { + execute: (mode: QueryMode, query: string) => Promise + cancel: () => void +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === 'AbortError' + : error instanceof Error && error.name === 'AbortError' +} + +function asRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Query returned an invalid response') + } + return value as Record +} + +function resultArray(value: unknown): unknown[] { + if (value === undefined) return [] + if (!Array.isArray(value)) throw new Error('Query returned an invalid response') + return value +} + +function responseError(data: Record, status: number): string | null { + if (typeof data.error === 'string') return data.error + return status >= 200 && status < 300 ? null : `HTTP ${status}` +} + +export function createQueryRequestManager({ + apiUrl, + fetchImpl = fetch, + onLoading, + onError, + onResults, + onMeta, + onDuration, +}: QueryRequestManagerOptions): QueryRequestManager { + let generation = 0 + let activeController: AbortController | null = null + + const cancel = (): void => { + generation += 1 + activeController?.abort() + activeController = null + onLoading(false) + } + + return { + async execute(mode: QueryMode, query: string): Promise { + const trimmed = query.trim() + if (!trimmed) return + + generation += 1 + const requestGeneration = generation + activeController?.abort() + const controller = new AbortController() + activeController = controller + onLoading(true) + onError(null) + onResults(null) + onMeta(null) + onDuration(null) + + const start = Date.now() + try { + let response: Response + if (mode === 'cypher') { + response = await fetchImpl(`${apiUrl}/api/query/cypher`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: trimmed, params: {} }), + signal: controller.signal, + }) + } else if (mode === 'search') { + response = await fetchImpl( + `${apiUrl}/api/search?q=${encodeURIComponent(trimmed)}&limit=20`, + { signal: controller.signal }, + ) + } else { + response = await fetchImpl(`${apiUrl}/api/query/natural`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question: trimmed }), + signal: controller.signal, + }) + } + + const data = asRecord(await response.json()) + if (requestGeneration !== generation) return + + const error = responseError(data, response.status) + const reportedDuration = asFiniteNumber(data.durationMs) + onDuration(reportedDuration ?? Date.now() - start) + if (error) { + onError(error) + return + } + + onResults(resultArray(data.results)) + if (mode === 'search') { + onMeta({ total: data.total }) + } else if (mode === 'natural') { + onMeta({ + routedTo: data.routedTo, + iterations: data.iterations, + queries: data.queries, + total: data.total, + }) + } + } catch (error) { + if (requestGeneration !== generation || isAbortError(error)) return + onDuration(Date.now() - start) + onError(error instanceof Error ? error.message : 'Query failed') + } finally { + if (requestGeneration === generation) { + onLoading(false) + if (activeController === controller) activeController = null + } + } + }, + cancel, + } +} + function asString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined } @@ -41,77 +176,33 @@ export function QueryPanel({ apiUrl }: QueryPanelProps) { const [durationMs, setDurationMs] = useState(null) const [copied, setCopied] = useState(false) const textareaRef = useRef(null) + const requestManagerRef = useRef(null) useEffect(() => { textareaRef.current?.focus() }, [mode]) + useEffect(() => { + const manager = createQueryRequestManager({ + apiUrl, + onLoading: setLoading, + onError: setError, + onResults: setResults, + onMeta: setMeta, + onDuration: setDurationMs, + }) + requestManagerRef.current = manager + return () => { + manager.cancel() + if (requestManagerRef.current === manager) requestManagerRef.current = null + } + }, [apiUrl]) + const handleExecute = useCallback(async () => { const trimmed = query.trim() if (!trimmed) return - - setLoading(true) - setError(null) - setResults(null) - setMeta(null) - setDurationMs(null) - - const start = Date.now() - try { - let res: Response - let data: Record - - if (mode === 'cypher') { - res = await fetch(`${apiUrl}/api/query/cypher`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: trimmed, params: {} }), - }) - data = await res.json() - setDurationMs(Date.now() - start) - if (!res.ok || data.error) { - setError(data.error as string ?? `HTTP ${res.status}`) - } else { - setResults(data.results as unknown[] ?? []) - } - } else if (mode === 'search') { - res = await fetch(`${apiUrl}/api/search?q=${encodeURIComponent(trimmed)}&limit=20`) - data = await res.json() - setDurationMs(data.durationMs as number ?? Date.now() - start) - if (!res.ok || data.error) { - setError(data.error as string ?? `HTTP ${res.status}`) - } else { - setResults(data.results as unknown[] ?? []) - setMeta({ total: data.total }) - } - } else { - // Natural language - res = await fetch(`${apiUrl}/api/query/natural`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ question: trimmed }), - }) - data = await res.json() - setDurationMs(data.durationMs as number ?? Date.now() - start) - if (!res.ok || data.error) { - setError(data.error as string ?? `HTTP ${res.status}`) - } else { - setResults(data.results as unknown[] ?? []) - setMeta({ - routedTo: data.routedTo, - iterations: data.iterations, - queries: data.queries, - total: data.total, - }) - } - } - } catch (err) { - setDurationMs(Date.now() - start) - setError(err instanceof Error ? err.message : 'Query failed') - } finally { - setLoading(false) - } - }, [query, mode, apiUrl]) + await requestManagerRef.current?.execute(mode, trimmed) + }, [query, mode]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -154,7 +245,13 @@ export function QueryPanel({ apiUrl }: QueryPanelProps) { ]).map((m) => (