import { useEffect, useMemo, useState } from "react"; import { Highlight, themes } from "prism-react-renderer"; import { useDashboardStore } from "../store"; import { useI18n } from "../contexts/I18nContext"; 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 { t } = useI18n(); 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 (

{t.codeViewer.noFile}

); } const source = state.source; const language = source?.language ?? fallbackLanguage(node.filePath); const lineInfo = highlightedRange ? `${t.codeViewer.lines} ${highlightedRange.start}-${highlightedRange.end}` : t.codeViewer.fullFile; const isModal = presentation === "modal"; const handleClose = onClose ?? closeCodeViewer; return (
{language} {lineInfo}
{node.name}
{node.filePath && (
{node.filePath}
)}
{onExpand && ( )}
{state.status === "loading" && (
{t.codeViewer.loading}
)} {state.status === "error" && (
{t.codeViewer.sourceUnavailable}

{state.error}

)} {source && ( <>
{source.lineCount} {t.codeViewer.linesLabel} {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) => ( ))}
); })}
)}
)}
); }