import { useMemo, useState, useEffect } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { colorForLabel } from "../lib/colors"; import { callTool } from "../api/rpc"; import type { GraphNode, GraphEdge, RepoInfo } from "../lib/types"; interface Connection { node: GraphNode; edgeType: string; direction: "inbound" | "outbound"; } interface NodeDetailPanelProps { node: GraphNode; allNodes: GraphNode[]; allEdges: GraphEdge[]; project: string | null; repoInfo: RepoInfo | null; onClose: () => void; onNavigate: (node: GraphNode) => void; } interface SnippetResult { source?: string; start_line?: number; end_line?: number; } function lineSuffix(node: GraphNode): string { if (!node.start_line) return ""; const end = node.end_line && node.end_line !== node.start_line ? `-L${node.end_line}` : ""; return `#L${node.start_line}${end}`; } /* Encode each path segment so an unusual file_path can't break (or escape) the * URL. The scheme is already https-forced by the backend (/api/repo-info); * this is defense-in-depth on the path. */ function encodePath(p: string): string { return p.split("/").map(encodeURIComponent).join("/"); } /* GitHub (or GitLab) deep-link, or null when we lack remote/path/line info. */ function githubUrl(node: GraphNode, repoInfo: RepoInfo | null): string | null { if (!repoInfo?.blob_base || !node.file_path) return null; return `${repoInfo.blob_base}/${encodePath(node.file_path)}${lineSuffix(node)}`; } export function NodeDetailPanel({ node, allNodes, allEdges, project, repoInfo, onClose, onNavigate, }: NodeDetailPanelProps) { const [code, setCode] = useState(null); const [codeLoading, setCodeLoading] = useState(false); const [codeError, setCodeError] = useState(null); /* Reset the fetched code whenever the selected node changes. */ useEffect(() => { setCode(null); setCodeError(null); setCodeLoading(false); }, [node.id]); const canFetchCode = Boolean(project && node.qualified_name); const ghUrl = githubUrl(node, repoInfo); const loadCode = async () => { if (!project || !node.qualified_name) return; setCodeLoading(true); setCodeError(null); try { const res = await callTool("get_code_snippet", { qualified_name: node.qualified_name, project, }); setCode(res.source ?? "(source not available)"); } catch (e) { setCodeError(e instanceof Error ? e.message : "Failed to load code"); } finally { setCodeLoading(false); } }; const connections = useMemo(() => { const nodeMap = new Map(); for (const n of allNodes) nodeMap.set(n.id, n); const conns: Connection[] = []; for (const edge of allEdges) { if (edge.source === node.id) { const t = nodeMap.get(edge.target); if (t) conns.push({ node: t, edgeType: edge.type, direction: "outbound" }); } if (edge.target === node.id) { const s = nodeMap.get(edge.source); if (s) conns.push({ node: s, edgeType: edge.type, direction: "inbound" }); } } return conns; }, [node, allNodes, allEdges]); const outbound = connections.filter((c) => c.direction === "outbound"); const inbound = connections.filter((c) => c.direction === "inbound"); const groupByType = (conns: Connection[]) => { const g = new Map(); for (const c of conns) g.set(c.edgeType, [...(g.get(c.edgeType) ?? []), c]); return [...g.entries()].sort((a, b) => b[1].length - a[1].length); }; return (
{/* Header */}

{node.name}

{node.label}
{node.file_path && (

{node.file_path} {node.start_line ? ( {" "}:{node.start_line} {node.end_line && node.end_line !== node.start_line ? `-${node.end_line}` : ""} ) : null}

)} {/* Code actions */}
{canFetchCode && ( )} {ghUrl && ( Open on GitHub ↗ )}
{codeError &&

{codeError}

} {code && (
            {code}
          
)} {/* Stats */}
{[ { label: "Out", value: outbound.length, color: "text-primary" }, { label: "In", value: inbound.length, color: "text-accent" }, { label: "Total", value: connections.length, color: "text-foreground" }, ].map((s) => (

{s.label}

{s.value}

))}
{/* Connections */}
{outbound.length > 0 && ( )} {inbound.length > 0 && ( )} {connections.length === 0 && (

No connections

)}
); } function ConnectionSection({ title, count, icon, groups, onNavigate }: { title: string; count: number; icon: string; groups: [string, Connection[]][]; onNavigate: (n: GraphNode) => void; }) { return (

{title} ({count})

{groups.map(([type, conns]) => (

{type.replace(/_/g, " ").toLowerCase()}

{conns.slice(0, 25).map((c, i) => ( ))} {conns.length > 25 && (

+{conns.length - 25} more

)}
))}
); }