From 07f1beba646c8b7636b3d7b861ba26e6a8afaae3 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Sun, 23 Aug 2026 15:16:42 -0400 Subject: [PATCH] Page through the graph window and load more without losing your place The window is deterministic, so the next 300 is well defined. Both window routes accept an offset and return page metadata with honest totals; pages are globally consistent (page N plus N plus 1 equals the first 2N window, proven by walking the whole index with no duplicates or gaps) and cost the same at offset 18,000 as at 0. A bounded POST returns edges induced among a set of loaded ids so an appended page connects to what is already on screen. The explorer gains Previous and Next with a nodes A to B of M indicator, and Load next N, which appends while preserving the viewport and positions, chunking induced-edge requests under the cap and carrying the project scope on every chunk after review caught it missing. Offset persists with the other view state and Back returns to the prior page. Co-Authored-By: Claude Fable 5 --- .../api/src/__tests__/graph-route.test.ts | 160 +++++++++++++- packages/api/src/routes/graph.ts | 77 ++++++- packages/core/src/__tests__/service.test.ts | 13 ++ packages/core/src/service.ts | 4 +- .../core/src/services/graph-data-service.ts | 8 +- .../src/components/dashboard/app-shell.tsx | 33 ++- .../dashboard/explorer-navigation.test.tsx | 24 ++ .../dashboard/graph-canvas-expansion.test.ts | 38 ++++ .../src/components/dashboard/graph-canvas.tsx | 110 +++++++++- .../components/dashboard/graph-controls.tsx | 57 ++++- .../dashboard/graph-window.test.tsx | 206 +++++++++++++++++- packages/dashboard/src/lib/graph-window.ts | 166 +++++++++++++- .../src/__tests__/full-graph-window.test.ts | 16 ++ .../graph-window-contract.integration.test.ts | 81 ++++++- packages/graph/src/index.ts | 1 + packages/graph/src/queries.ts | 148 ++++++++++++- 16 files changed, 1098 insertions(+), 44 deletions(-) diff --git a/packages/api/src/__tests__/graph-route.test.ts b/packages/api/src/__tests__/graph-route.test.ts index 3540172..846feb0 100644 --- a/packages/api/src/__tests__/graph-route.test.ts +++ b/packages/api/src/__tests__/graph-route.test.ts @@ -77,6 +77,11 @@ describe('graph route numeric boundaries', () => { totalEdges: 0, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset: 0, + limit: 100, + returned: 0, + hasMore: false, + nextOffset: null, truncated: false, }); mockedReferences.mockResolvedValue({ references: [], referencingFiles: [], truncated: false }); @@ -106,7 +111,23 @@ describe('graph route numeric boundaries', () => { const response = await graphRoutes.request('/api/graph/full?limit=1000'); expect(response.status).toBe(200); - expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined); + expect(mockedFullGraph).toHaveBeenCalledWith(1000, undefined, 0); + }); + + it.each(['NaN', 'Infinity', '-1', '1.5'])( + 'rejects full graph offset=%s before touching the graph', + async (offset) => { + const result = await errorFor(`/api/graph/full?offset=${offset}`); + + expect(result).toEqual({ status: 400, error: 'offset must be a non-negative integer' }); + expect(mockedFullGraph).not.toHaveBeenCalled(); + }, + ); + + it('forwards offset zero and positive offsets', async () => { + await graphRoutes.request('/api/graph/full?limit=25&offset=3000'); + + expect(mockedFullGraph).toHaveBeenCalledWith(25, undefined, 3000); }); it('preserves full graph totals, ordering metadata, and truncation caveat', async () => { @@ -117,6 +138,11 @@ describe('graph route numeric boundaries', () => { totalEdges: 40, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset: 0, + limit: 10, + returned: 0, + hasMore: true, + nextOffset: 10, truncated: true, }); @@ -130,6 +156,11 @@ describe('graph route numeric boundaries', () => { totalEdges: 40, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset: 0, + limit: 10, + returned: 0, + hasMore: true, + nextOffset: 10, truncated: true, }); }); @@ -153,6 +184,11 @@ describe('graph route numeric boundaries', () => { totalEdges: 1, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset: 0, + limit: 10, + returned: 0, + hasMore: false, + nextOffset: null, truncated: false, }); @@ -173,7 +209,7 @@ describe('graph route numeric boundaries', () => { const response = await graphRoutes.request('/api/graph/full?projectId=project-app'); expect(response.status).toBe(200); - expect(mockedFullGraph).toHaveBeenCalledWith(100, '/workspace/app'); + expect(mockedFullGraph).toHaveBeenCalledWith(100, '/workspace/app', 0); }); it('does not fall back to the global graph for an unknown projectId', async () => { @@ -257,6 +293,11 @@ describe('GET /api/graph/full unavailable storage', () => { totalEdges: 0, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset: 0, + limit: 100, + returned: 0, + hasMore: false, + nextOffset: null, truncated: false, storage: blockedSetupStatus.storage, }); @@ -277,6 +318,11 @@ describe('GET /api/graph/files', () => { totalNodes: 4, totalEdges: 5, windowOrder: 'degree-desc,id-asc' as const, + offset: 0, + limit: 50, + returned: 1, + hasMore: true, + nextOffset: 1, truncated: true, }; @@ -305,7 +351,23 @@ describe('GET /api/graph/files', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual(fileGraphResult); - expect(getFileGraph).toHaveBeenCalledWith(50, '/x'); + expect(getFileGraph).toHaveBeenCalledWith(50, '/x', 0); + }); + + it.each(['NaN', 'Infinity', '-1', '1.5'])( + 'rejects offset=%s before touching the graph', + async (offset) => { + const result = await errorFor(`/api/graph/files?offset=${offset}`); + + expect(result).toEqual({ status: 400, error: 'offset must be a non-negative integer' }); + expect(mockedGetGraphClient).not.toHaveBeenCalled(); + }, + ); + + it('forwards a positive file graph offset', async () => { + await graphRoutes.request('/api/graph/files?limit=50&offset=3000'); + + expect(getFileGraph).toHaveBeenCalledWith(50, undefined, 3000); }); it('does not return the global file graph for an unknown projectId', async () => { @@ -321,6 +383,98 @@ describe('GET /api/graph/files', () => { }); }); +describe('POST /api/graph/induced-edges', () => { + const getInducedEdges = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockedGetGraphClient.mockResolvedValue({ + roQuery: vi.fn().mockResolvedValue({ data: [{ rootPath: '/x' }], metadata: [] }), + } as never); + getInducedEdges.mockResolvedValue([{ + source: 'File:/x/a.ts', + target: 'File:/x/b.ts', + label: 'IMPORTS', + id: 'hidden', + data: { embedding: [0.1] }, + }]); + mockedCreateQueries.mockReturnValue({ getInducedEdges } as never); + }); + + it.each([ + undefined, + null, + {}, + { ids: 'File:/x/a.ts' }, + { ids: [1] }, + ])('rejects malformed body %j before touching the graph', async (body) => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? '{' : JSON.stringify(body), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'body must be an object with an ids string array' }); + expect(getInducedEdges).not.toHaveBeenCalled(); + }); + + it('rejects more than 2000 ids before touching the graph', async () => { + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from({ length: 2001 }, (_, index) => `node:${index}`) }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'ids must contain at most 2000 items' }); + expect(getInducedEdges).not.toHaveBeenCalled(); + }); + + it('accepts exactly 2000 ids', async () => { + const ids = Array.from({ length: 2000 }, (_, index) => `node:${index}`); + const response = await graphRoutes.request('/api/graph/induced-edges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + + expect(response.status).toBe(200); + expect(getInducedEdges).toHaveBeenCalledWith(ids, undefined); + }); + + it('returns only the public window edge shape and forwards project scope', async () => { + const ids = ['File:/x/a.ts', 'File:/x/b.ts', 'File:/x/missing.ts']; + const response = await graphRoutes.request('/api/graph/induced-edges?projectId=project-x', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + edges: [{ source: 'File:/x/a.ts', target: 'File:/x/b.ts', label: 'IMPORTS' }], + }); + expect(getInducedEdges).toHaveBeenCalledWith(ids, '/x'); + }); + + it('does not fall back to global scope for an unknown project', async () => { + mockedGetGraphClient.mockResolvedValueOnce({ + roQuery: vi.fn().mockResolvedValue({ data: [], metadata: [] }), + } as never); + + const response = await graphRoutes.request('/api/graph/induced-edges?projectId=missing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: [] }), + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: 'Project not found' }); + expect(getInducedEdges).not.toHaveBeenCalled(); + }); +}); + describe('GET /api/graph/neighbors', () => { const getNodeNeighbors = vi.fn(); const neighborResult = { diff --git a/packages/api/src/routes/graph.ts b/packages/api/src/routes/graph.ts index 1a1d75c..76460ee 100644 --- a/packages/api/src/routes/graph.ts +++ b/packages/api/src/routes/graph.ts @@ -8,6 +8,7 @@ export const graphRoutes = new Hono(); const FULL_GRAPH_LIMIT_MAX = 1000; const FILE_GRAPH_LIMIT_MAX = 1000; +const INDUCED_EDGE_IDS_MAX = 2000; const NEIGHBOR_LIMIT_MAX = 1000; const FILE_RELATIONSHIP_LIMIT_MAX = 1000; const REFERENCE_LIMIT_MAX = 1000; @@ -31,6 +32,15 @@ function boundedPositiveInteger( return { valid: true, value }; } +function nonNegativeInteger(raw: string | undefined, name: string): BoundedIntegerResult { + if (raw === undefined) return { valid: true }; + const value = Number(raw); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + return { valid: false, error: `${name} must be a non-negative integer` }; + } + return { valid: true, value }; +} + async function resolveProjectRootPath(projectId: string): Promise { const client = await getGraphClient(); const projectResult = await client.roQuery<{ rootPath: string | null }>( @@ -51,24 +61,29 @@ function projectFullGraphResponse { + let limit = 100; + let offset = 0; try { 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 parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); + if (!parsedOffset.valid) return c.json({ error: parsedOffset.error }, 400); + limit = parsedLimit.value ?? limit; + offset = parsedOffset.value ?? offset; const projectId = c.req.query('projectId'); if (projectId) { const rootPath = await resolveProjectRootPath(projectId); if (!rootPath) return c.json({ error: 'Project not found' }, 404); - const data = await codeGraphService.getFullGraph(limit, rootPath); + const data = await codeGraphService.getFullGraph(limit, rootPath, offset); return c.json(projectFullGraphResponse(data)); } // No project filter — return all const rootPath = c.req.query('rootPath') ?? undefined; - const data = await codeGraphService.getFullGraph(limit, rootPath); + const data = await codeGraphService.getFullGraph(limit, rootPath, offset); return c.json(projectFullGraphResponse(data)); } catch (error) { const setup = await readBlockedSetupStatus(); @@ -80,6 +95,11 @@ graphRoutes.get('/api/graph/full', async (c) => { totalEdges: 0, windowOrder: 'degree-desc,id-asc', degreeScope: 'global', + offset, + limit, + returned: 0, + hasMore: false, + nextOffset: null, truncated: false, storage: setup.storage, }); @@ -88,11 +108,15 @@ graphRoutes.get('/api/graph/full', async (c) => { } }); -/** GET /api/graph/files?projectId=X&limit=N - bounded File-to-File IMPORTS graph. */ +/** GET /api/graph/files?projectId=X&limit=N&offset=N - bounded File-to-File IMPORTS graph. */ graphRoutes.get('/api/graph/files', async (c) => { try { const parsedLimit = boundedPositiveInteger(c.req.query('limit'), 'limit', FILE_GRAPH_LIMIT_MAX); if (!parsedLimit.valid) return c.json({ error: parsedLimit.error }, 400); + const parsedOffset = nonNegativeInteger(c.req.query('offset'), 'offset'); + if (!parsedOffset.valid) return c.json({ error: parsedOffset.error }, 400); + const limit = parsedLimit.value ?? 100; + const offset = parsedOffset.value ?? 0; const projectId = c.req.query('projectId'); let rootPath: string | undefined; @@ -103,7 +127,7 @@ graphRoutes.get('/api/graph/files', async (c) => { } const client = await getGraphClient(); - const data = await createQueries(client).getFileGraph(parsedLimit.value ?? 100, rootPath); + const data = await createQueries(client).getFileGraph(limit, rootPath, offset); return c.json(data); } catch (error) { return c.json({ @@ -112,6 +136,47 @@ graphRoutes.get('/api/graph/files', async (c) => { } }); +/** POST /api/graph/induced-edges returns public edges among the requested persisted node ids. */ +graphRoutes.post('/api/graph/induced-edges', async (c) => { + try { + const body: unknown = await c.req.json().catch(() => undefined); + if ( + body === null + || typeof body !== 'object' + || !('ids' in body) + || !Array.isArray(body.ids) + || !body.ids.every((id): id is string => typeof id === 'string') + ) { + return c.json({ error: 'body must be an object with an ids string array' }, 400); + } + if (body.ids.length > INDUCED_EDGE_IDS_MAX) { + return c.json({ error: `ids must contain at most ${INDUCED_EDGE_IDS_MAX} items` }, 400); + } + + const projectId = c.req.query('projectId'); + let rootPath: string | undefined; + if (projectId) { + const resolvedRootPath = await resolveProjectRootPath(projectId); + if (!resolvedRootPath) return c.json({ error: 'Project not found' }, 404); + rootPath = resolvedRootPath; + } + + const client = await getGraphClient(); + const edges = await createQueries(client).getInducedEdges(body.ids, rootPath); + return c.json({ + edges: edges.map(({ source, target, label }) => ({ source, target, label })), + }); + } catch (error) { + return c.json({ + error: safeErrorMessage( + 'POST /api/graph/induced-edges', + error, + 'Failed to fetch induced graph edges.', + ), + }, 500); + } +}); + /** GET /api/graph/neighbors?id=X&limit=N - direct neighbors and their induced graph. */ graphRoutes.get('/api/graph/neighbors', async (c) => { try { diff --git a/packages/core/src/__tests__/service.test.ts b/packages/core/src/__tests__/service.test.ts index f46b465..f8b0b39 100644 --- a/packages/core/src/__tests__/service.test.ts +++ b/packages/core/src/__tests__/service.test.ts @@ -53,6 +53,19 @@ describe('CodeGraphService', () => { mockClient.roQuery.mockResolvedValue({ data: [], metadata: null }); }); + describe('getFullGraph', () => { + it('forwards the requested offset into the ordered graph query', async () => { + await codeGraphService.getFullGraph(25, '/repo', 3000); + + const nodeQuery = mockClient.roQuery.mock.calls.find(([cypher]) => ( + typeof cypher === 'string' && cypher.includes('ORDER BY degree DESC, stableId ASC') + )); + expect(nodeQuery?.[1]).toEqual({ + params: { limit: 25, offset: 3000, rootPath: '/repo', rootPathPrefix: '/repo/' }, + }); + }); + }); + // ========================================================================= // deleteProject // ========================================================================= diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index 5d0fa1e..490586c 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -107,8 +107,8 @@ class CodeGraphServiceImpl { // --- Graph Traversal --- - async getFullGraph(limit?: number, rootPath?: string): Promise { - return getFullGraphImpl(limit, rootPath); + async getFullGraph(limit?: number, rootPath?: string, offset?: number): Promise { + return getFullGraphImpl(limit, rootPath, offset); } async getFileSubgraph(filePath: string): Promise { diff --git a/packages/core/src/services/graph-data-service.ts b/packages/core/src/services/graph-data-service.ts index 2a51891..af3742b 100644 --- a/packages/core/src/services/graph-data-service.ts +++ b/packages/core/src/services/graph-data-service.ts @@ -59,10 +59,14 @@ export async function getGraphStatsImpl(): Promise { /** * Get the full graph (nodes + edges), optionally filtered by root path. */ -export async function getFullGraphImpl(limit?: number, rootPath?: string): Promise { +export async function getFullGraphImpl( + limit?: number, + rootPath?: string, + offset?: number, +): Promise { const client = await getGraphClient(); const queries = createQueries(client); - return queries.getFullGraph(limit, rootPath); + return queries.getFullGraph(limit, rootPath, offset); } /** diff --git a/packages/dashboard/src/components/dashboard/app-shell.tsx b/packages/dashboard/src/components/dashboard/app-shell.tsx index ccd4428..92449b0 100644 --- a/packages/dashboard/src/components/dashboard/app-shell.tsx +++ b/packages/dashboard/src/components/dashboard/app-shell.tsx @@ -19,7 +19,7 @@ import { fetchGraphNodeDetail, persistGraphViewState, readGraphViewState, - resetGraphExpansions, + resetGraphView, type GraphCanvasViewState, type GraphViewMode, type GraphWindowLimit, @@ -67,6 +67,18 @@ export function moveSelectionHistory( return index === history.index ? history : { ...history, index } } +export function pushViewChangeHistory( + history: SelectionHistory, + node: GraphNode | null, + currentView: GraphCanvasViewState, + nextView: GraphCanvasViewState, +): SelectionHistory { + const seeded = history.index < 0 + ? pushSelectionHistory(history, null, currentView, { force: true }) + : history + return pushSelectionHistory(seeded, node, nextView, { force: true }) +} + interface ExplorerBreadcrumb { level: 'project' | 'file' | 'symbol' label: string @@ -284,27 +296,34 @@ export function AppShell({ }, [selectionHistory]) const recordCanvasView = useCallback((view: GraphCanvasViewState, node?: GraphNode | null) => { + const previousView = canvasViewRef.current + canvasViewRef.current = view setCanvasView(view) - setSelectionHistory((history) => pushSelectionHistory( + setSelectionHistory((history) => pushViewChangeHistory( history, node === undefined ? currentHistoryNode(history) : node, + previousView, view, - { force: true }, )) }, []) const handleModeChange = useCallback((mode: GraphViewMode) => { - recordCanvasView({ ...canvasView, mode, fileScope: null, expansions: [] }) + recordCanvasView({ ...canvasView, mode, offset: 0, fileScope: null, expansions: [] }) }, [canvasView, recordCanvasView]) const handleWindowLimitChange = useCallback((limit: GraphWindowLimit) => { - recordCanvasView({ ...canvasView, limit }) + recordCanvasView({ ...canvasView, limit, offset: 0, expansions: [] }) }, [canvasView, recordCanvasView]) + const handlePageChange = useCallback((offset: number) => { + recordCanvasView({ ...canvasViewRef.current, offset, expansions: [] }, null) + }, [recordCanvasView]) + const handleOpenSymbols = useCallback((node: GraphNode) => { recordCanvasView({ ...canvasView, mode: 'symbols', + offset: 0, fileScope: node, expansions: [], }, node) @@ -319,7 +338,7 @@ export function AppShell({ }, [recordCanvasView]) const handleResetView = useCallback(() => { - recordCanvasView(resetGraphExpansions(canvasViewRef.current)) + recordCanvasView(resetGraphView(canvasViewRef.current)) }, [recordCanvasView]) useEffect(() => { @@ -456,10 +475,12 @@ export function AppShell({ referenceNodeIds={referenceNodeIds} mode={canvasView.mode} windowLimit={canvasView.limit} + pageOffset={canvasView.offset} fileScope={canvasView.fileScope} restoredExpansions={canvasView.expansions} onModeChange={handleModeChange} onWindowLimitChange={handleWindowLimitChange} + onPageChange={handlePageChange} expansionRequest={expansionRequest} onExpanded={handleExpanded} onResetView={handleResetView} diff --git a/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx b/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx index c35cf09..ef0315d 100644 --- a/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx +++ b/packages/dashboard/src/components/dashboard/explorer-navigation.test.tsx @@ -5,6 +5,7 @@ import { ExplorerNavigation, deriveBreadcrumbs, moveSelectionHistory, + pushViewChangeHistory, pushSelectionHistory, searchResultToGraphNode, } from './app-shell' @@ -31,6 +32,7 @@ const symbolNode: GraphNode = { const canvasView: GraphCanvasViewState = { mode: 'symbols', limit: 300, + offset: 0, fileScope: null, expansions: [], } @@ -140,6 +142,28 @@ describe('explorer selection history', () => { history = moveSelectionHistory(history, 1) expect(history.entries[history.index]?.node?.id).toBe(symbolNode.id) }) + + it('records page changes so Back restores the prior offset', () => { + const nextPage = { ...canvasView, offset: 300 } + let history = pushSelectionHistory(EMPTY_SELECTION_HISTORY, fileNode, canvasView) + history = pushSelectionHistory(history, null, nextPage, { force: true }) + + expect(history.entries[history.index]?.node).toBeNull() + + history = moveSelectionHistory(history, -1) + + expect(history.entries[history.index]?.view.offset).toBe(0) + expect(history.entries[history.index]?.node?.id).toBe(fileNode.id) + }) + + it('seeds page one when the first interaction is paging', () => { + const nextPage = { ...canvasView, offset: 300 } + + const history = pushViewChangeHistory(EMPTY_SELECTION_HISTORY, null, canvasView, nextPage) + + expect(history.entries.map((entry) => entry.view.offset)).toEqual([0, 300]) + expect(moveSelectionHistory(history, -1).entries[0]?.view.offset).toBe(0) + }) }) describe('explorer breadcrumbs', () => { diff --git a/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts b/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts index 73ade4b..cd5b489 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts +++ b/packages/dashboard/src/components/dashboard/graph-canvas-expansion.test.ts @@ -2,6 +2,7 @@ import cytoscape from 'cytoscape' import { describe, expect, it } from 'vitest' import { applyCanvasExpansion } from './graph-canvas' import { + planGraphPageAppend, planGraphExpansion, type GraphNodeData, type GraphWindow, @@ -29,6 +30,11 @@ const baseWindow: GraphWindow = { totalEdges: 0, windowOrder: 'degree-desc,id-asc', truncation: { incoming: false, outgoing: false }, + offset: 0, + limit: 300, + returned: 2, + hasMore: true, + nextOffset: 300, } const incoming: NeighborWindow = { @@ -71,6 +77,38 @@ describe('incremental graph expansion', () => { expect(new Set(plan.newNodes.map(({ position }) => `${position.x},${position.y}`)).size).toBe(2) }) + it('appends a page without duplicates in a deterministic band beside existing content', () => { + const incomingPage: GraphWindow = { + ...baseWindow, + nodes: [existingNode, ...incoming.nodes.slice(1)], + edges: incoming.edges, + offset: 300, + returned: 3, + hasMore: false, + nextOffset: null, + } + const plan = planGraphPageAppend( + baseWindow, + incomingPage, + [{ id: 'cross', source: 'existing', target: 'neighbor-a', label: 'CALLS' }], + { x1: 100, y1: 150, x2: 500, y2: 450 }, + ) + + expect(plan.preserveViewport).toBe(true) + expect(plan.fit).toBe(false) + expect(plan.runLayout).toBe(false) + expect(plan.newNodes.map(({ node }) => node.id)).toEqual(['neighbor-a', 'neighbor-b']) + expect(plan.newNodes.every(({ position }) => position.x > 500)).toBe(true) + expect(plan.newEdges.map((edge) => edge.id)).toEqual(['edge-a', 'edge-b', 'cross']) + expect(plan.window.nodes.map((node) => node.id)).toEqual([ + 'source', + 'existing', + 'neighbor-a', + 'neighbor-b', + ]) + expect(plan.window.hasMore).toBe(false) + }) + it('keeps existing positions and the viewport unchanged while adding an expansion', () => { const cy = cytoscape({ headless: true, diff --git a/packages/dashboard/src/components/dashboard/graph-canvas.tsx b/packages/dashboard/src/components/dashboard/graph-canvas.tsx index 65bffa9..e89c5f8 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas.tsx +++ b/packages/dashboard/src/components/dashboard/graph-canvas.tsx @@ -4,7 +4,10 @@ import { GraphControls } from './graph-controls' import { cytoscapeStylesheet, LAYOUT_OPTIONS, type LayoutName } from '@/lib/cytoscape-config' import { fetchGraphWindow, + fetchGraphInducedEdges, fetchNeighbors, + planGraphPageAppend, + planInducedEdgeRequests, planGraphExpansion, resetGraphWindow, restoreGraphWindow, @@ -109,9 +112,11 @@ interface GraphCanvasProps { projectId?: string | null mode?: GraphViewMode windowLimit?: GraphWindowLimit + pageOffset?: number fileScope?: GraphNode | null onModeChange?: (mode: GraphViewMode) => void onWindowLimitChange?: (limit: GraphWindowLimit) => void + onPageChange?: (offset: number) => void expansionRequest?: { node: GraphNode; sequence: number } | null restoredExpansions?: readonly GraphNode[] onExpanded?: (node: GraphNode) => void @@ -129,9 +134,11 @@ export function GraphCanvas({ projectId, mode = 'symbols', windowLimit = 300, + pageOffset = 0, fileScope = null, onModeChange, onWindowLimitChange, + onPageChange, expansionRequest = null, restoredExpansions = [], onExpanded, @@ -145,6 +152,7 @@ export function GraphCanvas({ const handledExpansionSequenceRef = useRef(null) const expansionAbortRef = useRef(null) const restorationAbortRef = useRef(null) + const loadMoreAbortRef = useRef(null) const appliedExpansionIdsRef = useRef([]) const resizeObserverRef = useRef(null) const [loading, setLoading] = useState(true) @@ -158,6 +166,8 @@ export function GraphCanvas({ const [expansionError, setExpansionError] = useState(null) const [layout, setLayout] = useState('cose') const [renderRevision, setRenderRevision] = useState(0) + const [loadingMore, setLoadingMore] = useState(false) + const [liveAnnouncement, setLiveAnnouncement] = useState('') const expandNode = useCallback(async (node: GraphNode): Promise => { if (expansionAbortRef.current !== null) return @@ -222,6 +232,7 @@ export function GraphCanvas({ apiUrl, mode, limit: windowLimit, + offset: pageOffset, projectId, fileScope, }) @@ -323,6 +334,9 @@ export function GraphCanvas({ } setLoading(false) + const rangeStart = data.returned === 0 ? 0 : data.offset + 1 + const rangeEnd = data.offset + data.returned + setLiveAnnouncement(`Loaded nodes ${rangeStart.toLocaleString()} to ${rangeEnd.toLocaleString()} of ${data.totalNodes.toLocaleString()}.`) } catch (err) { if (mounted) { setError(err instanceof Error ? err.message : 'Failed to load graph') @@ -342,11 +356,13 @@ export function GraphCanvas({ expansionAbortRef.current = null restorationAbortRef.current?.abort() restorationAbortRef.current = null + loadMoreAbortRef.current?.abort() + loadMoreAbortRef.current = null appliedExpansionIdsRef.current = [] graphWindowRef.current = null baseWindowRef.current = null } - }, [apiUrl, fileScope, mode, onNodeSelect, projectId, windowLimit]) + }, [apiUrl, fileScope, mode, onNodeSelect, pageOffset, projectId, windowLimit]) useEffect(() => { if ( @@ -554,6 +570,87 @@ export function GraphCanvas({ onResetView?.() }, [layout, onResetView]) + const handlePreviousPage = useCallback(() => { + const current = graphWindowRef.current + if (!current || current.offset === 0 || loadingMore) return + onPageChange?.(Math.max(0, current.offset - current.limit)) + }, [loadingMore, onPageChange]) + + const handleNextPage = useCallback(() => { + const current = graphWindowRef.current + if (!current || !current.hasMore || current.nextOffset === null || loadingMore) return + onPageChange?.(current.nextOffset) + }, [loadingMore, onPageChange]) + + const handleLoadMore = useCallback(async (): Promise => { + const current = graphWindowRef.current + const cy = cyRef.current + if ( + !current + || !cy + || cy.destroyed() + || !current.hasMore + || current.nextOffset === null + || loadMoreAbortRef.current !== null + || fileScope !== null + ) return + + const viewport = { pan: { ...cy.pan() }, zoom: cy.zoom() } + const controller = new AbortController() + loadMoreAbortRef.current = controller + setLoadingMore(true) + setExpansionError(null) + try { + const incoming = await fetchGraphWindow({ + apiUrl, + mode, + limit: windowLimit, + offset: current.nextOffset, + projectId, + signal: controller.signal, + }) + const latest = graphWindowRef.current + const activeCy = cyRef.current + if (!latest || !activeCy || activeCy.destroyed() || controller.signal.aborted) return + + const existingIds = latest.nodes.map((node) => node.id) + const existingIdSet = new Set(existingIds) + const newIds = incoming.nodes + .map((node) => node.id) + .filter((id) => !existingIdSet.has(id)) + const inducedEdges = await fetchGraphInducedEdges( + apiUrl, + planInducedEdgeRequests(existingIds, newIds), + controller.signal, + undefined, + projectId, + ) + if (controller.signal.aborted || activeCy.destroyed()) return + + const bounds = activeCy.nodes().boundingBox({ includeLabels: false, includeOverlays: false }) + const plan = planGraphPageAppend(latest, incoming, inducedEdges, bounds) + applyCanvasExpansion(activeCy, plan, viewport) + graphWindowRef.current = plan.window + setGraphWindow(plan.window) + setCanvasNodes(plan.window.nodes) + setNodeCount(plan.window.nodes.length) + setEdgeCount(plan.window.edges.length) + setRenderRevision((revision) => revision + 1) + setLiveAnnouncement( + `${plan.newNodes.length.toLocaleString()} nodes loaded.${plan.window.hasMore ? '' : ' All nodes loaded.'}`, + ) + } catch (error) { + if (controller.signal.aborted) return + console.error('Failed to load the next graph page', error) + setExpansionError(error instanceof Error ? error.message : 'Failed to load the next graph page') + } finally { + if (loadMoreAbortRef.current === controller) { + loadMoreAbortRef.current = null + setLoadingMore(false) + } + } + }, [apiUrl, fileScope, mode, projectId, windowLimit]) + const handleRelayout = useCallback((newLayout?: LayoutName) => { const l = newLayout ?? layout if (newLayout) setLayout(l) @@ -658,7 +755,16 @@ export function GraphCanvas({ onWindowLimitChange={onWindowLimitChange} mode={mode} onModeChange={onModeChange} - canReset={graphWindow !== null && baseWindow !== null && graphWindow !== baseWindow} + canReset={graphWindow !== null && baseWindow !== null && (graphWindow !== baseWindow || pageOffset > 0)} + pageOffset={graphWindow?.offset ?? pageOffset} + pageReturned={graphWindow?.returned ?? nodeCount} + hasMore={graphWindow?.hasMore ?? false} + pagingEnabled={fileScope === null} + isLoadingMore={loadingMore} + onPreviousPage={handlePreviousPage} + onNextPage={handleNextPage} + onLoadMore={() => { void handleLoadMore() }} + liveAnnouncement={liveAnnouncement} truncation={graphWindow?.truncation} windowOrder={graphWindow?.windowOrder} layout={layout} diff --git a/packages/dashboard/src/components/dashboard/graph-controls.tsx b/packages/dashboard/src/components/dashboard/graph-controls.tsx index 1207acb..232983f 100644 --- a/packages/dashboard/src/components/dashboard/graph-controls.tsx +++ b/packages/dashboard/src/components/dashboard/graph-controls.tsx @@ -17,6 +17,15 @@ interface GraphControlsProps { mode?: GraphViewMode onModeChange?: (mode: GraphViewMode) => void canReset?: boolean + pageOffset?: number + pageReturned?: number + hasMore?: boolean + pagingEnabled?: boolean + isLoadingMore?: boolean + onPreviousPage?: () => void + onNextPage?: () => void + onLoadMore?: () => void + liveAnnouncement?: string truncation?: GraphTruncation windowOrder?: string layout: LayoutName @@ -43,6 +52,15 @@ export function GraphControls({ mode = 'symbols', onModeChange, canReset = false, + pageOffset = 0, + pageReturned = nodeCount, + hasMore = false, + pagingEnabled = true, + isLoadingMore = false, + onPreviousPage, + onNextPage, + onLoadMore, + liveAnnouncement = '', truncation = { incoming: false, outgoing: false }, windowOrder = 'degree-desc,id-asc', layout, @@ -50,7 +68,9 @@ export function GraphControls({ const edgeCountLabel = windowOrder === 'file-contained' && truncation.window ? `${edgeCount.toLocaleString()} loaded edge${edgeCount === 1 ? '' : 's'}` : `${edgeCount.toLocaleString()} of ${totalEdges.toLocaleString()} edges` - const countLabel = `${nodeCount.toLocaleString()} of ${totalNodes.toLocaleString()} nodes, ${edgeCountLabel}` + const rangeStart = pageReturned === 0 ? 0 : pageOffset + 1 + const rangeEnd = pageOffset + pageReturned + const countLabel = `nodes ${rangeStart.toLocaleString()} to ${rangeEnd.toLocaleString()} of ${totalNodes.toLocaleString()}, ${edgeCountLabel}` const orderLabel = windowOrder.startsWith('degree-desc') ? 'Most connected first' : 'Selected file symbols' @@ -116,9 +136,41 @@ export function GraphControls({ > Reset view +