Skip to content

Commit 0f7951a

Browse files
shantanu patilclaude
authored andcommitted
feat: add Visual Explorer page with React Flow interactive diagrams
Phase 4: Visual Explorer at /[owner]/[repo]/explore - New dedicated route with full-screen interactive diagram canvas - React Flow (@xyflow/react) with custom nodes showing tech logos + category colors - Dagre-powered automatic graph layout (top-to-bottom hierarchy) - Depth toggle: Overview / Detailed / Full filters nodes by complexity level - Diagram type tabs: Architecture / Data Flow / Dependencies - Persistent detail panel with node info, file links, AI explanations - Custom edge styles: animated for data_flow, colored by type - "Open in Explorer" button on inline Mermaid diagrams when structured data available - MiniMap, zoom controls, fit-to-view, deep-linkable URLs - Dark/light theme support throughout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8cefcde commit 0f7951a

14 files changed

Lines changed: 1358 additions & 26 deletions

package-lock.json

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
"@gsap/react": "^2.1.2",
1313
"@react-three/drei": "^10.7.7",
1414
"@react-three/fiber": "^9.5.0",
15+
"@xyflow/react": "^12.10.1",
1516
"class-variance-authority": "^0.7.1",
1617
"clsx": "^2.1.1",
18+
"dagre": "^0.8.5",
1719
"file-saver": "^2.0.5",
1820
"framer-motion": "^12.34.3",
1921
"gsap": "^3.14.2",
@@ -40,6 +42,7 @@
4042
"devDependencies": {
4143
"@eslint/eslintrc": "^3",
4244
"@tailwindcss/postcss": "^4",
45+
"@types/dagre": "^0.7.53",
4346
"@types/file-saver": "^2.0.7",
4447
"@types/node": "^20",
4548
"@types/react": "^19",
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
'use client';
2+
3+
import React, { useState, useEffect, useMemo, useCallback } from 'react';
4+
import { useParams, useSearchParams } from 'next/navigation';
5+
import Link from 'next/link';
6+
import dynamic from 'next/dynamic';
7+
import type { DiagramData } from '@/types/diagramData';
8+
import DiagramDetailPanel from '@/components/DiagramDetailPanel';
9+
10+
// Lazy-load the canvas to avoid SSR issues with React Flow
11+
const ExplorerCanvas = dynamic(
12+
() => import('@/components/explorer/ExplorerCanvas'),
13+
{ ssr: false },
14+
);
15+
16+
/* ------------------------------------------------------------------ */
17+
/* Wiki page shape (matches the cache format) */
18+
/* ------------------------------------------------------------------ */
19+
20+
interface CachedWikiPage {
21+
id: string;
22+
title: string;
23+
content: string;
24+
filePaths: string[];
25+
importance: 'high' | 'medium' | 'low';
26+
relatedPages: string[];
27+
diagramData?: DiagramData[] | null;
28+
parentId?: string;
29+
isSection?: boolean;
30+
children?: string[];
31+
}
32+
33+
/* ------------------------------------------------------------------ */
34+
/* Views */
35+
/* ------------------------------------------------------------------ */
36+
37+
type ExplorerView = 'architecture' | 'dataflow' | 'dependencies';
38+
39+
const VIEW_LABELS: Record<ExplorerView, string> = {
40+
architecture: 'Architecture',
41+
dataflow: 'Data Flow',
42+
dependencies: 'Dependencies',
43+
};
44+
45+
/* ------------------------------------------------------------------ */
46+
/* Page component */
47+
/* ------------------------------------------------------------------ */
48+
49+
export default function ExplorePage() {
50+
const params = useParams();
51+
const searchParams = useSearchParams();
52+
53+
const owner = params.owner as string;
54+
const repo = params.repo as string;
55+
const repoType = (searchParams.get('type') || 'github') as 'github' | 'gitlab' | 'bitbucket';
56+
57+
// State from URL or defaults
58+
const [selectedView, setSelectedView] = useState<ExplorerView>(
59+
(searchParams.get('view') as ExplorerView) || 'architecture',
60+
);
61+
const [selectedDepth, setSelectedDepth] = useState<number>(
62+
Number(searchParams.get('depth') ?? 1),
63+
);
64+
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
65+
searchParams.get('node') || null,
66+
);
67+
68+
// Data state
69+
const [allDiagrams, setAllDiagrams] = useState<DiagramData[]>([]);
70+
const [isLoading, setIsLoading] = useState(true);
71+
const [error, setError] = useState<string | null>(null);
72+
const [isPanelOpen, setIsPanelOpen] = useState(false);
73+
74+
// Fetch wiki cache on mount
75+
useEffect(() => {
76+
async function fetchWikiData() {
77+
try {
78+
setIsLoading(true);
79+
const cacheParams = new URLSearchParams({
80+
owner,
81+
repo,
82+
repo_type: repoType,
83+
});
84+
const response = await fetch(`/api/wiki_cache?${cacheParams.toString()}`);
85+
if (!response.ok) {
86+
setError('Failed to fetch wiki data.');
87+
setIsLoading(false);
88+
return;
89+
}
90+
91+
const cachedData = await response.json();
92+
if (!cachedData || !cachedData.generated_pages) {
93+
setError('No wiki data found. Generate a wiki first.');
94+
setIsLoading(false);
95+
return;
96+
}
97+
98+
// Extract all DiagramData from pages
99+
const diagrams: DiagramData[] = [];
100+
const pages = cachedData.generated_pages as Record<string, CachedWikiPage>;
101+
for (const page of Object.values(pages)) {
102+
if (page.diagramData && Array.isArray(page.diagramData)) {
103+
diagrams.push(...page.diagramData);
104+
}
105+
}
106+
107+
setAllDiagrams(diagrams);
108+
setIsLoading(false);
109+
} catch (err) {
110+
console.error('Error loading explorer data:', err);
111+
setError('Error loading wiki data.');
112+
setIsLoading(false);
113+
}
114+
}
115+
116+
fetchWikiData();
117+
}, [owner, repo, repoType]);
118+
119+
// Merge all diagram data for the selected view into one DiagramData
120+
const mergedDiagram = useMemo<DiagramData | null>(() => {
121+
if (allDiagrams.length === 0) return null;
122+
123+
// For now merge all diagrams. In future, filter by view type.
124+
const mergedNodes = new Map<string, DiagramData['nodes'][0]>();
125+
const mergedEdges: DiagramData['edges'] = [];
126+
const edgeKeys = new Set<string>();
127+
128+
for (const diagram of allDiagrams) {
129+
for (const node of diagram.nodes) {
130+
if (!mergedNodes.has(node.id)) {
131+
mergedNodes.set(node.id, node);
132+
}
133+
}
134+
for (const edge of diagram.edges) {
135+
const key = `${edge.source}->${edge.target}:${edge.type}`;
136+
if (!edgeKeys.has(key)) {
137+
edgeKeys.add(key);
138+
mergedEdges.push(edge);
139+
}
140+
}
141+
}
142+
143+
return {
144+
nodes: Array.from(mergedNodes.values()),
145+
edges: mergedEdges,
146+
mermaidSource: allDiagrams[0]?.mermaidSource ?? '',
147+
diagramType: allDiagrams[0]?.diagramType ?? 'flowchart',
148+
};
149+
}, [allDiagrams]);
150+
151+
// Look up the selected node info for the detail panel
152+
const selectedNodeInfo = useMemo(() => {
153+
if (!selectedNodeId || !mergedDiagram) return null;
154+
return mergedDiagram.nodes.find((n) => n.id === selectedNodeId) ?? null;
155+
}, [selectedNodeId, mergedDiagram]);
156+
157+
// Handlers
158+
const handleNodeClick = useCallback(
159+
(nodeId: string) => {
160+
setSelectedNodeId(nodeId);
161+
setIsPanelOpen(true);
162+
},
163+
[],
164+
);
165+
166+
const handleClosePanel = useCallback(() => {
167+
setIsPanelOpen(false);
168+
setSelectedNodeId(null);
169+
}, []);
170+
171+
const handleViewChange = useCallback((view: ExplorerView) => {
172+
setSelectedView(view);
173+
}, []);
174+
175+
const handleDepthChange = useCallback((depth: number) => {
176+
setSelectedDepth(depth);
177+
}, []);
178+
179+
// Build back link
180+
const backHref = `/${owner}/${repo}${searchParams.get('type') ? `?type=${repoType}` : ''}`;
181+
182+
// No diagram data state
183+
const hasNoDiagramData = !isLoading && !error && allDiagrams.length === 0;
184+
185+
return (
186+
<div className="h-screen flex flex-col bg-gray-50 dark:bg-zinc-950">
187+
{/* ---- Toolbar ---- */}
188+
<div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
189+
{/* Left: Back + Repo name */}
190+
<div className="flex items-center gap-3">
191+
<Link
192+
href={backHref}
193+
className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
194+
>
195+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
196+
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
197+
</svg>
198+
Back to Wiki
199+
</Link>
200+
<div className="w-px h-5 bg-gray-200 dark:bg-zinc-700" />
201+
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
202+
{owner}/{repo}
203+
</span>
204+
</div>
205+
206+
{/* Center: View tabs */}
207+
<div className="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5 gap-0.5">
208+
{(Object.entries(VIEW_LABELS) as [ExplorerView, string][]).map(([view, label]) => (
209+
<button
210+
key={view}
211+
onClick={() => handleViewChange(view)}
212+
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${
213+
selectedView === view
214+
? 'bg-white dark:bg-zinc-700 text-gray-900 dark:text-gray-100 shadow-sm'
215+
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
216+
}`}
217+
>
218+
{label}
219+
</button>
220+
))}
221+
</div>
222+
223+
{/* Right: spacer for balance */}
224+
<div className="w-[160px]" />
225+
</div>
226+
227+
{/* ---- Main content ---- */}
228+
<div className="flex-1 flex overflow-hidden relative">
229+
{/* Canvas */}
230+
<div className="flex-1 overflow-hidden">
231+
{isLoading ? (
232+
<div className="flex items-center justify-center h-full">
233+
<div className="text-center">
234+
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
235+
<p className="text-sm text-gray-500 dark:text-gray-400">Loading explorer data...</p>
236+
</div>
237+
</div>
238+
) : error ? (
239+
<div className="flex items-center justify-center h-full">
240+
<div className="text-center max-w-md px-6">
241+
<svg className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
242+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
243+
</svg>
244+
<p className="text-sm text-gray-500 dark:text-gray-400">{error}</p>
245+
</div>
246+
</div>
247+
) : hasNoDiagramData ? (
248+
<div className="flex items-center justify-center h-full">
249+
<div className="text-center max-w-md px-6">
250+
<svg className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
251+
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
252+
</svg>
253+
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
254+
No structured diagram data available
255+
</p>
256+
<p className="text-xs text-gray-500 dark:text-gray-400">
257+
This wiki doesn&apos;t have structured diagram data yet. Regenerate the wiki to enable the Visual Explorer.
258+
</p>
259+
</div>
260+
</div>
261+
) : (
262+
<ExplorerCanvas
263+
diagramData={mergedDiagram}
264+
maxDepth={selectedDepth}
265+
onNodeClick={handleNodeClick}
266+
selectedNodeId={selectedNodeId}
267+
/>
268+
)}
269+
</div>
270+
271+
{/* Detail panel (reuse DiagramDetailPanel) */}
272+
<DiagramDetailPanel
273+
isOpen={isPanelOpen}
274+
onClose={handleClosePanel}
275+
nodeId={selectedNodeId}
276+
nodeLabel={selectedNodeInfo?.label ?? null}
277+
diagramData={mergedDiagram}
278+
repoOwner={owner}
279+
repoName={repo}
280+
repoType={repoType}
281+
/>
282+
</div>
283+
284+
{/* ---- Depth toggle ---- */}
285+
<div className="shrink-0 flex items-center justify-center px-4 py-2 border-t border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
286+
<div className="flex items-center gap-1 bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5">
287+
{[
288+
{ depth: 0, label: 'Overview' },
289+
{ depth: 1, label: 'Detailed' },
290+
{ depth: 2, label: 'Full' },
291+
].map(({ depth, label }) => (
292+
<button
293+
key={depth}
294+
onClick={() => handleDepthChange(depth)}
295+
className={`px-4 py-1.5 rounded-md text-xs font-medium transition-all duration-150 ${
296+
selectedDepth === depth
297+
? 'bg-white dark:bg-zinc-700 text-gray-900 dark:text-gray-100 shadow-sm'
298+
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
299+
}`}
300+
>
301+
{label}
302+
</button>
303+
))}
304+
</div>
305+
</div>
306+
</div>
307+
);
308+
}

src/components/Markdown.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
44
import rehypeRaw from 'rehype-raw';
55
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
66
import { tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/prism';
7+
import { useParams } from 'next/navigation';
78
import Mermaid from './Mermaid';
89
import { slugify } from './TableOfContents';
910
import type { DiagramData } from '../types/diagramData';
@@ -78,6 +79,12 @@ const CopyButton: React.FC<{ text: string }> = ({ text }) => {
7879
};
7980

8081
const Markdown: React.FC<MarkdownProps> = ({ content, onDiagramNodeClick }) => {
82+
// Get owner/repo from route params for explorer URL
83+
const params = useParams();
84+
const owner = typeof params?.owner === 'string' ? params.owner : '';
85+
const repo = typeof params?.repo === 'string' ? params.repo : '';
86+
const explorerUrl = owner && repo ? `/${owner}/${repo}/explore` : undefined;
87+
8188
// Extract and strip diagram data markers from content
8289
const { cleanContent, diagramDataMap } = useMemo(() => extractDiagramData(content), [content]);
8390

@@ -214,6 +221,7 @@ const Markdown: React.FC<MarkdownProps> = ({ content, onDiagramNodeClick }) => {
214221
zoomingEnabled={true}
215222
diagramData={matchedData}
216223
onNodeClick={handleNodeClick}
224+
explorerUrl={matchedData ? explorerUrl : undefined}
217225
/>
218226
</div>
219227
);

0 commit comments

Comments
 (0)