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 +