mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #108 from arkaigrowth/codex/code-viewer-sidepane
feat(dashboard): add read-only source viewer
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+20
-9
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<GraphIssue[]>([]);
|
||||
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||
const [metaTheme, setMetaTheme] = useState<ThemeConfig | null>(null);
|
||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("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<KeyboardShortcut[]>(
|
||||
() => [
|
||||
@@ -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 && <NodeInfo />}
|
||||
{isLearnMode && (
|
||||
@@ -334,6 +345,30 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
</>
|
||||
);
|
||||
|
||||
const sidebarContent = (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="flex items-center gap-1 p-2 border-b border-border-subtle bg-surface shrink-0">
|
||||
{(["info", "files"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setSidebarTab(tab)}
|
||||
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||
sidebarTab === tab
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-text-muted hover:text-text-primary hover:bg-elevated"
|
||||
}`}
|
||||
>
|
||||
{tab === "info" ? "Info" : "Files"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{sidebarTab === "files" ? <FileExplorer /> : infoSidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider metaTheme={metaTheme}>
|
||||
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
|
||||
@@ -503,28 +538,37 @@ function Dashboard({ accessToken }: { accessToken: string }) {
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
|
||||
{/* Code viewer overlay */}
|
||||
{codeViewerOpen && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[25vh] bg-surface border-t border-border-subtle animate-slide-up z-20">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-end px-3 py-1 shrink-0">
|
||||
<button
|
||||
onClick={closeCodeViewer}
|
||||
className="text-text-muted hover:text-text-primary text-xs transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
{/* Code viewer slide-up overlay (collapsed state) */}
|
||||
{codeViewerOpen && !codeViewerExpanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[40vh] bg-surface border-t border-border-subtle animate-slide-up z-20 overflow-hidden">
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer accessToken={accessToken} onExpand={expandCodeViewer} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded code viewer modal */}
|
||||
{codeViewerOpen && codeViewerExpanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 backdrop-blur-sm p-4 sm:p-6"
|
||||
onMouseDown={collapseCodeViewer}
|
||||
>
|
||||
<div
|
||||
className="w-[calc(100vw-32px)] max-w-[1120px] h-[calc(100vh-32px)] sm:h-[calc(100vh-48px)] max-h-[820px] rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<CodeViewer
|
||||
accessToken={accessToken}
|
||||
presentation="modal"
|
||||
onClose={collapseCodeViewer}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts help modal */}
|
||||
{showKeyboardHelp && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<SourceState>({
|
||||
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 (
|
||||
<div className="h-full w-full flex flex-col bg-surface overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-elevated border-b border-border-subtle shrink-0">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border"
|
||||
style={{
|
||||
color: "var(--color-node-file)",
|
||||
borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)",
|
||||
backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
{node.type}
|
||||
</span>
|
||||
<span className="text-sm font-serif text-text-primary truncate">
|
||||
{node.name}
|
||||
</span>
|
||||
{node.filePath && (
|
||||
<span className="text-xs font-mono text-text-muted truncate ml-auto">
|
||||
{node.filePath}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-muted">{lineInfo}</span>
|
||||
<button
|
||||
onClick={closeCodeViewer}
|
||||
className="text-text-muted hover:text-text-primary ml-2 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-start gap-3 px-4 py-3 bg-elevated border-b border-border-subtle shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded border"
|
||||
style={{
|
||||
color: "var(--color-node-file)",
|
||||
borderColor: "color-mix(in srgb, var(--color-node-file) 30%, transparent)",
|
||||
backgroundColor: "color-mix(in srgb, var(--color-node-file) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">{lineInfo}</span>
|
||||
</div>
|
||||
<div className="text-sm font-serif text-text-primary truncate" title={node.name}>
|
||||
{node.name}
|
||||
</div>
|
||||
{node.filePath && (
|
||||
<div className="text-[11px] font-mono text-text-muted truncate mt-0.5" title={node.filePath}>
|
||||
{node.filePath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
title="Open larger code viewer"
|
||||
aria-label="Open larger code viewer"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 9V4h5M20 15v5h-5M4 4l6 6M20 20l-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="text-text-muted hover:text-text-primary transition-colors"
|
||||
title={isModal ? "Close expanded code viewer" : "Close code viewer"}
|
||||
aria-label={isModal ? "Close expanded code viewer" : "Close code viewer"}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-5">
|
||||
{/* Summary */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Summary</h4>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{node.summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Language notes callout */}
|
||||
{node.languageNotes && (
|
||||
<div className="mb-4 bg-accent/5 border border-accent/20 rounded-lg p-3">
|
||||
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-1.5">Language Notes</h4>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{node.languageNotes}</p>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto bg-root">
|
||||
{state.status === "loading" && (
|
||||
<div className="p-5 text-sm text-text-muted">Loading source...</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{node.tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-[11px] font-semibold text-accent uppercase tracking-wider mb-2">Tags</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{node.tags.map((tag) => (
|
||||
<span key={tag} className="text-[11px] glass text-text-secondary px-2.5 py-1 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{state.status === "error" && (
|
||||
<div className="p-5">
|
||||
<div className="rounded-lg border border-border-subtle bg-elevated p-4">
|
||||
<div className="text-sm font-medium text-text-primary mb-2">Source unavailable</div>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{state.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source note */}
|
||||
<div className="text-[11px] text-text-muted italic">
|
||||
Source code available locally at {node.filePath ?? "the project directory"}
|
||||
</div>
|
||||
{source && (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b border-border-subtle bg-surface text-[11px] text-text-muted flex items-center justify-between">
|
||||
<span>{source.lineCount} lines</span>
|
||||
<span>{formatBytes(source.sizeBytes)}</span>
|
||||
</div>
|
||||
<Highlight code={source.content} language={language} theme={themes.vsDark}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={`${className} min-w-max p-0 m-0 ${
|
||||
isModal ? "text-xs leading-5" : "text-[11px] leading-5"
|
||||
} font-mono`}
|
||||
style={{ ...style, background: "transparent" }}
|
||||
>
|
||||
{tokens.map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const isHighlighted =
|
||||
highlightedRange !== null &&
|
||||
lineNumber >= highlightedRange.start &&
|
||||
lineNumber <= highlightedRange.end;
|
||||
const lineProps = getLineProps({ line });
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={`${lineProps.className} flex ${
|
||||
isHighlighted ? "bg-accent/15" : "hover:bg-elevated/40"
|
||||
}`}
|
||||
>
|
||||
<span className="w-12 shrink-0 select-none border-r border-border-subtle pr-3 text-right text-text-muted bg-surface/60">
|
||||
{lineNumber}
|
||||
</span>
|
||||
<span className="pl-3 pr-6 whitespace-pre">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string, GraphNode>();
|
||||
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<string, FileEntry>([["", 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<string>;
|
||||
toggleFolder: (path: string) => void;
|
||||
openFile: (nodeId: string) => void;
|
||||
}) {
|
||||
const isExpanded = expanded.has(entry.path);
|
||||
const paddingLeft = 12 + depth * 14;
|
||||
|
||||
if (entry.type === "folder") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(entry.path)}
|
||||
className="w-full flex items-center gap-1.5 py-1.5 pr-3 text-left text-xs text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors"
|
||||
style={{ paddingLeft }}
|
||||
title={entry.path}
|
||||
>
|
||||
<span className="w-3 text-text-muted">{isExpanded ? "v" : ">"}</span>
|
||||
<span className="truncate font-medium">{entry.name}</span>
|
||||
</button>
|
||||
{isExpanded &&
|
||||
entry.children.map((child) => (
|
||||
<FileTreeRow
|
||||
key={child.path}
|
||||
entry={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
toggleFolder={toggleFolder}
|
||||
openFile={openFile}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onDoubleClick={() => entry.nodeId && openFile(entry.nodeId)}
|
||||
className="w-full flex items-center gap-1.5 py-1.5 pr-3 text-left text-xs text-text-secondary hover:text-accent hover:bg-accent/5 transition-colors"
|
||||
style={{ paddingLeft }}
|
||||
title={`${entry.path} - double-click to open`}
|
||||
>
|
||||
<span className="w-3 text-text-muted">-</span>
|
||||
<span className="truncate font-mono">{entry.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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<Set<string>>(() => 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 (
|
||||
<div className="h-full flex items-center justify-center p-5 text-sm text-text-muted">
|
||||
No graph loaded
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<div className="px-4 py-3 border-b border-border-subtle shrink-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-accent">
|
||||
Analyzed Files
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
{totalFiles} files from the current knowledge graph
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto py-2">
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-text-muted">No file paths found.</div>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<FileTreeRow
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
toggleFolder={toggleFolder}
|
||||
openFile={handleOpenFile}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</p>
|
||||
|
||||
{node.filePath && (
|
||||
<div className="text-xs text-text-secondary mb-2">
|
||||
<span className="font-medium text-text-muted">File:</span>{" "}
|
||||
{node.filePath}
|
||||
{node.lineRange && (
|
||||
<span className="ml-2">
|
||||
(L{node.lineRange[0]}-{node.lineRange[1]})
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-text-secondary mb-4 rounded-lg border border-border-subtle bg-elevated/60 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-text-muted mb-1">File</div>
|
||||
<div className="font-mono truncate" title={node.filePath}>
|
||||
{node.filePath}
|
||||
{node.lineRange && (
|
||||
<span className="ml-2 text-text-muted">
|
||||
L{node.lineRange[0]}-{node.lineRange[1]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCodeViewer(node.id)}
|
||||
className="shrink-0 text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1 rounded border border-accent/30 text-accent hover:text-accent-bright hover:border-accent/60 transition-colors"
|
||||
>
|
||||
Open code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<DashboardStore>()((set, get) => ({
|
||||
activeLayerId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
codeViewerExpanded: false,
|
||||
|
||||
tourActive: false,
|
||||
currentTourStep: 0,
|
||||
@@ -264,6 +268,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
codeViewerExpanded: false,
|
||||
nodeHistory: newHistory,
|
||||
});
|
||||
} else {
|
||||
@@ -316,6 +321,7 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
codeViewerExpanded: false,
|
||||
}),
|
||||
|
||||
navigateToOverview: () =>
|
||||
@@ -326,6 +332,7 @@ export const useDashboardStore = create<DashboardStore>()((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<DashboardStore>()((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<DashboardStore>()((set, get) => ({
|
||||
focusNodeId: null,
|
||||
codeViewerOpen: false,
|
||||
codeViewerNodeId: null,
|
||||
codeViewerExpanded: false,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
const allowed = new Set<string>();
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(graphFile, "utf-8")) as {
|
||||
nodes?: Array<Record<string, unknown>>;
|
||||
};
|
||||
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<string, string> = {
|
||||
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) => {
|
||||
|
||||
+25
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user