diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index df52ba3..a28ed0b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.3.2", + "version": "2.4.0", "author": { "name": "Lum1104" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index aea7e22..1bdb943 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.3.2", + "version": "2.4.0", "author": { "name": "Lum1104" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index a3c9aa7..9d814fa 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.3.2", + "version": "2.4.0", "author": { "name": "Lum1104" }, diff --git a/CLAUDE.md b/CLAUDE.md index 10334a4..9ab3dc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ An open-source tool combining LLM intelligence + static analysis to produce inte - Dark luxury theme: deep blacks (#0a0a0a), gold/amber accents (#d4a574), DM Serif Display typography - Graph-first layout: 75% graph + 360px right sidebar - No ChatPanel or Monaco Editor -- Sidebar: ProjectOverview (default) → NodeInfo (node selected) → LearnPanel (Learn persona) -- Code viewer: styled summary overlay (slides up from bottom on file node click) +- Sidebar tabs: `Info` (ProjectOverview default → NodeInfo when node selected → LearnPanel in Learn persona, composing) and `Files` (FileExplorer tree built from the structural graph) +- Code viewer: prism-react-renderer source viewer that slides up from the bottom on file node click; an expand button promotes it into a full-screen modal. Source content is fetched from the dev server's `/file-content.json` endpoint, gated by access token + a graph-derived path allowlist - Schema validation on graph load with error banner ## Agent Pipeline diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d5c2b8..b026d0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: hast-util-to-jsx-runtime: specifier: ^2.3.6 version: 2.3.6 + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -1089,6 +1092,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2150,6 +2156,11 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -3590,6 +3601,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3643,14 +3656,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 @@ -4994,6 +4999,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prism-react-renderer@2.4.1(react@19.2.4): + dependencies: + '@types/prismjs': 1.26.6 + clsx: 2.1.1 + react: 19.2.4 + prismjs@1.30.0: {} property-information@7.1.0: {} @@ -5605,7 +5616,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index df52ba3..a28ed0b 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.3.2", + "version": "2.4.0", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 6676744..dd9c517 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.3.2", + "version": "2.4.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/packages/dashboard/package.json b/understand-anything-plugin/packages/dashboard/package.json index 2702091..8bb9196 100644 --- a/understand-anything-plugin/packages/dashboard/package.json +++ b/understand-anything-plugin/packages/dashboard/package.json @@ -16,6 +16,7 @@ "d3-force": "^3.0.0", "devlop": "^1.1.0", "hast-util-to-jsx-runtime": "^2.3.6", + "prism-react-renderer": "^2.4.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 2c16f53..b3240e6 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -13,6 +13,7 @@ import FilterPanel from "./components/FilterPanel"; import ExportMenu from "./components/ExportMenu"; import PersonaSelector from "./components/PersonaSelector"; import ProjectOverview from "./components/ProjectOverview"; +import FileExplorer from "./components/FileExplorer"; import WarningBanner from "./components/WarningBanner"; import TokenGate from "./components/TokenGate"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; @@ -31,6 +32,7 @@ const KeyboardShortcutsHelp = lazy( const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true"; const SESSION_TOKEN_KEY = "understand-anything-token"; +type SidebarTab = "info" | "files"; /** Resolve data file URL — in demo mode, use env var URLs; otherwise use local paths with token. */ function dataUrl(fileName: string, token: string | null): string { @@ -97,7 +99,9 @@ function Dashboard({ accessToken }: { accessToken: string }) { const tourActive = useDashboardStore((s) => s.tourActive); const persona = useDashboardStore((s) => s.persona); const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen); - const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer); + const codeViewerExpanded = useDashboardStore((s) => s.codeViewerExpanded); + const expandCodeViewer = useDashboardStore((s) => s.expandCodeViewer); + const collapseCodeViewer = useDashboardStore((s) => s.collapseCodeViewer); const setDiffOverlay = useDashboardStore((s) => s.setDiffOverlay); const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen); const togglePathFinder = useDashboardStore((s) => s.togglePathFinder); @@ -107,6 +111,7 @@ function Dashboard({ accessToken }: { accessToken: string }) { const [graphIssues, setGraphIssues] = useState([]); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [metaTheme, setMetaTheme] = useState(null); + const [sidebarTab, setSidebarTab] = useState("info"); const viewMode = useDashboardStore((s) => s.viewMode); const setViewMode = useDashboardStore((s) => s.setViewMode); const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph); @@ -122,6 +127,10 @@ function Dashboard({ accessToken }: { accessToken: string }) { .catch(() => {}); }, []); + useEffect(() => { + if (selectedNodeId) setSidebarTab("info"); + }, [selectedNodeId]); + // Define keyboard shortcuts const shortcuts = useMemo( () => [ @@ -146,6 +155,8 @@ function Dashboard({ accessToken }: { accessToken: string }) { state.toggleFilterPanel(); } else if (state.exportMenuOpen) { state.toggleExportMenu(); + } else if (state.codeViewerExpanded) { + state.collapseCodeViewer(); } else if (state.codeViewerOpen) { state.closeCodeViewer(); } else if (state.selectedNodeId) { @@ -322,7 +333,7 @@ function Dashboard({ accessToken }: { accessToken: string }) { // NodeInfo always takes priority when a node is selected. // Learn mode adds LearnPanel below it; otherwise ProjectOverview shows when idle. const isLearnMode = tourActive || persona === "junior"; - const sidebarContent = ( + const infoSidebarContent = ( <> {selectedNodeId && } {isLearnMode && ( @@ -334,6 +345,30 @@ function Dashboard({ accessToken }: { accessToken: string }) { ); + const sidebarContent = ( +
+
+ {(["info", "files"] as const).map((tab) => ( + + ))} +
+
+ {sidebarTab === "files" ? : infoSidebarContent} +
+
+ ); + return (
@@ -503,28 +538,37 @@ function Dashboard({ accessToken }: { accessToken: string }) { {sidebarContent} - {/* Code viewer overlay */} - {codeViewerOpen && ( -
-
-
- -
-
- - - -
-
+ {/* Code viewer slide-up overlay (collapsed state) */} + {codeViewerOpen && !codeViewerExpanded && ( +
+ + +
)}
+ {/* Expanded code viewer modal */} + {codeViewerOpen && codeViewerExpanded && ( +
+
event.stopPropagation()} + > + + + +
+
+ )} + {/* Keyboard shortcuts help modal */} {showKeyboardHelp && ( diff --git a/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx b/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx index 64fe249..4d8b018 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx @@ -1,11 +1,126 @@ +import { useEffect, useMemo, useState } from "react"; +import { Highlight, themes } from "prism-react-renderer"; import { useDashboardStore } from "../store"; -export default function CodeViewer() { +interface CodeViewerProps { + accessToken: string; + presentation?: "sidebar" | "modal"; + onClose?: () => void; + onExpand?: () => void; +} + +interface SourceFile { + path: string; + language: string; + content: string; + sizeBytes: number; + lineCount: number; +} + +type SourceState = + | { status: "idle" | "loading"; source: null; error: null } + | { status: "loaded"; source: SourceFile; error: null } + | { status: "error"; source: null; error: string }; + +function fileContentUrl(filePath: string, token: string): string { + const params = new URLSearchParams({ token, path: filePath }); + return `/file-content.json?${params.toString()}`; +} + +function fallbackLanguage(filePath: string | undefined): string { + const ext = filePath?.split(".").pop()?.toLowerCase(); + const byExt: Record = { + css: "css", + go: "go", + html: "markup", + js: "javascript", + jsx: "jsx", + json: "json", + md: "markdown", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + ts: "typescript", + tsx: "tsx", + yaml: "yaml", + yml: "yaml", + }; + return ext ? byExt[ext] ?? "text" : "text"; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function CodeViewer({ + accessToken, + presentation = "sidebar", + onClose, + onExpand, +}: CodeViewerProps) { const graph = useDashboardStore((s) => s.graph); + const domainGraph = useDashboardStore((s) => s.domainGraph); + const viewMode = useDashboardStore((s) => s.viewMode); const codeViewerNodeId = useDashboardStore((s) => s.codeViewerNodeId); const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer); + const activeGraph = viewMode === "domain" && domainGraph ? domainGraph : graph; + // Files tab always builds its tree from the structural graph, so a node ID opened from + // there may not exist in the active (domain) graph — fall back to the structural graph. + const node = + activeGraph?.nodes.find((n) => n.id === codeViewerNodeId) ?? + graph?.nodes.find((n) => n.id === codeViewerNodeId) ?? + null; + const [state, setState] = useState({ + status: "idle", + source: null, + error: null, + }); - const node = graph?.nodes.find((n) => n.id === codeViewerNodeId) ?? null; + useEffect(() => { + if (!node?.filePath) { + setState({ status: "error", source: null, error: "This node does not have a file path." }); + return; + } + + if (accessToken === "__demo__") { + setState({ + status: "error", + source: null, + error: "Source preview is available only when the local dashboard server is running.", + }); + return; + } + + const controller = new AbortController(); + setState({ status: "loading", source: null, error: null }); + + fetch(fileContentUrl(node.filePath, accessToken), { signal: controller.signal }) + .then(async (res) => { + const data = (await res.json()) as SourceFile | { error?: string }; + if (!res.ok) { + throw new Error("error" in data && data.error ? data.error : "Source unavailable"); + } + setState({ status: "loaded", source: data as SourceFile, error: null }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setState({ + status: "error", + source: null, + error: err instanceof Error ? err.message : String(err), + }); + }); + + return () => controller.abort(); + }, [accessToken, node?.filePath]); + + const highlightedRange = useMemo(() => { + if (!node?.lineRange) return null; + return { start: node.lineRange[0], end: node.lineRange[1] }; + }, [node?.lineRange]); if (!node) { return ( @@ -15,77 +130,127 @@ export default function CodeViewer() { ); } - const lineInfo = node.lineRange - ? `Lines ${node.lineRange[0]}\u2013${node.lineRange[1]}` + const source = state.source; + const language = source?.language ?? fallbackLanguage(node.filePath); + const lineInfo = highlightedRange + ? `Lines ${highlightedRange.start}-${highlightedRange.end}` : "Full file"; + const isModal = presentation === "modal"; + const handleClose = onClose ?? closeCodeViewer; return (
- {/* Header */} -
- - {node.type} - - - {node.name} - - {node.filePath && ( - - {node.filePath} - - )} - {lineInfo} - +
+
+
+ + {language} + + {lineInfo} +
+
+ {node.name} +
+ {node.filePath && ( +
+ {node.filePath} +
+ )} +
+
+ {onExpand && ( + + )} + +
- {/* Body */} -
- {/* Summary */} -
-

Summary

-

{node.summary}

-
- - {/* Language notes callout */} - {node.languageNotes && ( -
-

Language Notes

-

{node.languageNotes}

-
+
+ {state.status === "loading" && ( +
Loading source...
)} - {/* Tags */} - {node.tags.length > 0 && ( -
-

Tags

-
- {node.tags.map((tag) => ( - - {tag} - - ))} + {state.status === "error" && ( +
+
+
Source unavailable
+

{state.error}

)} - {/* Source note */} -
- Source code available locally at {node.filePath ?? "the project directory"} -
+ {source && ( + <> +
+ {source.lineCount} lines + {formatBytes(source.sizeBytes)} +
+ + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+                  {tokens.map((line, index) => {
+                    const lineNumber = index + 1;
+                    const isHighlighted =
+                      highlightedRange !== null &&
+                      lineNumber >= highlightedRange.start &&
+                      lineNumber <= highlightedRange.end;
+                    const lineProps = getLineProps({ line });
+                    return (
+                      
+ + {lineNumber} + + + {line.map((token, key) => ( + + ))} + +
+ ); + })} +
+ )} +
+ + )}
); diff --git a/understand-anything-plugin/packages/dashboard/src/components/FileExplorer.tsx b/understand-anything-plugin/packages/dashboard/src/components/FileExplorer.tsx new file mode 100644 index 0000000..a0697d1 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/FileExplorer.tsx @@ -0,0 +1,212 @@ +import { useMemo, useState } from "react"; +import type { GraphNode } from "@understand-anything/core/types"; +import { useDashboardStore } from "../store"; + +interface FileEntry { + name: string; + path: string; + type: "folder" | "file"; + children: FileEntry[]; + nodeId?: string; +} + +function normalizeFilePath(filePath: string): string | null { + const normalized = filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, ""); + if (!normalized || normalized === "." || normalized.includes("\0")) return null; + if (normalized.split("/").some((part) => part === "..")) return null; + return normalized; +} + +function bestFileNode(existing: GraphNode | undefined, candidate: GraphNode): GraphNode { + if (!existing) return candidate; + if (existing.type !== "file" && candidate.type === "file") return candidate; + return existing; +} + +function buildFileTree(nodes: GraphNode[]): FileEntry[] { + const files = new Map(); + for (const node of nodes) { + if (!node.filePath) continue; + const filePath = normalizeFilePath(node.filePath); + if (!filePath) continue; + files.set(filePath, bestFileNode(files.get(filePath), node)); + } + + const root: FileEntry = { name: "", path: "", type: "folder", children: [] }; + const folders = new Map([["", root]]); + + for (const [filePath, node] of files) { + const parts = filePath.split("/"); + let parent = root; + let currentPath = ""; + + for (let i = 0; i < parts.length; i += 1) { + const name = parts[i]; + currentPath = currentPath ? `${currentPath}/${name}` : name; + const isFile = i === parts.length - 1; + + if (isFile) { + parent.children.push({ + name, + path: currentPath, + type: "file", + children: [], + nodeId: node.id, + }); + continue; + } + + let folder = folders.get(currentPath); + if (!folder) { + folder = { name, path: currentPath, type: "folder", children: [] }; + folders.set(currentPath, folder); + parent.children.push(folder); + } + parent = folder; + } + } + + const sortEntries = (entries: FileEntry[]): FileEntry[] => + entries + .sort((a, b) => { + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }) + .map((entry) => ({ + ...entry, + children: sortEntries(entry.children), + })); + + return sortEntries(root.children); +} + +function FileTreeRow({ + entry, + depth, + expanded, + toggleFolder, + openFile, +}: { + entry: FileEntry; + depth: number; + expanded: Set; + toggleFolder: (path: string) => void; + openFile: (nodeId: string) => void; +}) { + const isExpanded = expanded.has(entry.path); + const paddingLeft = 12 + depth * 14; + + if (entry.type === "folder") { + return ( + <> + + {isExpanded && + entry.children.map((child) => ( + + ))} + + ); + } + + return ( + + ); +} + +export default function FileExplorer() { + const graph = useDashboardStore((s) => s.graph); + const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); + const navigateToNode = useDashboardStore((s) => s.navigateToNode); + const entries = useMemo(() => buildFileTree(graph?.nodes ?? []), [graph]); + const [expanded, setExpanded] = useState>(() => new Set()); + + // Navigate the graph first (drills into layer + selects node, which clears the + // code viewer), then re-open the viewer so the source panel stays visible. + const handleOpenFile = (nodeId: string) => { + navigateToNode(nodeId); + openCodeViewer(nodeId); + }; + + const toggleFolder = (folderPath: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(folderPath)) { + next.delete(folderPath); + } else { + next.add(folderPath); + } + return next; + }); + }; + + const totalFiles = useMemo(() => { + const countFiles = (items: FileEntry[]): number => + items.reduce( + (count, item) => count + (item.type === "file" ? 1 : countFiles(item.children)), + 0, + ); + return countFiles(entries); + }, [entries]); + + if (!graph) { + return ( +
+ No graph loaded +
+ ); + } + + return ( +
+
+
+ Analyzed Files +
+
+ {totalFiles} files from the current knowledge graph +
+
+
+ {entries.length === 0 ? ( +
No file paths found.
+ ) : ( + entries.map((entry) => ( + + )) + )} +
+
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx index 9d767b1..748f405 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx @@ -310,6 +310,7 @@ export default function NodeInfo() { const navigateToNode = useDashboardStore((s) => s.navigateToNode); const navigateToHistoryIndex = useDashboardStore((s) => s.navigateToHistoryIndex); const setFocusNode = useDashboardStore((s) => s.setFocusNode); + const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); const focusNodeId = useDashboardStore((s) => s.focusNodeId); const viewMode = useDashboardStore((s) => s.viewMode); const domainGraph = useDashboardStore((s) => s.domainGraph); @@ -427,14 +428,27 @@ export default function NodeInfo() {

{node.filePath && ( -
- File:{" "} - {node.filePath} - {node.lineRange && ( - - (L{node.lineRange[0]}-{node.lineRange[1]}) - - )} +
+
+
+
File
+
+ {node.filePath} + {node.lineRange && ( + + L{node.lineRange[0]}-{node.lineRange[1]} + + )} +
+
+ +
)} diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index 73ae557..826d260 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -74,6 +74,7 @@ interface DashboardStore { codeViewerOpen: boolean; codeViewerNodeId: string | null; + codeViewerExpanded: boolean; tourActive: boolean; currentTourStep: number; @@ -115,6 +116,8 @@ interface DashboardStore { setPersona: (persona: Persona) => void; openCodeViewer: (nodeId: string) => void; closeCodeViewer: () => void; + expandCodeViewer: () => void; + collapseCodeViewer: () => void; setDiffOverlay: (changed: string[], affected: string[]) => void; toggleDiffMode: () => void; @@ -180,6 +183,7 @@ export const useDashboardStore = create()((set, get) => ({ activeLayerId: null, codeViewerOpen: false, codeViewerNodeId: null, + codeViewerExpanded: false, tourActive: false, currentTourStep: 0, @@ -264,6 +268,7 @@ export const useDashboardStore = create()((set, get) => ({ focusNodeId: null, codeViewerOpen: false, codeViewerNodeId: null, + codeViewerExpanded: false, nodeHistory: newHistory, }); } else { @@ -316,6 +321,7 @@ export const useDashboardStore = create()((set, get) => ({ focusNodeId: null, codeViewerOpen: false, codeViewerNodeId: null, + codeViewerExpanded: false, }), navigateToOverview: () => @@ -326,6 +332,7 @@ export const useDashboardStore = create()((set, get) => ({ focusNodeId: null, codeViewerOpen: false, codeViewerNodeId: null, + codeViewerExpanded: false, }), setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }), @@ -346,8 +353,12 @@ export const useDashboardStore = create()((set, get) => ({ setPersona: (persona) => set({ persona }), - openCodeViewer: (nodeId) => set({ codeViewerOpen: true, codeViewerNodeId: nodeId }), - closeCodeViewer: () => set({ codeViewerOpen: false, codeViewerNodeId: null }), + openCodeViewer: (nodeId) => + set({ codeViewerOpen: true, codeViewerNodeId: nodeId, codeViewerExpanded: false }), + closeCodeViewer: () => + set({ codeViewerOpen: false, codeViewerNodeId: null, codeViewerExpanded: false }), + expandCodeViewer: () => set({ codeViewerExpanded: true }), + collapseCodeViewer: () => set({ codeViewerExpanded: false }), setDiffOverlay: (changed, affected) => set({ @@ -486,6 +497,7 @@ export const useDashboardStore = create()((set, get) => ({ focusNodeId: null, codeViewerOpen: false, codeViewerNodeId: null, + codeViewerExpanded: false, }); }, diff --git a/understand-anything-plugin/packages/dashboard/vite.config.ts b/understand-anything-plugin/packages/dashboard/vite.config.ts index 92daaf3..697ca5b 100644 --- a/understand-anything-plugin/packages/dashboard/vite.config.ts +++ b/understand-anything-plugin/packages/dashboard/vite.config.ts @@ -9,6 +9,171 @@ import crypto from "crypto"; // This token is printed to the terminal and must be in the URL // to fetch knowledge-graph.json or diff-overlay.json. const ACCESS_TOKEN = crypto.randomBytes(16).toString("hex"); +const MAX_SOURCE_FILE_BYTES = 1024 * 1024; + +function graphFileCandidates(fileName: string): string[] { + const graphDir = process.env.GRAPH_DIR; + return [ + ...(graphDir + ? [path.resolve(graphDir, `.understand-anything/${fileName}`)] + : []), + path.resolve(process.cwd(), `.understand-anything/${fileName}`), + path.resolve(process.cwd(), `../../../.understand-anything/${fileName}`), + ]; +} + +function findGraphFile(fileName: string): string | null { + return graphFileCandidates(fileName).find((candidate) => fs.existsSync(candidate)) ?? null; +} + +function projectRootFromGraphFile(candidate: string): string { + return path.dirname(path.dirname(candidate)); +} + +function normalizeGraphPath(filePath: string, projectRoot: string): string | null { + const rawPath = path.isAbsolute(filePath) + ? filePath.startsWith(projectRoot) + ? path.relative(projectRoot, filePath) + : null + : filePath; + if (rawPath === null) return null; + const normalized = path.normalize(rawPath); + if ( + !normalized || + normalized === "." || + normalized.includes("\0") || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) || + path.isAbsolute(normalized) + ) { + return null; + } + return normalized.split(path.sep).join("/"); +} + +function graphFilePathSet(graphFile: string, projectRoot: string): Set { + const allowed = new Set(); + try { + const raw = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as { + nodes?: Array>; + }; + for (const node of raw.nodes ?? []) { + if (typeof node.filePath !== "string") continue; + const normalized = normalizeGraphPath(node.filePath, projectRoot); + if (normalized) allowed.add(normalized); + } + } catch { + return allowed; + } + return allowed; +} + +function detectLanguage(filePath: string): string { + const ext = path.extname(filePath).slice(1).toLowerCase(); + const byExt: Record = { + bash: "bash", + c: "c", + cc: "cpp", + cpp: "cpp", + cs: "csharp", + css: "css", + go: "go", + h: "c", + hpp: "cpp", + html: "markup", + java: "java", + js: "javascript", + jsx: "jsx", + json: "json", + md: "markdown", + mjs: "javascript", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + ts: "typescript", + tsx: "tsx", + txt: "text", + yaml: "yaml", + yml: "yaml", + }; + return byExt[ext] ?? "text"; +} + +function sendJson(res: import("http").ServerResponse, statusCode: number, payload: unknown) { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(payload)); +} + +function rejectFileRequest(message: string, statusCode = 400) { + return { statusCode, payload: { error: message } }; +} + +function readSourceFile(url: URL) { + const requestedPath = url.searchParams.get("path") ?? ""; + if (!requestedPath) return rejectFileRequest("Missing path"); + if (requestedPath.includes("\0")) return rejectFileRequest("Invalid path"); + if (path.isAbsolute(requestedPath)) return rejectFileRequest("Absolute paths are not allowed"); + + const normalizedPath = path.normalize(requestedPath); + if ( + normalizedPath === "." || + normalizedPath.startsWith(`..${path.sep}`) || + normalizedPath === ".." || + path.isAbsolute(normalizedPath) + ) { + return rejectFileRequest("Path must stay inside the project"); + } + + const graphFile = findGraphFile("knowledge-graph.json"); + if (!graphFile) { + return rejectFileRequest("No knowledge graph found. Run /understand first.", 404); + } + + const projectRoot = projectRootFromGraphFile(graphFile); + const absoluteFile = path.resolve(projectRoot, normalizedPath); + const relativeToRoot = path.relative(projectRoot, absoluteFile); + if ( + !relativeToRoot || + relativeToRoot.startsWith(`..${path.sep}`) || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return rejectFileRequest("Path must stay inside the project"); + } + const safeRelativePath = relativeToRoot.split(path.sep).join("/"); + if (!graphFilePathSet(graphFile, projectRoot).has(safeRelativePath)) { + return rejectFileRequest("File is not in the knowledge graph", 404); + } + + let stat: fs.Stats; + try { + stat = fs.statSync(absoluteFile); + } catch { + return rejectFileRequest("File not found", 404); + } + + if (!stat.isFile()) return rejectFileRequest("Path is not a file"); + if (stat.size > MAX_SOURCE_FILE_BYTES) { + return rejectFileRequest("File is too large to preview", 413); + } + + const buffer = fs.readFileSync(absoluteFile); + if (buffer.includes(0)) return rejectFileRequest("Binary files cannot be previewed", 415); + + const content = buffer.toString("utf8"); + return { + statusCode: 200, + payload: { + path: safeRelativePath, + language: detectLanguage(relativeToRoot), + content, + sizeBytes: buffer.byteLength, + lineCount: content.length === 0 ? 0 : content.split(/\r\n|\n|\r/).length, + }, + }; +} export default defineConfig({ // FIX 1 — bind only to localhost, not 0.0.0.0 @@ -62,8 +227,10 @@ export default defineConfig({ configureServer(server) { // Print the access URL once so the developer can open it. server.httpServer?.once("listening", () => { + const address = server.httpServer?.address(); + const port = typeof address === "object" && address ? address.port : 5173; console.log( - `\n 🔑 Dashboard URL: http://127.0.0.1:5173?token=${ACCESS_TOKEN}\n` + `\n 🔑 Dashboard URL: http://127.0.0.1:${port}?token=${ACCESS_TOKEN}\n` ); }); @@ -74,7 +241,8 @@ export default defineConfig({ pathname === "/knowledge-graph.json" || pathname === "/domain-graph.json" || pathname === "/diff-overlay.json" || - pathname === "/meta.json"; + pathname === "/meta.json" || + pathname === "/file-content.json"; if (!isProtectedEndpoint) { next(); @@ -84,9 +252,13 @@ export default defineConfig({ // FIX 3 — require the one-time token on all data endpoints. // Requests without a matching ?token= get a 403. if (url.searchParams.get("token") !== ACCESS_TOKEN) { - res.statusCode = 403; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify({ error: "Forbidden: missing or invalid token" })); + sendJson(res, 403, { error: "Forbidden: missing or invalid token" }); + return; + } + + if (pathname === "/file-content.json") { + const result = readSourceFile(url); + sendJson(res, result.statusCode, result.payload); return; } @@ -99,17 +271,7 @@ export default defineConfig({ ? "domain-graph.json" : "knowledge-graph.json"; - const graphDir = process.env.GRAPH_DIR; - const candidates = [ - ...(graphDir - ? [path.resolve(graphDir, `.understand-anything/${fileName}`)] - : []), - path.resolve(process.cwd(), `.understand-anything/${fileName}`), - path.resolve( - process.cwd(), - `../../../.understand-anything/${fileName}` - ), - ]; + const candidates = graphFileCandidates(fileName); for (const candidate of candidates) { if (!fs.existsSync(candidate)) continue; @@ -126,12 +288,7 @@ export default defineConfig({ // Derive the project root from the candidate path so we can // make file paths relative to it. - const projectRoot = path.dirname( - candidate.replace( - `${path.sep}.understand-anything${path.sep}${fileName}`, - "" - ) - ); + const projectRoot = projectRootFromGraphFile(candidate); if (Array.isArray(raw.nodes)) { raw.nodes = raw.nodes.map((node) => { diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml index 1e22299..bf0f21f 100644 --- a/understand-anything-plugin/pnpm-lock.yaml +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: hast-util-to-jsx-runtime: specifier: ^2.3.6 version: 2.3.6 + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -705,6 +708,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/prismjs@1.26.6': + resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -858,6 +864,10 @@ packages: classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1356,6 +1366,11 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -2245,6 +2260,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/prismjs@1.26.6': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -2423,6 +2440,8 @@ snapshots: classcat@5.0.5: {} + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3024,6 +3043,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prism-react-renderer@2.4.1(react@19.2.4): + dependencies: + '@types/prismjs': 1.26.6 + clsx: 2.1.1 + react: 19.2.4 + property-information@7.1.0: {} react-dom@19.2.4(react@19.2.4):