Files
deusdata--codebase-memory-mcp/graph-ui/src/components/NodeDetailPanel.tsx
T
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

246 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null>(null);
const [codeLoading, setCodeLoading] = useState(false);
const [codeError, setCodeError] = useState<string | null>(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<SnippetResult>("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<number, GraphNode>();
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<string, Connection[]>();
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 (
<div className="w-full bg-[#0b1920]/95 backdrop-blur-xl flex flex-col h-full min-h-0 overflow-hidden">
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-border/30">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: colorForLabel(node.label) }} />
<h3 className="text-[13px] font-semibold text-foreground truncate">{node.name}</h3>
</div>
<span
className="inline-block px-2 py-0.5 rounded-md text-[10px] font-medium"
style={{ backgroundColor: colorForLabel(node.label) + "18", color: colorForLabel(node.label) }}
>
{node.label}
</span>
</div>
<button onClick={onClose} className="text-foreground/20 hover:text-foreground/50 transition-colors text-[16px] leading-none p-1">×</button>
</div>
{node.file_path && (
<p className="text-[11px] text-foreground/30 font-mono mt-2 break-all leading-relaxed">
{node.file_path}
{node.start_line ? (
<span className="text-foreground/45">
{" "}:{node.start_line}
{node.end_line && node.end_line !== node.start_line ? `-${node.end_line}` : ""}
</span>
) : null}
</p>
)}
{/* Code actions */}
<div className="flex flex-wrap items-center gap-2 mt-2.5">
{canFetchCode && (
<button
onClick={code ? () => setCode(null) : loadCode}
disabled={codeLoading}
className="px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors disabled:opacity-50"
>
{codeLoading ? "Loading…" : code ? "Hide code" : "Show code"}
</button>
)}
{ghUrl && (
<a
href={ghUrl}
target="_blank"
rel="noopener noreferrer"
className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] hover:text-foreground/90 transition-colors"
>
Open on GitHub
</a>
)}
</div>
{codeError && <p className="text-[11px] text-red-400/80 mt-2">{codeError}</p>}
{code && (
<pre className="mt-2 max-h-[300px] overflow-auto rounded-md bg-black/40 border border-white/[0.06] p-2.5 text-[10.5px] leading-relaxed font-mono text-foreground/75 whitespace-pre">
{code}
</pre>
)}
{/* Stats */}
<div className="flex gap-5 mt-3">
{[
{ 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) => (
<div key={s.label}>
<p className="text-[9px] text-foreground/25 uppercase tracking-widest">{s.label}</p>
<p className={`text-[18px] font-semibold tabular-nums ${s.color}`}>{s.value}</p>
</div>
))}
</div>
</div>
{/* Connections */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 py-3 space-y-4">
{outbound.length > 0 && (
<ConnectionSection title="References" count={outbound.length} icon="→" groups={groupByType(outbound)} onNavigate={onNavigate} />
)}
{inbound.length > 0 && (
<ConnectionSection title="Referenced by" count={inbound.length} icon="←" groups={groupByType(inbound)} onNavigate={onNavigate} />
)}
{connections.length === 0 && (
<p className="text-[12px] text-foreground/20 text-center py-8">No connections</p>
)}
</div>
</ScrollArea>
</div>
);
}
function ConnectionSection({ title, count, icon, groups, onNavigate }: {
title: string; count: number; icon: string;
groups: [string, Connection[]][];
onNavigate: (n: GraphNode) => void;
}) {
return (
<div>
<p className="text-[11px] font-medium text-foreground/40 mb-2">
{title} <span className="text-foreground/15">({count})</span>
</p>
{groups.map(([type, conns]) => (
<div key={type} className="mb-2">
<p className="text-[9px] text-foreground/20 uppercase tracking-wider mb-1 font-medium">
{type.replace(/_/g, " ").toLowerCase()}
</p>
<div className="space-y-px">
{conns.slice(0, 25).map((c, i) => (
<button
key={`${c.node.id}-${i}`}
onClick={() => onNavigate(c.node)}
className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-white/[0.04] text-[11px] transition-colors group"
>
<span className="text-foreground/15 text-[10px] group-hover:text-foreground/30">{icon}</span>
<span className="w-[5px] h-[5px] rounded-full shrink-0" style={{ backgroundColor: colorForLabel(c.node.label) }} />
<span className="text-foreground/55 group-hover:text-foreground/80 truncate">{c.node.name}</span>
<span className="text-foreground/10 ml-auto text-[10px] shrink-0">{c.node.label}</span>
</button>
))}
{conns.length > 25 && (
<p className="text-[10px] text-foreground/15 px-2 py-1">+{conns.length - 25} more</p>
)}
</div>
</div>
))}
</div>
);
}