This commit is contained in:
xiongxiao
2026-03-14 01:07:43 +08:00
committed by cnb
parent fa11796aef
commit 977913e636
11 changed files with 1268 additions and 3457 deletions

View File

@@ -0,0 +1,314 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import ForceGraph3D from '3d-force-graph';
import type { NodeObject, LinkObject, ForceGraph3DInstance } from '3d-force-graph';
import { FileProjectData } from '../modules/tree';
import { NodeSearchEntry } from '../modules/graph';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
import { useCodeGraphStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
// ─── 类型定义 ─────────────────────────────────────────────────────────────────
type NodeKind = 'root' | 'dir' | 'file';
interface Graph3DNode extends NodeObject {
id: string;
label: string;
kind: NodeKind;
color: string;
nodeSize: number;
fullPath: string;
projectPath: string;
fileId?: string;
}
interface Graph3DLink extends LinkObject {
source: string;
target: string;
isRoot?: boolean;
}
interface Graph3DData {
nodes: Graph3DNode[];
links: Graph3DLink[];
searchIndex: NodeSearchEntry[];
}
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
const posixPath = {
dirname(p: string): string {
const i = p.lastIndexOf('/');
return i > 0 ? p.slice(0, i) : '/';
},
};
function fileExtColor(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
ts: '#818cf8',
tsx: '#a78bfa',
js: '#fbbf24',
jsx: '#fb923c',
md: '#34d399',
json: '#f97316',
css: '#f472b6',
scss: '#ec4899',
html: '#60a5fa',
lock: '#64748b',
gitignore: '#64748b',
npmrc: '#64748b',
};
return map[ext] ?? '#94a3b8';
}
// ─── 构建图数据 ───────────────────────────────────────────────────────────────
function buildGraph3DData(files: FileProjectData[]): Graph3DData {
const nodes: Graph3DNode[] = [];
const links: Graph3DLink[] = [];
const searchIndex: NodeSearchEntry[] = [];
if (files.length === 0) return { nodes, links, searchIndex };
const projectGroups = new Map<string, FileProjectData[]>();
for (const f of files) {
if (!projectGroups.has(f.projectPath)) projectGroups.set(f.projectPath, []);
projectGroups.get(f.projectPath)!.push(f);
}
const addedNodes = new Set<string>();
for (const [projectPath, groupFiles] of projectGroups) {
// 收集中间目录
const dirSet = new Set<string>();
for (const f of groupFiles) {
let cur = posixPath.dirname(f.filepath);
while (cur.startsWith(projectPath) && cur !== projectPath) {
dirSet.add(cur);
cur = posixPath.dirname(cur);
}
}
const rootKey = `root::${projectPath}`;
// 添加 root 节点
if (!addedNodes.has(rootKey)) {
const name = projectPath.split('/').pop() ?? projectPath;
nodes.push({
id: rootKey,
label: name,
kind: 'root',
color: '#f59e0b',
nodeSize: 10,
fullPath: projectPath,
projectPath,
});
searchIndex.push({ nodeKey: rootKey, label: name, fullPath: projectPath, kind: 'root' });
addedNodes.add(rootKey);
}
// 添加目录节点
for (const dir of dirSet) {
const key = `dir::${dir}`;
if (!addedNodes.has(key)) {
const name = dir.split('/').pop() ?? dir;
nodes.push({
id: key,
label: name,
kind: 'dir',
color: '#ffd04c',
nodeSize: 5,
fullPath: dir,
projectPath,
});
searchIndex.push({ nodeKey: key, label: name, fullPath: dir, kind: 'dir' });
addedNodes.add(key);
}
}
// 添加文件节点
for (const f of groupFiles) {
const key = `file::${f.id}`;
if (!addedNodes.has(key)) {
const name = f.filepath.split('/').pop() ?? f.filepath;
const label = f.title ?? name;
nodes.push({
id: key,
label,
kind: 'file',
color: fileExtColor(name),
nodeSize: 3,
fullPath: f.filepath,
projectPath,
fileId: f.id,
});
searchIndex.push({ nodeKey: key, label, fullPath: f.filepath, kind: 'file' });
addedNodes.add(key);
}
}
// 建立父子关系 -> links
const allKeys = [rootKey, ...[...dirSet].map((d) => `dir::${d}`), ...groupFiles.map((f) => `file::${f.id}`)];
for (const key of allKeys) {
if (key === rootKey) continue;
let fullPath: string;
if (key.startsWith('dir::')) fullPath = key.slice(5);
else {
const fId = key.slice(6);
const f = groupFiles.find((f) => f.id === fId);
if (!f) continue;
fullPath = f.filepath;
}
const parentPath = posixPath.dirname(fullPath);
const parentKey = parentPath === projectPath ? rootKey : `dir::${parentPath}`;
if (addedNodes.has(parentKey)) {
links.push({ source: parentKey, target: key, isRoot: parentKey === rootKey });
} else {
links.push({ source: rootKey, target: key, isRoot: true });
}
}
}
return { nodes, links, searchIndex };
}
// ─── 组件 Props ───────────────────────────────────────────────────────────────
interface Code3DGraphProps {
files: FileProjectData[];
className?: string;
}
// ─── 主组件 ───────────────────────────────────────────────────────────────────
export function Code3DGraph({ files, className }: Code3DGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<ForceGraph3DInstance | null>(null);
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const { setNodeInfo } = useCodeGraphStore(
useShallow((s) => ({
setNodeInfo: s.setNodeInfo,
})),
);
// 节点跳转
const focusNode = useCallback((nodeKey: string) => {
const graph = graphRef.current;
if (!graph) return;
const node = (graph.graphData().nodes as Graph3DNode[]).find((n) => n.id === nodeKey);
if (!node) return;
const distance = 80;
const distRatio = 1 + distance / Math.hypot((node.x ?? 0), (node.y ?? 0), (node.z ?? 0));
graph.cameraPosition(
{ x: (node.x ?? 0) * distRatio, y: (node.y ?? 0) * distRatio, z: (node.z ?? 0) * distRatio },
{ x: node.x ?? 0, y: node.y ?? 0, z: node.z ?? 0 },
800,
);
}, []);
// 初始化 & 数据更新
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const { nodes, links, searchIndex: idx } = buildGraph3DData(files);
setSearchIndex(idx);
let graph = graphRef.current;
if (!graph) {
graph = new ForceGraph3D(el, { controlType: 'orbit' });
graphRef.current = graph;
graph
.backgroundColor('#0f172a')
.showNavInfo(false)
.nodeId('id')
.nodeRelSize(1)
.nodeVal((node) => {
const n = node as Graph3DNode;
return n.nodeSize * n.nodeSize;
})
.nodeColor((node) => (node as Graph3DNode).color)
.nodeLabel((node) => (node as Graph3DNode).label)
.linkWidth(0)
.linkColor(() => 'rgba(255,255,255,0.6)')
.linkDirectionalParticles(2)
.linkDirectionalParticleWidth(1.5)
.linkDirectionalParticleSpeed(0.004)
.linkDirectionalParticleColor(() => '#ffffff')
.onNodeClick((node, event) => {
const n = node as Graph3DNode;
focusNode(n.id);
setNodeInfo(
{
label: n.label,
fullPath: n.fullPath,
projectPath: n.projectPath,
kind: n.kind,
color: n.color,
fileId: n.fileId,
nodeSize: n.nodeSize,
},
{ x: event.clientX, y: event.clientY },
);
})
.onNodeHover((node) => {
if (el) el.style.cursor = node ? 'pointer' : 'default';
});
}
graph.width(el.clientWidth || window.innerWidth);
graph.height(el.clientHeight || window.innerHeight);
graph.graphData({ nodes, links });
// 缩短连线距离root→dir 稍长dir→file 较短
const linkForce = graph.d3Force('link');
if (linkForce) {
(linkForce as any).distance((link: Graph3DLink) => (link.isRoot ? 40 : 20));
}
// 增强聚合,防止节点散得太开
const chargeForce = graph.d3Force('charge');
if (chargeForce) {
(chargeForce as any).strength(-60);
}
// 响应容器大小变化
const ro = new ResizeObserver(() => {
graph!.width(el.clientWidth);
graph!.height(el.clientHeight);
});
ro.observe(el);
return () => {
ro.disconnect();
};
}, [files, focusNode, setNodeInfo]);
// 卸载时销毁
useEffect(() => {
return () => {
graphRef.current?._destructor();
graphRef.current = null;
};
}, []);
return (
<div className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
<div ref={containerRef} className='w-full h-full' />
<div className='absolute top-3 left-1/2 -translate-x-1/2 z-10 w-72'>
<NodeSearchBox
ref={searchBoxRef}
searchIndex={searchIndex}
onSelect={(entry) => focusNode(entry.nodeKey)}
/>
</div>
</div>
);
}
export default Code3DGraph;

