/** * @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) through `GET /api/cc-config/file`, an attached * image through `GET /api/run/drop/:name`. Anything else shows its path only. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Copy, FileText, Loader2, X } from "lucide-react"; import { api, gitTarget } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; import { useDialog } from "../../hooks/useDialog"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; /** 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; // 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]+)$/; 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(() => { const drop = DROP_RE.exec(path); if (drop) { setContent( IMAGE_RE.test(path) ? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` } : { kind: "none" } ); return; } let live = true; const show = (p: Promise) => { setContent({ kind: "loading" }); p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" })); }; const base = scope.cwd.replace(/\/+$/, ""); if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) { // Claude Code's own files (memory): the config pages' guarded read. if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) { show( api.ccConfig .file(path) .then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated })) ); } else setContent({ kind: "none" }); return () => { live = false; }; } const rel = path.slice(base.length + 1); const target = base === 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; } function Body({ path, scope }: { path: string; scope: PreviewScope }) { const { t } = useTranslation("run"); const content = useContent(path, scope); if (content.kind === "loading") { return (
); } if (content.kind === "image") { return (
{baseName(path)}
); } if (content.kind === "none") { return (

{t("chat.outline.noPreview")}

{path}

); } return (
{isMarkdown(path) ? ( ) : (
          {content.text}
        
)} {content.truncated && (

{t("chat.outline.truncated")}

)}
); } /** One file: header (name · EXT, Copy path, Close) over its content. */ export function FilePreview({ path, scope, onClose, className = "", }: { path: string; scope: PreviewScope; onClose: () => void; className?: string; }) { const { t } = useTranslation("run"); return (
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")} {extOf(path)}
); } /** Claude's Uploads dialog: the files on the left, the picked one on the right. */ export function FileListModal({ title, paths, scope, onClose, }: { title: string; paths: string[]; scope: PreviewScope; onClose: () => void; }) { const [picked, setPicked] = useState(paths[0] ?? ""); const panelRef = useRef(null); useDialog(true, panelRef, onClose); // Into document.body, like ImageLightbox: a pane's own stacking layer // would otherwise draw over a `fixed` overlay opened from inside it. return createPortal(
e.stopPropagation()} className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl" >

{title}

    {paths.map((p) => (
  • ))}
{picked && }
, document.body ); }