365 lines
12 KiB
TypeScript
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: "📄",
|
|
};
|