View File

@@ -0,0 +1,428 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Graph, NodeEvent, IElementEvent, NodeData, EdgeData } from '@antv/g6';
import { FileProjectData } from '../modules/tree';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
import { NodeSearchEntry, GraphNode } from '../modules/graph';
import { useShallow } from 'zustand/react/shallow';
import { useCodeGraphStore } from '../store';
interface CodeG6GraphProps {
files: FileProjectData[];
className?: string;
}
// ─── 路径工具 ────────────────────────────────────────────────────────────────
const posixPath = {
dirname(p: string): string {
const i = p.lastIndexOf('/');
return i > 0 ? p.slice(0, i) : '/';
},
};
type NodeKind = 'root' | 'dir' | 'file';
function fileExtColor(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const map: Record<string, string> = {
ts: '#818cf8',
tsx: '#a78bfa',
js: '#fbbf24',
jsx: '#fb923c',
md: '#34d399',
json: '#f97316',
css: '#f472b6',
scss: '#ec4899',
html: '#60a5fa',
lock: '#64748b',
gitignore: '#64748b',
npmrc: '#64748b',
};
return map[ext] ?? '#94a3b8';
}
interface G6NodeExtra {
fullPath: string;
projectPath: string;
kind: NodeKind;
fileId?: string;
label: string;
nodeSize: number;
color: string;
}
interface BuildResult {
nodes: NodeData[];
edges: EdgeData[];
searchIndex: NodeSearchEntry[];
nodeExtras: Map<string, G6NodeExtra>;
}
function buildG6GraphData(files: FileProjectData[]): BuildResult {
const nodes: NodeData[] = [];
const edges: EdgeData[] = [];
const searchIndex: NodeSearchEntry[] = [];
const nodeExtras = new Map<string, G6NodeExtra>();
if (files.length === 0) return { nodes, edges, searchIndex, nodeExtras };
const projectGroups = new Map<string, FileProjectData[]>();
for (const f of files) {
if (!projectGroups.has(f.projectPath)) projectGroups.set(f.projectPath, []);
projectGroups.get(f.projectPath)!.push(f);
}
const projects = Array.from(projectGroups.keys());
const PROJECT_SPACING = 1200;
projects.forEach((projectPath, pi) => {
const groupFiles = projectGroups.get(projectPath)!;
const dirSet = new Set<string>();
for (const f of groupFiles) {
let cur = posixPath.dirname(f.filepath);
while (cur.startsWith(projectPath) && cur !== projectPath) {
dirSet.add(cur);
cur = posixPath.dirname(cur);
}
}
const children = new Map<string, string[]>();
const nodeKind = new Map<string, NodeKind>();
const nodeFullPath = new Map<string, string>();
const nodeFileId = new Map<string, string>();
const rootKey = `root::${projectPath}`;
nodeKind.set(rootKey, 'root');
nodeFullPath.set(rootKey, projectPath);
children.set(rootKey, []);
for (const dir of dirSet) {
const key = `dir::${dir}`;
nodeKind.set(key, 'dir');
nodeFullPath.set(key, dir);
children.set(key, []);
}
for (const f of groupFiles) {
const key = `file::${f.id}`;
nodeKind.set(key, 'file');
nodeFullPath.set(key, f.filepath);
nodeFileId.set(key, f.id);
children.set(key, []);
}
for (const key of nodeKind.keys()) {
if (key === rootKey) continue;
const fullPath = nodeFullPath.get(key)!;
const parentPath = posixPath.dirname(fullPath);
const parentKey = parentPath === projectPath ? rootKey : `dir::${parentPath}`;
if (children.has(parentKey)) {
children.get(parentKey)!.push(key);
} else {
children.get(rootKey)!.push(key);
}
}
const cx = pi * PROJECT_SPACING;
const cy = 0;
const nodePos = new Map<string, { x: number; y: number }>();
function radialLayout(key: string, px: number, py: number, sa: number, ea: number, r: number) {
nodePos.set(key, { x: px, y: py });
const kids = children.get(key) ?? [];
if (kids.length === 0) return;
const step = (ea - sa) / kids.length;
kids.forEach((child, i) => {
const angle = sa + step * (i + 0.5);
const nr = Math.max(r * 0.6, 80);
radialLayout(child, px + r * Math.cos(angle), py + r * Math.sin(angle), angle - step / 2, angle + step / 2, nr);
});
}
const rootKids = children.get(rootKey)!;
radialLayout(rootKey, cx, cy, 0, 2 * Math.PI, Math.max(rootKids.length * 60, 200));
for (const [key, kind] of nodeKind) {
const pos = nodePos.get(key) ?? { x: cx, y: cy };
const fullPath = nodeFullPath.get(key)!;
const name = fullPath.split('/').pop() ?? fullPath;
const fd = kind === 'file' ? groupFiles.find((f) => f.id === nodeFileId.get(key)) : undefined;
const color = kind === 'root' ? '#f59e0b' : kind === 'dir' ? '#60a5fa' : fileExtColor(name);
const nodeSize = kind === 'root' ? 28 : kind === 'dir' ? 16 : 10;
const label = fd?.title ?? name;
nodes.push({
id: key,
style: {
x: pos.x,
y: pos.y,
size: nodeSize,
fill: color,
stroke: color,
lineWidth: 1,
// label 为 boolean 控制显示labelText 设置文字(来自 Prefix<'label', LabelStyleProps>
label: kind !== 'file',
labelText: label,
labelFill: '#e2e8f0',
labelFontSize: kind === 'root' ? 13 : 11,
labelFontFamily: 'Inter, system-ui, sans-serif',
labelPlacement: kind === 'root' ? 'bottom' : 'right',
labelOffsetX: 4,
opacity: 1,
} as Record<string, unknown>,
data: { kind, fullPath, label, fileId: nodeFileId.get(key), projectPath },
});
nodeExtras.set(key, { fullPath, projectPath, kind, fileId: nodeFileId.get(key), label, nodeSize, color });
searchIndex.push({ nodeKey: key, label, fullPath, kind });
}
for (const [parentKey, kids] of children) {
for (const childKey of kids) {
const edgeId = `${parentKey}-->${childKey}`;
const isRoot = nodeKind.get(parentKey) === 'root';
edges.push({
id: edgeId,
source: parentKey,
target: childKey,
style: {
stroke: isRoot ? '#78716c' : '#334155',
lineWidth: isRoot ? 1.5 : 1,
opacity: 0.7,
endArrow: false,
},
});
}
}
});
return { nodes, edges, searchIndex, nodeExtras };
}
interface ContextMenuState {
x: number;
y: number;
nodeId: string;
extra: G6NodeExtra;
}
interface TooltipState {
x: number;
y: number;
label: string;
fullPath: string;
kind: NodeKind;
}
export function CodeG6Graph({ files, className }: CodeG6GraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const searchBoxRef = useRef<NodeSearchBoxHandle>(null);
const nodeExtrasRef = useRef<Map<string, G6NodeExtra>>(new Map());
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const { setCodePodOpen, setCodePodAttrs } = useCodeGraphStore(
useShallow((s) => ({
setCodePodOpen: s.setCodePodOpen,
setCodePodAttrs: s.setCodePodAttrs,
})),
);
useEffect(() => {
if (!containerRef.current) return;
if (graphRef.current) {
graphRef.current.destroy();
graphRef.current = null;
}
const { nodes, edges, searchIndex: idx, nodeExtras } = buildG6GraphData(files);
nodeExtrasRef.current = nodeExtras;
setSearchIndex(idx);
setStats({ nodes: nodes.length, edges: edges.length });
const container = containerRef.current;
const { width, height } = container.getBoundingClientRect();
const graph = new Graph({
container,
width: width || 800,
height: height || 600,
data: { nodes, edges },
node: {
type: 'circle',
},
edge: {
type: 'line',
},
behaviors: [
{ type: 'drag-canvas', key: 'drag-canvas' },
{ type: 'zoom-canvas', key: 'zoom-canvas' },
{ type: 'drag-element', key: 'drag-element' },
],
autoFit: 'view',
animation: false,
});
// 获取节点 ID 的辅助函数
const getNodeId = (e: IElementEvent): string => (e.target as unknown as { id: string }).id;
// 节点悬浮 - tooltip
graph.on(NodeEvent.POINTER_OVER, (e: IElementEvent) => {
const nodeId = getNodeId(e);
const extra = nodeExtrasRef.current.get(nodeId);
if (!extra) return;
setTooltip({
x: e.canvas.x,
y: e.canvas.y,
label: extra.label,
fullPath: extra.fullPath,
kind: extra.kind,
});
});
graph.on(NodeEvent.POINTER_OUT, () => {
setTooltip(null);
});
// 右键菜单
graph.on(NodeEvent.CONTEXT_MENU, (e: IElementEvent) => {
const nodeId = getNodeId(e);
const extra = nodeExtrasRef.current.get(nodeId);
if (!extra) return;
setContextMenu({ x: e.canvas.x, y: e.canvas.y, nodeId, extra });
});
// 点击节点以外区域关闭菜单
graph.on(NodeEvent.CLICK, (e: IElementEvent) => {
if (e.targetType !== 'node') setContextMenu(null);
});
// 阻止默认右键菜单
const onContextMenu = (ev: MouseEvent) => ev.preventDefault();
container.addEventListener('contextmenu', onContextMenu);
graph.render();
graphRef.current = graph;
// 响应容器尺寸变化
const ro = new ResizeObserver(() => {
if (!graphRef.current || !containerRef.current) return;
const { width: w, height: h } = containerRef.current.getBoundingClientRect();
graphRef.current.setSize(w, h);
});
ro.observe(container);
return () => {
ro.disconnect();
container.removeEventListener('contextmenu', onContextMenu);
graph.destroy();
graphRef.current = null;
};
}, [files]);
// 跳转到节点(搜索后聚焦)
const jumpToNode = useCallback((entry: NodeSearchEntry) => {
const g = graphRef.current;
if (!g) return;
g.focusElement(entry.nodeKey);
}, []);
// Ctrl+F 激活搜索框
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchBoxRef.current?.focus();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const kindLabel: Record<string, string> = { root: '项目根', dir: '目录', file: '文件' };
const kindBadgeColor: Record<string, string> = {
root: 'bg-amber-500/20 text-amber-300',
dir: 'bg-blue-500/20 text-blue-300',
file: 'bg-indigo-500/20 text-indigo-300',
};
return (
<div className={`relative flex flex-col h-full ${className ?? ''}`}>
{/* 顶部工具栏 */}
<div className='relative z-10 flex items-center gap-4 px-4 py-2 border-b border-white/10 text-xs text-slate-400 bg-slate-900/60 backdrop-blur shrink-0 overflow-visible'>
<NodeSearchBox ref={searchBoxRef} searchIndex={searchIndex} onSelect={jumpToNode} />
<span>
<span className='text-slate-200 font-semibold'>{stats.nodes}</span>
</span>
<span>
<span className='text-slate-200 font-semibold'>{stats.edges}</span>
</span>
<div className='flex items-center gap-3 ml-auto shrink-0'>
<span className='flex items-center gap-1'>
<span className='inline-block w-3 h-3 rounded-full bg-amber-400'></span>
</span>
<span className='flex items-center gap-1'>
<span className='inline-block w-2.5 h-2.5 rounded-full bg-blue-400'></span>
</span>
<span className='flex items-center gap-1'>
<span className='inline-block w-2 h-2 rounded-full bg-indigo-400'></span>
</span>
</div>
</div>
{/* G6 画布 */}
<div ref={containerRef} className='flex-1 bg-slate-950 overflow-hidden' />
{/* Tooltip */}
{tooltip && (
<div
className='absolute z-10 pointer-events-none rounded-lg border border-white/10 bg-slate-800/95 px-3 py-2 shadow-xl text-xs leading-relaxed backdrop-blur'
style={{ left: tooltip.x + 14, top: tooltip.y - 10 }}>
<div className='flex items-center gap-2 mb-0.5'>
<span className={`rounded px-1 py-0.5 text-[10px] font-medium ${kindBadgeColor[tooltip.kind] ?? ''}`}>
{kindLabel[tooltip.kind] ?? tooltip.kind}
</span>
<span className='font-semibold text-slate-100'>{tooltip.label}</span>
</div>
<div className='text-slate-400 max-w-[320px] break-all'>{tooltip.fullPath}</div>
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className='absolute z-20 min-w-[120px] rounded-lg border border-white/10 bg-slate-800 shadow-xl py-1 text-xs'
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseLeave={() => setContextMenu(null)}>
<button
className='w-full text-left px-4 py-1.5 text-slate-200 hover:bg-indigo-600/40 transition-colors'
onClick={() => {
const extra = contextMenu.extra;
const attrs: GraphNode = {
label: extra.label,
size: extra.nodeSize,
color: extra.color,
x: 0,
y: 0,
fullPath: extra.fullPath,
projectPath: extra.projectPath,
kind: extra.kind,
fileId: extra.fileId,
};
setCodePodAttrs(attrs);
setCodePodOpen(true);
setContextMenu(null);
}}>
</button>
</div>
)}
</div>
);
}
export default CodeG6Graph;

