Files
hunjing/frontend/src/components/chat/FilePreviewPanel.tsx
T
2026-08-21 16:00:13 +08:00

365 lines
12 KiB
TypeScript

"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<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [mimeType, setMimeType] = useState("");
const [sourceText, setSourceText] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("preview");
const objectUrlRef = useRef<string | null>(null);
// 拖拽:使用 ref + 直接 DOM 操作避免 re-render 风暴;仅在 mouseup 时 commit 最终宽度
const [panelWidth, setPanelWidth] = useState(420);
const [isDragging, setIsDragging] = useState(false);
const panelRef = useRef<HTMLDivElement>(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 (
<div
className="relative flex shrink-0 will-change-[width]"
ref={panelRef}
style={{ width: panelWidth }}
>
{/* 拖拽手柄 */}
<div
className={cn(
"absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors",
isDragging ? "bg-patina/50" : "hover:bg-patina/30",
)}
onMouseDown={handleDragStart}
/>
<aside className="flex min-h-0 flex-1 flex-col overflow-hidden border-l border-rule bg-night-bronze/95 backdrop-blur-sm">
{/* —— 标签栏:文件列表 + 关闭 —— */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-rule px-3 py-2 has-[button:only-of-type]:py-1.5">
{previewFiles.map((f, i) => {
const tabExt = f.path.split(".").pop()?.toLowerCase() ?? "";
const tabName = f.fileName || f.path.split("/").pop() || f.path;
return (
<button
key={`${f.runId ?? ""}:${f.path}`}
type="button"
onClick={() => switchFile(i)}
className={cn(
"flex shrink-0 items-center gap-1 rounded px-2 py-1 text-[11px] font-mono transition-colors",
i === activeIndex
? "bg-patina/15 text-patina"
: "text-muted hover:bg-fg/5 hover:text-fg",
)}
>
<FileIcon ext={tabExt} />
<span className="max-w-[120px] truncate">{tabName}</span>
</button>
);
})}
<div className="ml-auto flex items-center">
<button
type="button"
onClick={closePanel}
className="flex h-6 w-6 items-center justify-center rounded text-muted transition-colors hover:bg-fg/5 hover:text-fg"
aria-label="关闭预览"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* —— 模式切换(当前激活文件) —— */}
{canRender && !loading && objectUrl && (
<div className="flex shrink-0 items-center justify-end gap-0.5 border-b border-rule px-4 py-1.5">
<button
type="button"
onClick={() => setViewMode("source")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "source"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
{"<"}源码{">"}
</button>
<button
type="button"
onClick={() => setViewMode("preview")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "preview"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
◉ 预览
</button>
</div>
)}
{/* —— 内容区 —— */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex items-center gap-2 text-xs text-muted/60">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
加载中…
</div>
</div>
)}
{error && (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="text-center text-xs text-cinnabar">{error}</div>
</div>
)}
{viewMode === "source" && !loading && sourceText && (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText}
</pre>
)}
{viewMode === "preview" && !loading && objectUrl && (
<>
{mimeType === "text/html" ? (
<iframe
src={objectUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : mimeType.startsWith("image/") || ext === "svg" ? (
<div className="flex min-h-0 flex-1 items-center justify-center bg-fg/[0.02] p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={objectUrl}
alt={displayName}
className="max-h-full max-w-full rounded object-contain shadow-sm"
/>
</div>
) : mimeType === "application/pdf" ? (
<iframe
src={objectUrl}
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : ext === "md" ||
ext === "markdown" ||
mimeType === "text/markdown" ? (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<MarkdownContent content={sourceText} />
</div>
) : (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText || "(不支持预览此文件类型)"}
</pre>
)}
</>
)}
{!loading && !error && !objectUrl && !sourceText && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<span className="text-xs text-muted/50">(空内容)</span>
</div>
)}
</div>
</aside>
</div>
);
}
function FileIcon({ ext }: { ext: string }) {
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
return (
<span className="shrink-0" aria-hidden>
{icon}
</span>
);
}
const EXT_ICONS: Record<string, string> = {
html: "🌐",
htm: "🌐",
svg: "🎨",
png: "🖼",
jpg: "🖼",
jpeg: "🖼",
gif: "🖼",
webp: "🖼",
pdf: "📄",
json: "📋",
yaml: "📋",
yml: "📋",
toml: "📋",
md: "📝",
markdown: "📝",
py: "🐍",
js: "📜",
ts: "📘",
tsx: "⚛️",
jsx: "⚛️",
css: "🎨",
scss: "🎨",
rs: "🦀",
go: "🔷",
sh: "💻",
bash: "💻",
txt: "📄",
csv: "📊",
xml: "📋",
_default: "📄",
};