/** * @file ChatFilePreview.tsx * @description A file a Chat pane's session touched, shown the way Claude * Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the * content) and `FileListModal` (a list on the left, the picked file's preview * on the right — Claude's Uploads dialog). Content comes only through routes * that already guard it: a file inside the workspace through * `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under * `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`, * an attached image or text file through `GET /api/run/drop/:name`, an image * the session read anywhere else through `GET /api/sessions/:id/image`, and a * text file it read, wrote or named (a generated report) anywhere else through * `GET /api/sessions/:id/file`. * Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout. * `SkillListModal` is the same dialog over the session's skills, each one's * SKILL.md found by name (`plugin:name` from the plugin list, else the user's * or the project's); a built-in skill has no file and shows by name only. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Copy, Download, FileText, Loader2, Maximize2, Minimize2, Share2, X, } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; import { useDialog } from "../../hooks/useDialog"; import { dragSkill } from "../../lib/promptAttach"; import { readMarkdownPreview } from "../../lib/markdownView"; import { MarkdownPreview } from "../MarkdownPreview"; import { CodeBlock } from "../conversation/CodeBlock"; import { useTheme } from "../../hooks/useTheme"; import { toast } from "../../lib/toast"; import type { TranscriptMessage } from "../../lib/types"; import { RENDERED, artifactDocument, artifactKind, artifactLang, artifactVersions, frameSandbox, parseCsv, type ArtifactKind, } from "../../lib/artifactView"; /** Where a Chat pane's files can be read from. */ export interface PreviewScope { laneId: number | null; /** The workspace's own folder. */ laneCwd: string; /** The pane's folder — the workspace's, or the session's own worktree. */ cwd: string; sessionId: string; } const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i; // An attached file the drop route serves as text (`DROP_TEXT_TYPES` in server/routes/run.js). const DROP_TEXT_RE = /\.(txt|md|markdown|csv|tsv|log|json|xml|ya?ml)$/i; // The drop dir (`/drops/`), or the older `/agenthub-drops-/`. const DROP_RE = /[\\/](?:agenthub-drops-\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/; /** A dropped text file shown at most this long. */ const DROP_TEXT_MAX = 200_000; /** `C:\a\b` → `C:/a/b`: a Windows path matched like a POSIX one. */ const slashed = (p: string) => p.replace(/\\/g, "/"); export const baseName = (p: string) => p.split(/[\\/]/).pop() || p; /** `report.v2.xlsx` → `XLSX`; none → "". */ export const extOf = (p: string) => { const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p)); return m ? m[1]!.toUpperCase() : ""; }; /** An attached file's name without the drop dir's random prefix. */ export const displayName = (p: string) => DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); type Content = | { kind: "loading" } | { kind: "image"; src: string } | { kind: "text"; text: string; truncated: boolean } | { kind: "none" }; function useContent(path: string, scope: PreviewScope): Content { const [content, setContent] = useState({ kind: "loading" }); useEffect(() => { let live = true; const show = (p: Promise) => { setContent({ kind: "loading" }); p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" })); }; const drop = DROP_RE.exec(path); if (drop) { const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`; if (IMAGE_RE.test(path)) setContent({ kind: "image", src }); else if (DROP_TEXT_RE.test(path)) show( fetch(src).then(async (r) => { if (!r.ok) throw new Error(String(r.status)); const text = await r.text(); return { kind: "text", text: text.slice(0, DROP_TEXT_MAX), truncated: text.length > DROP_TEXT_MAX, }; }) ); else setContent({ kind: "none" }); return () => { live = false; }; } const file = slashed(path); const base = slashed(scope.cwd).replace(/\/+$/, ""); if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) { // Claude Code's own files (memory, a skill): the config pages' guarded read. if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) { show( api.ccConfig .file(path) .then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated })) ); } else if (IMAGE_RE.test(file) && scope.sessionId) { // An image it read elsewhere (a screenshot under ~/.cache): served // only because this session's own Read named it. setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) }); } else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) { // A text file it read or wrote elsewhere (a report under ~/Documents): // served only because this session's own Read/Write/Edit named it. show( api.sessions .readFile(scope.sessionId, path) .then((f) => ({ kind: "text", text: f.text, truncated: f.truncated })) ); } else setContent({ kind: "none" }); return () => { live = false; }; } const rel = file.slice(base.length + 1); const target = base === slashed(scope.laneCwd).replace(/\/+$/, "") ? scope.laneId : gitTarget(scope.laneId, scope.sessionId); if (IMAGE_RE.test(path)) { setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); return; } show( api.lanes .gitFile(target, rel) .then((f) => f.available && !f.binary ? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated } : { kind: "none" } ) ); return () => { live = false; }; }, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]); return content; } /** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */ function CsvTable({ text, tab }: { text: string; tab: boolean }) { const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]); const [head, ...body] = rows; return (
{head && ( {head.map((h, i) => ( ))} )} {body.map((r, i) => ( {r.map((c, j) => ( ))} ))}
{h}
{c}
); } /** An HTML, SVG, Mermaid or React artifact, running in its own sandbox: * scripts (none for an SVG) but never the dashboard's origin. */ function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) { const { theme } = useTheme(); const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]); return (