View File

@@ -4,17 +4,10 @@ import Graph from 'graphology';
import { FileProjectData } from '../modules/tree';
import { buildTreeGraph, GraphNode, NodeSearchEntry } from '../modules/graph';
import { NodeSearchBox, NodeSearchBoxHandle } from './NodeSearchBox';
import { CodePod } from './CodePod';
import { useShallow } from 'zustand/react/shallow';
import { useCodeGraphStore } from '../store';
interface ContextMenu {
x: number;
y: number;
nodeKey: string;
nodeAttrs: GraphNode;
}
interface TooltipInfo {
label: string;
fullPath: string;
@@ -36,13 +29,9 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
const [tooltip, setTooltip] = useState<TooltipInfo | null>(null);
const [stats, setStats] = useState({ nodes: 0, edges: 0 });
const [searchIndex, setSearchIndex] = useState<NodeSearchEntry[]>([]);
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
const { codePodOpen, setCodePodOpen, codePodAttrs, setCodePodAttrs } = useCodeGraphStore(
const { setNodeInfo } = useCodeGraphStore(
useShallow((s) => ({
codePodOpen: s.codePodOpen,
setCodePodOpen: s.setCodePodOpen,
codePodAttrs: s.codePodAttrs,
setCodePodAttrs: s.setCodePodAttrs,
setNodeInfo: s.setNodeInfo,
})),
);
@@ -85,25 +74,29 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
graph.setNodeAttribute(node, 'highlighted', false);
});
sigma.on('rightClickNode', ({ node }) => {
sigma.on('clickNode', ({ node, event }) => {
const attrs = graph.getNodeAttributes(node) as GraphNode;
const pos = sigma.graphToViewport({ x: attrs.x, y: attrs.y });
setContextMenu({ x: pos.x, y: pos.y, nodeKey: node, nodeAttrs: attrs });
const orig = event.original;
const clientX = orig instanceof MouseEvent ? orig.clientX : orig.touches[0]?.clientX ?? 0;
const clientY = orig instanceof MouseEvent ? orig.clientY : orig.touches[0]?.clientY ?? 0;
setNodeInfo(
{
label: attrs.label,
fullPath: attrs.fullPath,
projectPath: attrs.projectPath,
kind: attrs.kind,
color: attrs.color,
fileId: attrs.fileId,
nodeSize: attrs.size,
},
{ x: clientX, y: clientY },
);
});
// 阻止画布默认右键菜单
const onContextMenu = (e: MouseEvent) => e.preventDefault();
const container = containerRef.current;
container?.addEventListener('contextmenu', onContextMenu);
// 点击画布空白处关闭右键菜单
sigma.on('clickStage', () => setContextMenu(null));
sigmaRef.current = sigma;
return () => {
sigma.kill();
sigmaRef.current = null;
container?.removeEventListener('contextmenu', onContextMenu);
};
}, [files]);
@@ -142,11 +135,11 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
{/* 搜索框 */}
<NodeSearchBox ref={searchBoxRef} searchIndex={searchIndex} onSelect={jumpToNode} />
<span> <span className='text-slate-200 font-semibold'>{stats.nodes}</span></span>
<span> <span className='text-slate-200 font-semibold'>{stats.edges}</span></span>
<span className='whitespace-nowrap'> <span className='text-slate-200 font-semibold'>{stats.nodes}</span></span>
<span className='whitespace-nowrap'> <span className='text-slate-200 font-semibold'>{stats.edges}</span></span>
{/* 图例 */}
<div className='flex items-center gap-3 ml-auto shrink-0'>
<div className='hidden sm:flex items-center gap-3 ml-auto shrink-0'>
<span className='flex items-center gap-1'><span className='inline-block w-3 h-3 rounded-full bg-amber-400'></span></span>
<span className='flex items-center gap-1'><span className='inline-block w-2.5 h-2.5 rounded-full bg-blue-400'></span></span>
<span className='flex items-center gap-1'><span className='inline-block w-2 h-2 rounded-full bg-indigo-400'></span></span>
@@ -169,30 +162,9 @@ export function CodeGraphView({ files, className }: CodeGraphProps) {
</div>
)}
{/* 右键菜单 */}
{contextMenu && (
<div
className='absolute z-20 min-w-[120px] rounded-lg border border-white/10 bg-slate-800 shadow-xl py-1 text-xs'
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseLeave={() => setContextMenu(null)}>
<button
className='w-full text-left px-4 py-1.5 text-slate-200 hover:bg-indigo-600/40 transition-colors'
onClick={() => {
setCodePodAttrs(contextMenu.nodeAttrs);
setCodePodOpen(true);
setContextMenu(null);
}}>
</button>
</div>
)}
{/* CodePod 弹窗 */}
<CodePod
open={codePodOpen}
onClose={() => setCodePodOpen(false)}
nodeAttrs={codePodAttrs}
/>
</div>
);
}

