From 9b8d9e756a4e57882da1f375f039b88674a46645 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Fri, 21 Aug 2026 17:02:30 -0400 Subject: [PATCH 1/2] Make the dashboard truthful: honest graph windows, real errors, and navigable exploration The graph canvas now receives a coherent window: nodes first, then only edges whose both endpoints are in the same response, ending years of edges pointing at nodes that were never fetched. Knowledge entities appear on the canvas for the first time, scoped correctly to their project through ABOUT targets with directory-boundary matching, after review caught the first attempt leaking every entity into every project. Embedding vectors are stripped from every dashboard payload. Search is debounced, aborts stale requests, and only the latest generation may write results; a failed request shows an error instead of silently clearing. The operations tab shows per-card error states with retry instead of rendering zeros when the API is down. Every dashboard route validates its numeric parameters with 400s. The explorer gains selection history with Back and Forward, keyboard shortcuts, and a project, file, symbol breadcrumb. Selecting a File shows its contained symbols, imports, importers, and linked knowledge through a new file-relationships endpoint. Selecting an off-canvas result adds the node to the canvas and pans to it, using canonical line-suffixed ids shared with the graph layer after review caught the mismatch. The legend derives from the shared label taxonomy and fails loudly on unknown labels. Eleven accessibility findings are fixed with measured contrast above 7 to 1, ARIA disclosure and pressed states, and a keyboard-reachable node list beside the canvas. Two adversarial review rounds and one final probe drove out four defects and three test-evidence gaps; every fix was re-verified against the reviewer's own attack scripts on fresh fixtures. Co-Authored-By: Claude Fable 5 --- .../api/src/__tests__/graph-route.test.ts | 135 +++++++ .../api/src/__tests__/profile-route.test.ts | 13 +- .../api/src/__tests__/search-route.test.ts | 20 + .../api/src/__tests__/source-route.test.ts | 52 +++ packages/api/src/routes/graph.ts | 82 +++- packages/api/src/routes/profile.ts | 28 +- packages/api/src/routes/search.ts | 13 +- packages/api/src/routes/source.ts | 62 ++- packages/core/src/__tests__/service.test.ts | 37 ++ .../core/src/services/graph-data-service.ts | 34 +- packages/dashboard/package.json | 1 + packages/dashboard/src/App.tsx | 12 +- .../src/components/dashboard/app-shell.tsx | 265 +++++++++++-- .../components/dashboard/embedding-badge.tsx | 219 ++++++++--- .../components/dashboard/entity-detail.tsx | 166 +++++++-- .../dashboard/explorer-navigation.test.tsx | 143 +++++++ .../dashboard/file-relationships.test.tsx | 142 +++++++ .../src/components/dashboard/graph-canvas.tsx | 172 ++++++++- .../components/dashboard/graph-controls.tsx | 2 + .../dashboard/graph-explorer.test.tsx | 111 ++++++ .../src/components/dashboard/graph-legend.tsx | 59 ++- .../dashboard/operations-tab.test.tsx | 127 +++++++ .../components/dashboard/operations-tab.tsx | 352 +++++++++++++----- .../dashboard/parse-project-dialog.tsx | 118 ++++-- .../components/dashboard/project-selector.tsx | 165 ++++++-- .../components/dashboard/query-panel.test.tsx | 78 ++++ .../src/components/dashboard/query-panel.tsx | 239 ++++++++---- .../dashboard/search-panel.test.tsx | 156 ++++++++ .../src/components/dashboard/search-panel.tsx | 213 +++++++++-- .../dashboard/secondary-controls.test.tsx | 81 ++++ .../src/components/dashboard/stat-card.tsx | 32 +- packages/dashboard/src/globals.css | 12 + packages/dashboard/src/lib/references.ts | 92 +++++ .../src/__tests__/full-graph-window.test.ts | 218 +++++++++++ .../src/__tests__/label-constants.test.ts | 32 +- packages/graph/src/queries.ts | 245 +++++++----- 36 files changed, 3374 insertions(+), 554 deletions(-) create mode 100644 packages/api/src/__tests__/graph-route.test.ts create mode 100644 packages/api/src/__tests__/source-route.test.ts create mode 100644 packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/file-relationships.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/graph-explorer.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/operations-tab.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/query-panel.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/search-panel.test.tsx create mode 100644 packages/dashboard/src/components/dashboard/secondary-controls.test.tsx create mode 100644 packages/graph/src/__tests__/full-graph-window.test.ts 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 00000000..a1b557a4 --- /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 a1c85ae3..f018227c 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 bdb4f19c..26761d0b 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 00000000..c93467f1 --- /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 4b82a7c0..b78d1f29 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 dbbb4b36..10da020c 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 c94d5d7b..f70d1f46 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 160b4342..5f9b84b6 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 695c2e8c..0341d6f7 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 8830d383..0d0dc32e 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 1080c471..4b107011 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 e19a55d6..9e82a429 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 d555fed3..ef3e0600 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 f63095ab..d42b868b 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 a16d5b03..dee407d8 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 00000000..d70b7a6d --- /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 00000000..325a68e9 --- /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 00000000..38ad37c2 --- /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 3d5d8d61..ef69f78a 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 3c19375d..e3f8ed7e 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 81d1de5f..153b4942 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 00000000..a55bed9f --- /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 a65179cf..1fdfe5ff 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) => (