Files
CodePilot/src/components/layout/DocPreview.tsx
T
7418andClaude Opus 4.6 8747464c2f fix: doc preview bugs — close on chat switch, copy content, filter binary files
- Close doc preview when switching between chats (pathname change)
- Copy button now copies file content instead of file path
- Skip preview for images, videos, audio, archives, and other binary files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:26:29 +08:00

276 lines
7.9 KiB
TypeScript

"use client";
import { useState, useEffect, useMemo } from "react";
import { useTheme } from "next-themes";
import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { FilePreview as FilePreviewType } from "@/types";
const streamdownPlugins = { cjk, code, math, mermaid };
type ViewMode = "source" | "rendered";
interface DocPreviewProps {
filePath: string;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onClose: () => void;
width: number;
}
/** Extensions that support a rendered preview */
const RENDERABLE_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]);
function getExtension(filePath: string): string {
const dot = filePath.lastIndexOf(".");
return dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
}
function isRenderable(filePath: string): boolean {
return RENDERABLE_EXTENSIONS.has(getExtension(filePath));
}
function isHtml(filePath: string): boolean {
const ext = getExtension(filePath);
return ext === ".html" || ext === ".htm";
}
export function DocPreview({
filePath,
viewMode,
onViewModeChange,
onClose,
width,
}: DocPreviewProps) {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadPreview() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/files/preview?path=${encodeURIComponent(filePath)}&maxLines=500`
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to load file");
}
const data = await res.json();
if (!cancelled) {
setPreview(data.preview);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadPreview();
return () => {
cancelled = true;
};
}, [filePath]);
const handleCopyContent = async () => {
const text = preview?.content || filePath;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const fileName = filePath.split("/").pop() || filePath;
// Build breadcrumb — show last 3 segments
const breadcrumb = useMemo(() => {
const segments = filePath.split("/").filter(Boolean);
const display = segments.slice(-3);
const prefix = display.length < segments.length ? ".../" : "";
return prefix + display.join("/");
}, [filePath]);
const canRender = isRenderable(filePath);
return (
<div
className="hidden h-full shrink-0 flex-col overflow-hidden border-l border-border/40 bg-background lg:flex"
style={{ width }}
>
{/* Header */}
<div className="flex h-10 shrink-0 items-center gap-2 px-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{fileName}</p>
</div>
{canRender && (
<ViewModeToggle value={viewMode} onChange={onViewModeChange} />
)}
<Button variant="ghost" size="icon-sm" onClick={handleCopyContent}>
{copied ? (
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5 text-green-500" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
)}
<span className="sr-only">Copy content</span>
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose}>
<HugeiconsIcon icon={Cancel01Icon} className="h-3.5 w-3.5" />
<span className="sr-only">Close preview</span>
</Button>
</div>
{/* Breadcrumb + language — subtle, no border */}
<div className="flex shrink-0 items-center gap-2 px-3 pb-2">
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/60">
{breadcrumb}
</p>
{preview && (
<span className="shrink-0 text-[10px] text-muted-foreground/50">
{preview.language}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon
icon={Loading02Icon}
className="h-5 w-5 animate-spin text-muted-foreground"
/>
</div>
) : error ? (
<div className="px-4 py-8 text-center">
<p className="text-sm text-destructive">{error}</p>
</div>
) : preview ? (
viewMode === "rendered" && canRender ? (
<RenderedView content={preview.content} filePath={filePath} />
) : (
<SourceView preview={preview} isDark={isDark} />
)
) : null}
</div>
</div>
);
}
/** Capsule toggle for Source / Preview view mode */
function ViewModeToggle({
value,
onChange,
}: {
value: ViewMode;
onChange: (v: ViewMode) => void;
}) {
return (
<div className="flex h-6 items-center rounded-full bg-muted p-0.5 text-[11px]">
<button
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
value === "source"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onChange("source")}
>
Source
</button>
<button
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
value === "rendered"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onChange("rendered")}
>
Preview
</button>
</div>
);
}
/** Source code view using react-syntax-highlighter */
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
return (
<div className="text-xs">
<SyntaxHighlighter
language={preview.language}
style={isDark ? atomOneDark : atomOneLight}
showLineNumbers
customStyle={{
margin: 0,
padding: "8px",
borderRadius: 0,
fontSize: "11px",
lineHeight: "1.5",
background: "transparent",
}}
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: isDark ? "#636d83" : "#9ca3af",
userSelect: "none",
}}
>
{preview.content}
</SyntaxHighlighter>
</div>
);
}
/** Rendered view for markdown / HTML files */
function RenderedView({
content,
filePath,
}: {
content: string;
filePath: string;
}) {
if (isHtml(filePath)) {
return (
<iframe
srcDoc={content}
sandbox=""
className="h-full w-full border-0"
title="HTML Preview"
/>
);
}
// Markdown / MDX
return (
<div className="p-4 overflow-x-hidden break-words">
<Streamdown
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
plugins={streamdownPlugins}
>
{content}
</Streamdown>
</div>
);
}