View File

@@ -179,12 +179,14 @@ interface CodePodProps {
export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
const [dirFiles, setDirFiles] = useState<FileProjectData[]>([]);
const [type, setType] = useState<'text' | null>('text');
const [selectedFile, setSelectedFile] = useState<FileProjectData | null>(null);
const [fileContent, setFileContent] = useState('');
const [loading, setLoading] = useState(false);
const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root';
const [sidebarOpen, setSidebarOpen] = useState(true);
const rootPath = nodeAttrs?.fullPath ?? '';
const isDir = nodeAttrs?.kind === 'dir' || nodeAttrs?.kind === 'root';
// 打开时重置
useEffect(() => {
@@ -194,6 +196,7 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
setSelectedFile(null);
setFileContent('');
init(nodeAttrs);
setSidebarOpen(isDir ? true : false);
}, [open, nodeAttrs]);
const init = async (nodeAttrs: GraphNode) => {
setLoading(true);
@@ -217,6 +220,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
if (!selectedFile) return;
if (selectedFile.content) {
setFileContent(selectedFile.content);
} else {
setFileContent('展示不支持的文件类型');
}
}, [selectedFile]);
@@ -234,8 +239,8 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
<div
className='fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm'
onClick={(e) => e.target === e.currentTarget && onClose()}>
{/* 弹窗主体 */}
<div className='relative flex w-[90vw] h-[80vh] rounded-xl border border-white/10 bg-slate-900 shadow-2xl overflow-hidden'>
{/* 弹窗主体:小屏幕全屏无边框,大屏幕居中带圆角/边框 */}
<div className='relative flex w-full h-full sm:w-[90vw] sm:h-[80vh] sm:rounded-xl sm:border sm:border-white/10 bg-slate-900 shadow-2xl overflow-hidden'>
{/* 关闭按钮 */}
<button
onClick={onClose}
@@ -243,40 +248,47 @@ export function CodePod({ open, onClose, nodeAttrs }: CodePodProps) {
×
</button>
{/* 侧边栏(仅 dir / root */}
{isDir && (
<aside className='w-64 shrink-0 border-r border-white/10 bg-slate-950 flex flex-col'>
{/* 标题:显示 rootPath */}
<div className='px-3 py-2.5 border-b border-white/10 shrink-0'>
<div className='text-[10px] text-slate-500 mb-0.5'></div>
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
{rootPath}
</div>
{/* 侧边栏 */}
<aside
className={`${sidebarOpen ? 'w-56 sm:w-64' : 'w-0'
} shrink-0 border-r border-white/10 bg-slate-950 flex flex-col overflow-hidden transition-all duration-200`}>
{/* 标题:显示 rootPath */}
<div className='px-3 py-2.5 border-b border-white/10 shrink-0 min-w-[14rem]'>
<div className='text-[10px] text-slate-500 mb-0.5'></div>
<div className='text-xs font-semibold text-slate-300 truncate' title={rootPath}>
{rootPath}
</div>
{/* 目录树 */}
<div className='flex-1 overflow-y-auto py-1'>
{loading && dirFiles.length === 0 && (
<div className='px-3 py-4 text-xs text-slate-500'></div>
)}
{buildTree(dirFiles, rootPath).map((node) => (
<TreeItem
key={node.fullPath}
node={node}
depth={0}
selectedId={selectedFile?.id}
onSelect={setSelectedFile}
/>
))}
</div>
</aside>
)}
</div>
{/* 目录树 */}
<div className='flex-1 overflow-y-auto py-1 scrollbar min-w-[14rem]'>
{loading && dirFiles.length === 0 && (
<div className='px-3 py-4 text-xs text-slate-500'></div>
)}
{buildTree(dirFiles, rootPath).map((node) => (
<TreeItem
key={node.fullPath}
node={node}
depth={0}
selectedId={selectedFile?.id}
onSelect={setSelectedFile}
/>
))}
</div>
</aside>
{/* 编辑器区域 */}
<div className='flex-1 flex flex-col min-w-0'>
{/* 编辑器标题栏 */}
<div className='flex items-center gap-2 px-4 py-4 border-b border-white/10 text-xs text-slate-400 shrink-0'>
<div className='flex items-center gap-2 px-4 py-2.5 border-b border-white/10 text-xs text-slate-400 shrink-0'>
{/* 侧边栏切换按钮 */}
<button
onClick={() => setSidebarOpen((v) => !v)}
className='shrink-0 flex items-center justify-center w-6 h-6 rounded text-slate-400 hover:text-white hover:bg-white/10 transition-colors text-sm'
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}>
{sidebarOpen ? '◀' : '▶'}
</button>
<span className='truncate text-slate-200'>
{isDir ? (selectedFile?.filepath ?? '请选择文件') : nodeAttrs.fullPath}
{selectedFile?.filepath ?? nodeAttrs.fullPath}
</span>
{loading && <span className='ml-auto text-slate-500'></span>}
</div>

