"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { readSandboxFile } from "@/lib/api/sandbox"; import { useChatStore } from "@/lib/stores/chat-store"; import { cn } from "@/lib/utils/cn"; import { MarkdownContent } from "@/components/chat/MarkdownContent"; type ViewMode = "source" | "preview"; const MIN_PANEL_WIDTH = 280; const MAX_PANEL_FRACTION = 0.6; /** * 右侧独立文件预览面板,支持多文件标签页切换。 */ export function FilePreviewPanel() { const previewFiles = useChatStore((s) => s.previewFiles); const activeIndex = useChatStore((s) => s.activePreviewIndex); const switchFile = useChatStore((s) => s.switchPreviewFile); const closePanel = useChatStore((s) => s.closePreviewPanel); const characterId = useChatStore((s) => s.characterId); const file = previewFiles[activeIndex] ?? null; const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [objectUrl, setObjectUrl] = useState(null); const [mimeType, setMimeType] = useState(""); const [sourceText, setSourceText] = useState(""); const [viewMode, setViewMode] = useState("preview"); const objectUrlRef = useRef(null); // 拖拽:使用 ref + 直接 DOM 操作避免 re-render 风暴;仅在 mouseup 时 commit 最终宽度 const [panelWidth, setPanelWidth] = useState(420); const [isDragging, setIsDragging] = useState(false); const panelRef = useRef(null); const dragStartXRef = useRef(0); const dragStartWidthRef = useRef(0); const handleDragStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); dragStartXRef.current = e.clientX; dragStartWidthRef.current = panelWidth; setIsDragging(true); }, [panelWidth], ); useEffect(() => { if (!isDragging) return; const el = panelRef.current; if (!el) return; const handleMouseMove = (e: MouseEvent) => { const delta = dragStartXRef.current - e.clientX; const newWidth = dragStartWidthRef.current + delta; const clamped = Math.max( MIN_PANEL_WIDTH, Math.min(newWidth, window.innerWidth * MAX_PANEL_FRACTION), ); // 直接操作 DOM 跳过 React 渲染周期 el.style.width = `${clamped}px`; }; const handleMouseUp = () => { // 在 mouseup 时才将最终宽度同步到 state(供下次拖拽起始宽度使用) if (el) { const finalWidth = parseFloat(el.style.width) || panelWidth; setPanelWidth(finalWidth); } setIsDragging(false); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.body.style.userSelect = ""; document.body.style.cursor = ""; }; }, [isDragging, panelWidth]); // 当前文件详情 const displayName = file?.fileName || file?.path.split("/").pop() || ""; const ext = file?.path.split(".").pop()?.toLowerCase() ?? ""; const canRender = mimeType === "text/html" || mimeType.startsWith("image/") || mimeType === "application/pdf" || mimeType === "text/markdown" || ext === "svg" || ext === "md" || ext === "markdown"; // 切换文件时重新加载 useEffect(() => { if (!file) return; let cancelled = false; (async () => { try { setLoading(true); setError(null); setMimeType(""); setSourceText(""); setViewMode("preview"); if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); objectUrlRef.current = null; } setObjectUrl(null); const blob = await readSandboxFile(file.path, { runId: file.runId, characterId: characterId ?? undefined, isolated: file.isolated ?? true, }); if (cancelled) return; const mt = blob.type || "text/plain"; setMimeType(mt); const url = URL.createObjectURL(blob); objectUrlRef.current = url; setObjectUrl(url); if (mt.startsWith("text/") || !mt || ext === "svg") { const text = await blob.text(); if (!cancelled) setSourceText(text); } } catch (err) { if (!cancelled) { setError(err instanceof Error ? err.message : "读取文件失败"); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; if (objectUrlRef.current) { URL.revokeObjectURL(objectUrlRef.current); objectUrlRef.current = null; } }; }, [file, characterId]); // eslint-disable-line react-hooks/exhaustive-deps // 卸载清理 useEffect(() => { return () => { if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); }; }, []); if (!file) return null; return (
{/* 拖拽手柄 */}