nexus/ui/src/components/ChatCodeFilePreview.tsx
Nexus Dev 03df062bec feat(25-04): create ChatCodeFilePreview with syntax highlighting
- Add ChatCodeFilePreview component with hljs syntax highlighting
- Fetch file content from contentPath with credentials
- Use DOMParser-based safe rendering (no dangerouslySetInnerHTML)
- Include copy button, language label, and ChatFileCard download below
- Add extToLang extension-to-language mapping
- Register 14 common languages with hljs
- Add highlight.js as direct dependency in ui/package.json
2026-04-04 03:55:48 +00:00

176 lines
6 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import { Copy, Check } from "lucide-react";
import hljs from "highlight.js/lib/core";
import typescript from "highlight.js/lib/languages/typescript";
import javascript from "highlight.js/lib/languages/javascript";
import python from "highlight.js/lib/languages/python";
import css from "highlight.js/lib/languages/css";
import json from "highlight.js/lib/languages/json";
import xml from "highlight.js/lib/languages/xml";
import bash from "highlight.js/lib/languages/bash";
import sql from "highlight.js/lib/languages/sql";
import go from "highlight.js/lib/languages/go";
import rust from "highlight.js/lib/languages/rust";
import java from "highlight.js/lib/languages/java";
import cpp from "highlight.js/lib/languages/cpp";
import markdownLang from "highlight.js/lib/languages/markdown";
import yaml from "highlight.js/lib/languages/yaml";
import { Button } from "./ui/button";
import { ChatFileCard } from "./ChatFileCard";
import type { ChatFile } from "@paperclipai/shared";
// Register languages with hljs
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("css", css);
hljs.registerLanguage("json", json);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("go", go);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("java", java);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("markdown", markdownLang);
hljs.registerLanguage("yaml", yaml);
function extToLang(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
css: "css", html: "xml", json: "json", sh: "bash", bash: "bash",
yaml: "yaml", yml: "yaml", toml: "ini", md: "markdown",
c: "c", cpp: "cpp", cs: "csharp", kt: "kotlin", swift: "swift",
php: "php", sql: "sql", xml: "xml",
};
return map[ext] ?? ext;
}
/**
* Safely renders hljs-highlighted HTML into a DOM node.
* hljs.highlight() produces only <span class="hljs-*"> tokens from source code.
* We parse through DOMParser (sandboxed document) and transfer child nodes
* rather than assigning raw HTML strings, so no script execution is possible.
*/
function applyHighlightedHtml(el: HTMLElement, highlightedHtml: string): void {
const parser = new DOMParser();
const doc = parser.parseFromString(
`<code>${highlightedHtml}</code>`,
"text/html"
);
const sourceCode = doc.body.firstChild;
if (!sourceCode) return;
// Transfer parsed child nodes into the target element
el.replaceChildren(...Array.from(sourceCode.childNodes));
}
interface ChatCodeFilePreviewProps {
file: ChatFile;
contentPath: string;
}
export function ChatCodeFilePreview({ file, contentPath }: ChatCodeFilePreviewProps) {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLElement>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(false);
setContent(null);
fetch(contentPath, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((text) => {
if (cancelled) return;
const capped =
text.length > 50000 ? text.slice(0, 50000) + "\n// ... truncated" : text;
setContent(capped);
setLoading(false);
})
.catch(() => {
if (cancelled) return;
setError(true);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [contentPath]);
// Apply highlighting whenever content or code element changes
useEffect(() => {
if (!codeRef.current || content === null) return;
const lang = extToLang(file.originalFilename);
let highlighted: string;
try {
const registeredLangs = hljs.listLanguages();
if (registeredLangs.includes(lang)) {
highlighted = hljs.highlight(content, { language: lang }).value;
} else {
highlighted = hljs.highlightAuto(content).value;
}
} catch {
highlighted = hljs.highlightAuto(content).value;
}
applyHighlightedHtml(codeRef.current, highlighted);
}, [content, file.originalFilename]);
if (loading) {
return <div className="rounded-lg border border-border bg-muted animate-pulse h-[120px]" />;
}
if (error || content === null) {
return <ChatFileCard file={file} contentPath={contentPath} />;
}
const lang = extToLang(file.originalFilename);
function handleCopy() {
navigator.clipboard.writeText(content ?? "").catch(() => {
// Ignore clipboard errors in restricted environments
});
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
return (
<div className="flex flex-col gap-1">
<div className="paperclip-markdown rounded-lg border border-border overflow-hidden">
<div className="flex items-center justify-between bg-card border-b border-border px-3 py-1">
<span className="text-xs text-muted-foreground font-mono">{lang || "text"}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={copied ? "Copied!" : "Copy code"}
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="max-h-[400px] overflow-auto">
<pre className="m-0 p-4">
<code ref={codeRef} />
</pre>
</div>
</div>
<ChatFileCard file={file} contentPath={contentPath} />
</div>
);
}