View File

@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react';
import { FileIcon, FolderIcon, DatabaseIcon, XIcon, MoveIcon, SquarePenIcon } from 'lucide-react';
import { useCodeGraphStore, NodeInfoData } from '../store';
import { useShallow } from 'zustand/react/shallow';
function KindIcon({ kind, color }: { kind: NodeInfoData['kind']; color: string }) {
const cls = 'size-4 shrink-0';
if (kind === 'root') return <DatabaseIcon className={cls} style={{ color }} />;
if (kind === 'dir') return <FolderIcon className={cls} style={{ color }} />;
return <FileIcon className={cls} style={{ color }} />;
}
const KIND_LABEL: Record<NodeInfoData['kind'], string> = {
root: '项目根目录',
dir: '目录',
file: '文件',
};
export function NodeInfo() {
const { nodeInfoOpen, nodeInfoData, nodeInfoPos, closeNodeInfo, setCodePodOpen, setCodePodAttrs } = useCodeGraphStore(
useShallow((s) => ({
nodeInfoOpen: s.nodeInfoOpen,
nodeInfoData: s.nodeInfoData,
nodeInfoPos: s.nodeInfoPos,
closeNodeInfo: s.closeNodeInfo,
setCodePodOpen: s.setCodePodOpen,
setCodePodAttrs: s.setCodePodAttrs,
})),
);
const handleEdit = () => {
if (!nodeInfoData) return;
setCodePodAttrs({
label: nodeInfoData.label,
size: nodeInfoData.nodeSize ?? 3,
color: nodeInfoData.color,
x: 0,
y: 0,
fullPath: nodeInfoData.fullPath,
projectPath: nodeInfoData.projectPath,
kind: nodeInfoData.kind,
fileId: nodeInfoData.fileId,
});
setCodePodOpen(true);
// 移到左上角,避免遮挡编辑器
setPinLeft(true);
setOffset({ x: 0, y: 0 });
};
// 拖拽偏移
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pinLeft, setPinLeft] = useState(false); // 编辑后固定到右下角
const dragging = useRef(false);
const dragStart = useRef({ mx: 0, my: 0, ox: 0, oy: 0 });
// 每次新节点被选中时重置偏移和 pinLeft
const prevDataRef = useRef<NodeInfoData | null>(null);
useEffect(() => {
if (nodeInfoData !== prevDataRef.current) {
prevDataRef.current = nodeInfoData;
setOffset({ x: 0, y: 0 });
setPinLeft(false);
}
}, [nodeInfoData]);
// 鼠标拖拽
const onMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button')) return;
dragging.current = true;
dragStart.current = { mx: e.clientX, my: e.clientY, ox: offset.x, oy: offset.y };
e.preventDefault();
};
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!dragging.current) return;
setOffset({
x: dragStart.current.ox + e.clientX - dragStart.current.mx,
y: dragStart.current.oy + e.clientY - dragStart.current.my,
});
};
const onUp = () => { dragging.current = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
if (!nodeInfoOpen || !nodeInfoData) return null;
const posStyle = pinLeft
? { right: 10 - offset.x, bottom: 10 - offset.y, top: 'auto' as const, left: 'auto' as const }
: { left: nodeInfoPos.x + offset.x + 16, top: nodeInfoPos.y + offset.y - 16 };
const name = nodeInfoData.fullPath.split('/').pop() || nodeInfoData.label;
const projectPath = nodeInfoData.projectPath || '';
const relativePath = nodeInfoData.fullPath.replace(projectPath + '/', '') || '/';
return (
<div
className='fixed z-50 w-72 rounded-xl border border-white/10 bg-slate-900/95 backdrop-blur-sm shadow-2xl select-none'
style={posStyle}
onMouseDown={onMouseDown}>
{/* 标题栏 */}
<div className='flex items-center gap-2 px-3 py-2.5 border-b border-white/10 cursor-grab active:cursor-grabbing'>
<MoveIcon className='size-3 text-slate-600 shrink-0' />
<KindIcon kind={nodeInfoData.kind} color={nodeInfoData.color} />
<span className='flex-1 text-sm font-medium text-slate-100 truncate' title={name}>
{name}
</span>
<button
onClick={handleEdit}
title='查看内容'
className='ml-1 text-slate-500 hover:text-indigo-400 transition-colors'>
<SquarePenIcon className='size-3.5' />
</button>
<span className='text-[10px] text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded'>
{KIND_LABEL[nodeInfoData.kind]}
</span>
<button
onClick={closeNodeInfo}
className='ml-1 text-slate-500 hover:text-slate-200 transition-colors'>
<XIcon className='size-3.5' />
</button>
</div>
{/* 内容 */}
<div className='px-3 py-2.5 flex flex-col gap-2'>
{/* projectPath */}
<div className='flex flex-col gap-0.5'>
<span className='text-[10px] uppercase tracking-wide text-slate-500 font-medium'></span>
<span
className='text-xs text-slate-400 break-all leading-relaxed font-mono'
title={projectPath}>
{projectPath}
</span>
</div>
{/* relativePath */}
<div className='flex flex-col gap-0.5'>
<span className='text-[10px] uppercase tracking-wide text-slate-500 font-medium'></span>
<span
className='text-xs text-slate-300 break-all leading-relaxed font-mono'
title={relativePath}>
{relativePath}
</span>
</div>
</div>
</div>
);
}
export default NodeInfo;

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { FolderOpenIcon, PlusIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useCodeGraphStore } from '../store';
export function ProjectDialog() {
const { projectDialogOpen, setProjectDialogOpen, projects, projectsLoading, loadProjects, addProject, removeProject } =
useCodeGraphStore(
useShallow((s) => ({
projectDialogOpen: s.projectDialogOpen,
setProjectDialogOpen: s.setProjectDialogOpen,
projects: s.projects,
projectsLoading: s.projectsLoading,
loadProjects: s.loadProjects,
addProject: s.addProject,
removeProject: s.removeProject,
})),
);
const [addLoading, setAddLoading] = useState(false);
const [newPath, setNewPath] = useState('');
const [newName, setNewName] = useState('');
const handleAdd = async () => {
if (!newPath.trim()) return;
setAddLoading(true);
const ok = await addProject(newPath.trim(), newName.trim() || undefined);
if (ok) {
setNewPath('');
setNewName('');
}
setAddLoading(false);
};
const handleOpenChange = (open: boolean) => {
setProjectDialogOpen(open);
if (open) loadProjects();
};
return (
<Dialog open={projectDialogOpen} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg bg-slate-900 text-slate-100 border border-white/10'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2 text-slate-100'>
<FolderOpenIcon className='w-4 h-4 text-indigo-400' />
</DialogTitle>
<DialogDescription className='text-slate-400'></DialogDescription>
</DialogHeader>
{/* 新增项目 */}
<div className='space-y-2 rounded-lg bg-slate-800/60 p-3 border border-white/5'>
<p className='text-xs font-medium text-slate-400 mb-2'></p>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400'> *</Label>
<Input
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
placeholder='/path/to/project'
className='bg-slate-700/60 border-white/10 text-slate-100 placeholder:text-slate-500 h-8 text-xs'
/>
</div>
<div className='space-y-1.5'>
<Label className='text-xs text-slate-400'></Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder='My Project'
className='bg-slate-700/60 border-white/10 text-slate-100 placeholder:text-slate-500 h-8 text-xs'
/>
</div>
<Button
size='sm'
onClick={handleAdd}
disabled={addLoading}
className='w-full bg-indigo-600 hover:bg-indigo-500 text-white text-xs h-8 mt-1'>
<PlusIcon className='w-3.5 h-3.5 mr-1' />
{addLoading ? '添加中…' : '添加项目'}
</Button>
</div>
{/* 项目列表 */}
<div>
<div className='flex items-center justify-between mb-2'>
<p className='text-xs font-medium text-slate-400'></p>
<button
onClick={loadProjects}
disabled={projectsLoading}
className='text-slate-500 hover:text-slate-300 transition-colors'>
<RefreshCwIcon className={`w-3.5 h-3.5 ${projectsLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{projectsLoading ? (
<div className='flex items-center justify-center h-16 text-slate-500 text-xs'></div>
) : projects.length === 0 ? (
<div className='flex items-center justify-center h-16 text-slate-500 text-xs border border-dashed border-white/10 rounded-lg'>
</div>
) : (
<ul className='space-y-1.5 max-h-56 overflow-y-auto pr-1'>
{projects.map((p) => (
<li
key={p.path}
className='flex items-center gap-2 rounded-md bg-slate-800/60 px-3 py-2 border border-white/5 group'>
<div className='flex-1 min-w-0'>
<p className='text-xs font-medium text-slate-200 truncate'>{p.name ?? p.path.split('/').pop()}</p>
<p className='text-[11px] text-slate-500 truncate'>{p.path}</p>
</div>
{p.status !== undefined && (
<span
className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-full ${p.status === 'active' ? 'bg-green-900/60 text-green-400' : 'bg-slate-700 text-slate-400'
}`}>
{p.status === 'active' ? '监听中' : '已停止'}
</span>
)}
<button
onClick={() => removeProject(p.path)}
className='shrink-0 text-slate-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100'>
<Trash2Icon className='w-3.5 h-3.5' />
</button>
</li>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,33 +1,34 @@
import { useState, useCallback, useEffect } from 'react';
import { CodeGraphView } from './components/CodeGraph';
import { useState, useEffect } from 'react';
import { FileProjectData } from './modules/tree';
import { getFilesApi } from './modules/api/get-files';
import { toast } from 'sonner';
import { exampleData } from './mock/example';
import { useShallow } from 'zustand/react/shallow';
import { DatabaseIcon } from 'lucide-react';
import { CodePod } from './components/CodePod';
import { useCodeGraphStore } from './store';
import CodeGraphView from './components/CodeGraph';
import { Code3DGraph } from './components/Code3DGraph';
import { NodeInfo } from './components/NodeInfo';
import { ProjectDialog } from './components/ProjectDialog';
type ViewMode = '2d' | '3d';
export default function CodeGraphPage() {
const [files, setFiles] = useState<FileProjectData[]>([]);
const [jsonInput, setJsonInput] = useState('');
const [jsonError, setJsonError] = useState('');
const [showPanel, setShowPanel] = useState(false);
const handleLoadJson = useCallback(() => {
try {
const parsed = JSON.parse(jsonInput);
// 支持 { list: [...] } 或直接数组
const arr: FileProjectData[] = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.list) ? parsed.list : null;
if (!arr) throw new Error('需要数组或 { list: [...] } 格式');
setFiles(arr);
setJsonError('');
setShowPanel(false);
} catch (e) {
setJsonError((e as Error).message);
}
}, [jsonInput]);
const [viewMode, setViewMode] = useState<ViewMode>('3d');
const { codePodOpen, setCodePodOpen, codePodAttrs, setProjectDialogOpen, loadProjects } = useCodeGraphStore(
useShallow((s) => ({
codePodOpen: s.codePodOpen,
setCodePodOpen: s.setCodePodOpen,
codePodAttrs: s.codePodAttrs,
setProjectDialogOpen: s.setProjectDialogOpen,
loadProjects: s.loadProjects
})),
);
useEffect(() => {
loadData();
loadProjects();
}, []);
// 页面加载时从 API 获取文件列表
const loadData = async () => {
@@ -42,43 +43,53 @@ export default function CodeGraphPage() {
<div className='flex flex-col h-full bg-slate-950 text-slate-100'>
{/* 顶部工具栏 */}
<div className='flex items-center gap-3 px-4 h-12 border-b border-white/10 bg-slate-900/80 shrink-0'>
<span className='font-semibold text-sm text-slate-200'>Code Graph</span>
<span className='font-semibold text-sm text-slate-200'>
<span className='sm:hidden'>Code</span>
<span className='hidden sm:inline'>Code Graph</span>
</span>
<div className='h-4 w-px bg-white/10' />
<span className='ml-auto text-xs text-slate-500'>{files.length} </span>
</div>
{/* JSON 导入面板 */}
{showPanel && (
<div className='shrink-0 border-b border-white/10 bg-slate-900 p-4 flex flex-col gap-3'>
<div className='text-xs text-slate-400'>
<code className='text-indigo-400'>FileProjectData[]</code> JSON
</div>
<textarea
className='w-full h-32 rounded-md border border-white/10 bg-slate-800 px-3 py-2 text-xs font-mono text-slate-100 resize-none outline-none focus:ring-1 focus:ring-indigo-500'
placeholder='[{ "id": "...", "filepath": "...", ... }]'
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
/>
{jsonError && <div className='text-xs text-red-400'>{jsonError}</div>}
<div className='flex gap-2'>
<button
onClick={handleLoadJson}
className='text-xs px-4 py-1.5 rounded-md bg-indigo-600 hover:bg-indigo-500 transition-colors'>
</button>
<button
onClick={() => setShowPanel(false)}
className='text-xs px-4 py-1.5 rounded-md bg-slate-700 hover:bg-slate-600 transition-colors'>
</button>
</div>
{/* 2D / 3D 切换 */}
<div className='flex items-center rounded-md overflow-hidden border border-white/10 text-xs'>
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1 transition-colors ${viewMode === '2d' ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1 transition-colors ${viewMode === '3d' ? 'bg-indigo-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}>
3D
</button>
</div>
)}
<div className='h-4 w-px bg-white/10' />
<button
onClick={() => setProjectDialogOpen(true)}
title='项目管理'
className='ml-auto flex items-center gap-1.5 px-2 py-1 rounded-md text-slate-400 hover:text-slate-200 hover:bg-white/5 transition-colors'>
<DatabaseIcon className='w-4 h-4' />
<span className='hidden sm:inline text-xs'></span>
</button>
<span className='hidden sm:inline text-xs text-slate-500'>{files.length} </span>
</div>
{/* 图视图 */}
<div className='flex-1 min-h-0'>
<CodeGraphView files={files} className='h-full' />
{viewMode === '3d' ? (
<Code3DGraph files={files} className='h-full' />
) : (
<CodeGraphView files={files} className='h-full' />
)}
</div>
{/* CodePod 弹窗 */}
<CodePod
open={codePodOpen}
onClose={() => setCodePodOpen(false)}
nodeAttrs={codePodAttrs}
/>
{/* NodeInfo 信息窗 */}
<NodeInfo />
{/* 项目管理弹窗 */}
<ProjectDialog />
</div>
);
}

View File

@@ -1,16 +1,113 @@
import { create } from 'zustand';
import { GraphNode } from '../modules/graph';
import { queryApi as projectApi } from '@/modules/project-api';
import { toast } from 'sonner';
export type ProjectItem = {
path: string;
name?: string;
repo?: string;
status?: 'active' | 'inactive';
};
const API_URL = '/root/v1/cnb-dev';
export type NodeInfoData = {
label: string;
fullPath: string;
projectPath: string;
kind: 'root' | 'dir' | 'file';
color: string;
fileId?: string;
nodeSize?: number;
};
type State = {
codePodOpen: boolean;
setCodePodOpen: (open: boolean) => void;
codePodAttrs: GraphNode | null;
setCodePodAttrs: (attrs: GraphNode | null) => void;
// 项目管理弹窗
projectDialogOpen: boolean;
setProjectDialogOpen: (open: boolean) => void;
// 项目列表
projects: ProjectItem[];
projectsLoading: boolean;
loadProjects: () => Promise<void>;
addProject: (filepath: string, name?: string) => Promise<boolean>;
removeProject: (path: string) => Promise<void>;
// NodeInfo 弹窗
nodeInfoOpen: boolean;
nodeInfoData: NodeInfoData | null;
nodeInfoPos: { x: number; y: number };
setNodeInfo: (data: NodeInfoData | null, pos?: { x: number; y: number }) => void;
closeNodeInfo: () => void;
};
export const useCodeGraphStore = create<State>((set) => ({
export const useCodeGraphStore = create<State>()((set, get) => ({
codePodOpen: false,
setCodePodOpen: (open) => set({ codePodOpen: open }),
codePodAttrs: null,
setCodePodAttrs: (attrs) => set({ codePodAttrs: attrs }),
projectDialogOpen: false,
setProjectDialogOpen: (open) => set({ projectDialogOpen: open }),
projects: [],
projectsLoading: false,
loadProjects: async () => {
set({ projectsLoading: true });
try {
const res = await projectApi.project.list(undefined, { url: API_URL });
if (res.code === 200) {
set({ projects: (res.data?.list as ProjectItem[]) ?? [] });
} else {
toast.error('获取项目列表失败');
}
} catch {
toast.error('获取项目列表失败');
} finally {
set({ projectsLoading: false });
}
},
addProject: async (filepath, name) => {
try {
const res = await projectApi.project.add(
{ filepath, name: name || undefined },
{ url: API_URL },
);
if (res.code === 200) {
toast.success('项目添加成功');
await get().loadProjects();
return true;
} else {
toast.error(res.message ?? '项目添加失败');
return false;
}
} catch {
toast.error('项目添加失败');
return false;
}
},
removeProject: async (path) => {
try {
const res = await projectApi.project.remove({ filepath: path }, { url: API_URL });
if (res.code === 200) {
toast.success('项目已移除');
set((s) => ({ projects: s.projects.filter((p) => p.path !== path) }));
} else {
toast.error(res.message ?? '移除失败');
}
} catch {
toast.error('移除失败');
}
},
nodeInfoOpen: false,
nodeInfoData: null,
nodeInfoPos: { x: 0, y: 0 },
setNodeInfo: (data, pos) =>
set({
nodeInfoOpen: !!data,
nodeInfoData: data,
nodeInfoPos: pos ?? { x: 0, y: 0 },
}),
closeNodeInfo: () => set({ nodeInfoOpen: false }),
}));