import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types'; import { resolveDependencies, getArtifactDependencies } from '../api'; import './DependencyGraph.css'; interface DependencyGraphProps { projectName: string; packageName: string; tagName: string; onClose: () => void; } interface GraphNode { id: string; project: string; package: string; version: string | null; size: number; depth: number; children: GraphNode[]; isRoot?: boolean; } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) { const navigate = useNavigate(); const containerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [resolution, setResolution] = useState(null); const [graphRoot, setGraphRoot] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [collapsedNodes, setCollapsedNodes] = useState>(new Set()); // Build graph structure from resolution data const buildGraph = useCallback(async (resolutionData: DependencyResolutionResponse) => { const artifactMap = new Map(); resolutionData.resolved.forEach(artifact => { artifactMap.set(artifact.artifact_id, artifact); }); // Fetch dependencies for each artifact to build the tree const depsMap = new Map(); for (const artifact of resolutionData.resolved) { try { const deps = await getArtifactDependencies(artifact.artifact_id); depsMap.set(artifact.artifact_id, deps.dependencies); } catch { depsMap.set(artifact.artifact_id, []); } } // Find the root artifact (the requested one) const rootArtifact = resolutionData.resolved.find( a => a.project === resolutionData.requested.project && a.package === resolutionData.requested.package ); if (!rootArtifact) { return null; } // Build tree recursively const visited = new Set(); const buildNode = (artifact: ResolvedArtifact, depth: number): GraphNode => { const nodeId = `${artifact.project}/${artifact.package}`; visited.add(artifact.artifact_id); const deps = depsMap.get(artifact.artifact_id) || []; const children: GraphNode[] = []; for (const dep of deps) { // Find the resolved artifact for this dependency const childArtifact = resolutionData.resolved.find( a => a.project === dep.project && a.package === dep.package ); if (childArtifact && !visited.has(childArtifact.artifact_id)) { children.push(buildNode(childArtifact, depth + 1)); } } return { id: nodeId, project: artifact.project, package: artifact.package, version: artifact.version || artifact.tag, size: artifact.size, depth, children, isRoot: depth === 0, }; }; return buildNode(rootArtifact, 0); }, []); useEffect(() => { async function loadData() { setLoading(true); setError(null); try { const result = await resolveDependencies(projectName, packageName, tagName); setResolution(result); const graph = await buildGraph(result); setGraphRoot(graph); } catch (err) { if (err instanceof Error) { // Check if it's a resolution error try { const errorData = JSON.parse(err.message); if (errorData.error === 'circular_dependency') { setError(`Circular dependency detected: ${errorData.cycle?.join(' → ')}`); } else if (errorData.error === 'dependency_conflict') { setError(`Dependency conflict: ${errorData.message}`); } else { setError(err.message); } } catch { setError(err.message); } } else { setError('Failed to load dependency graph'); } } finally { setLoading(false); } } loadData(); }, [projectName, packageName, tagName, buildGraph]); const handleNodeClick = (node: GraphNode) => { navigate(`/project/${node.project}/${node.package}`); onClose(); }; const handleNodeToggle = (node: GraphNode, e: React.MouseEvent) => { e.stopPropagation(); setCollapsedNodes(prev => { const next = new Set(prev); if (next.has(node.id)) { next.delete(node.id); } else { next.add(node.id); } return next; }); }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; setZoom(z => Math.max(0.25, Math.min(2, z + delta))); }; const handleMouseDown = (e: React.MouseEvent) => { if (e.target === containerRef.current || (e.target as HTMLElement).classList.contains('graph-canvas')) { setIsDragging(true); setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); } }; const handleMouseMove = (e: React.MouseEvent) => { if (isDragging) { setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y }); } }; const handleMouseUp = () => { setIsDragging(false); }; const resetView = () => { setZoom(1); setPan({ x: 0, y: 0 }); }; const renderNode = (node: GraphNode, index: number = 0): JSX.Element => { const isCollapsed = collapsedNodes.has(node.id); const hasChildren = node.children.length > 0; return (
handleNodeClick(node)} onMouseEnter={() => setHoveredNode(node)} onMouseLeave={() => setHoveredNode(null)} >
{node.project}/{node.package} {hasChildren && ( )}
{node.version && @ {node.version}} {formatBytes(node.size)}
{hasChildren && !isCollapsed && (
{node.children.map((child, i) => renderNode(child, i))}
)}
); }; return (
e.stopPropagation()}>

Dependency Graph

{projectName}/{packageName} @ {tagName} {resolution && ( {resolution.artifact_count} packages • {formatBytes(resolution.total_size)} total )}
{Math.round(zoom * 100)}%
{loading ? (
Resolving dependencies...
) : error ? (

{error}

) : graphRoot ? (
{renderNode(graphRoot)}
) : (
No dependencies to display
)}
{hoveredNode && (
{hoveredNode.project}/{hoveredNode.package} {hoveredNode.version &&
Version: {hoveredNode.version}
}
Size: {formatBytes(hoveredNode.size)}
Click to navigate
)}
); } export default DependencyGraph;