From 03df062bec95a479ea8d9579b00efbcf3f4e2739 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 00:02:11 +0000 Subject: [PATCH] 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 --- pnpm-lock.yaml | 24 ++- server/src/routes/chat-files.ts | 26 ++++ server/src/services/chat-files.ts | 9 ++ ui/package.json | 1 + ui/src/api/chat.ts | 5 + ui/src/components/ChatCodeFilePreview.tsx | 176 ++++++++++++++++++++++ ui/src/components/ChatFileCard.tsx | 31 +++- 7 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/ChatCodeFilePreview.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf5d7035..d4c93158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/src/routes/chat-files.ts b/server/src/routes/chat-files.ts index 7b51c591..7e72814d 100644 --- a/server/src/routes/chat-files.ts +++ b/server/src/routes/chat-files.ts @@ -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); diff --git a/server/src/services/chat-files.ts b/server/src/services/chat-files.ts index e9d674f7..1f8f2abc 100644 --- a/server/src/services/chat-files.ts +++ b/server/src/services/chat-files.ts @@ -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); + }, }; } diff --git a/ui/package.json b/ui/package.json index 575b40ce..4268bc30 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index a7a4db0b..fa2cdd95 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -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(`/files/${fileId}/promote`, { projectId }); + }, + async attachFilesToMessage(fileIds: string[], messageId: string) { await Promise.all( fileIds.map((fileId) => diff --git a/ui/src/components/ChatCodeFilePreview.tsx b/ui/src/components/ChatCodeFilePreview.tsx new file mode 100644 index 00000000..c9cb7321 --- /dev/null +++ b/ui/src/components/ChatCodeFilePreview.tsx @@ -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 = { + 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 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( + `${highlightedHtml}`, + "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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [copied, setCopied] = useState(false); + const codeRef = useRef(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
; + } + + if (error || content === null) { + return ; + } + + 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 ( +
+
+
+ {lang || "text"} + +
+
+
+            
+          
+
+
+ +
+ ); +} diff --git a/ui/src/components/ChatFileCard.tsx b/ui/src/components/ChatFileCard.tsx index 6fe241a3..c55b1204 100644 --- a/ui/src/components/ChatFileCard.tsx +++ b/ui/src/components/ChatFileCard.tsx @@ -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 (
+ {file.projectId === null && projectId && onPromoted && ( + + )} +