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
This commit is contained in:
parent
e21c3088a0
commit
03df062bec
7 changed files with 269 additions and 3 deletions
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
|
|
@ -645,6 +645,9 @@ importers:
|
|||
'@tanstack/react-query':
|
||||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.23
|
||||
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -660,6 +663,9 @@ importers:
|
|||
hermes-paperclip-adapter:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.1
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
lexical:
|
||||
specifier: 0.35.0
|
||||
version: 0.35.0
|
||||
|
|
@ -3345,6 +3351,15 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.13.23':
|
||||
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.23':
|
||||
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -4820,7 +4835,6 @@ packages:
|
|||
|
||||
libsql@0.5.29:
|
||||
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
|
||||
cpu: [x64, arm64, wasm32, arm]
|
||||
os: [darwin, linux, win32]
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
|
|
@ -9330,6 +9344,14 @@ snapshots:
|
|||
'@tanstack/query-core': 5.90.20
|
||||
react: 19.2.4
|
||||
|
||||
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.23
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/virtual-core@3.13.23': {}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
|
|
|
|||
|
|
@ -179,6 +179,32 @@ export function chatFileRoutes(db: Db, storage: StorageService) {
|
|||
res.status(201).json(reference);
|
||||
});
|
||||
|
||||
// PATCH /files/:fileId/promote — Promote chat file to project scope
|
||||
router.patch("/files/:fileId/promote", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const fileId = req.params.fileId as string;
|
||||
const chatFile = await fileSvc.getById(fileId);
|
||||
if (!chatFile) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, chatFile.companyId);
|
||||
|
||||
const { projectId } = req.body ?? {};
|
||||
if (!projectId || typeof projectId !== "string") {
|
||||
res.status(400).json({ error: "projectId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await fileSvc.promoteToProject(fileId, projectId);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// PATCH /files/:fileId — Attach file to a message (set messageId)
|
||||
router.patch("/files/:fileId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
|
|
|||
|
|
@ -109,5 +109,14 @@ export function chatFileService(db: Db) {
|
|||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
},
|
||||
|
||||
promoteToProject(fileId: string, projectId: string) {
|
||||
return db
|
||||
.update(chatFiles)
|
||||
.set({ projectId, updatedAt: new Date() })
|
||||
.where(eq(chatFiles.id, fileId))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"cmdk": "^1.1.1",
|
||||
"diff": "^8.0.4",
|
||||
"hermes-paperclip-adapter": "^0.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lexical": "0.35.0",
|
||||
"lucide-react": "^0.574.0",
|
||||
"mermaid": "^11.12.0",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { api } from "./client";
|
|||
import type {
|
||||
ChatConversation,
|
||||
ChatConversationListResponse,
|
||||
ChatFile,
|
||||
ChatMessage,
|
||||
ChatMessageListResponse,
|
||||
ChatMessageSearchResponse,
|
||||
|
|
@ -213,6 +214,10 @@ export const chatApi = {
|
|||
return `/api/conversations/${conversationId}/export?format=${format}`;
|
||||
},
|
||||
|
||||
promoteFile(fileId: string, projectId: string) {
|
||||
return api.patch<ChatFile>(`/files/${fileId}/promote`, { projectId });
|
||||
},
|
||||
|
||||
async attachFilesToMessage(fileIds: string[], messageId: string) {
|
||||
await Promise.all(
|
||||
fileIds.map((fileId) =>
|
||||
|
|
|
|||
176
ui/src/components/ChatCodeFilePreview.tsx
Normal file
176
ui/src/components/ChatCodeFilePreview.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import { Download, File, FileCode, FileText, ImageIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download, File, FileCode, FileText, FolderUp, ImageIcon } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { ChatFile } from "@paperclipai/shared";
|
||||
import { chatApi } from "../api/chat";
|
||||
|
||||
interface ChatFileCardProps {
|
||||
file: ChatFile;
|
||||
contentPath: string;
|
||||
className?: string;
|
||||
projectId?: string | null;
|
||||
onPromoted?: (file: ChatFile) => void;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
|
|
@ -28,7 +32,9 @@ function FileIcon({ category }: { category: ChatFile["category"] }) {
|
|||
}
|
||||
}
|
||||
|
||||
export function ChatFileCard({ file, contentPath, className }: ChatFileCardProps) {
|
||||
export function ChatFileCard({ file, contentPath, className, projectId, onPromoted }: ChatFileCardProps) {
|
||||
const [promoting, setPromoting] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -50,6 +56,27 @@ export function ChatFileCard({ file, contentPath, className }: ChatFileCardProps
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{file.projectId === null && projectId && onPromoted && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setPromoting(true);
|
||||
try {
|
||||
const updated = await chatApi.promoteFile(file.id, projectId);
|
||||
onPromoted(updated);
|
||||
} finally {
|
||||
setPromoting(false);
|
||||
}
|
||||
}}
|
||||
disabled={promoting}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
aria-label="Promote to project"
|
||||
title="Promote to project"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={contentPath}
|
||||
download={file.originalFilename}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue