feat: add doc preview panel for HTML and Markdown files
Click files in the file tree to open a dedicated preview panel with source/rendered toggle for .md/.mdx/.html/.htm files. Preview panel supports drag-to-resize (320–800px) with localStorage persistence. Bump version to 0.9.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
f335c7f821
commit
8c6e7bba5b
6 files changed
+356
-25
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codepilot",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -8,13 +8,25 @@ import { ChatListPanel } from "./ChatListPanel";
|
||||
import { RightPanel } from "./RightPanel";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { UpdateDialog } from "./UpdateDialog";
|
||||
import { PanelContext, type PanelContent } from "@/hooks/usePanel";
|
||||
import { DocPreview } from "./DocPreview";
|
||||
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
|
||||
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
|
||||
|
||||
const CHATLIST_MIN = 180;
|
||||
const CHATLIST_MAX = 400;
|
||||
const RIGHTPANEL_MIN = 200;
|
||||
const RIGHTPANEL_MAX = 480;
|
||||
const DOCPREVIEW_MIN = 320;
|
||||
const DOCPREVIEW_MAX = 800;
|
||||
|
||||
/** Extensions that default to "rendered" view mode */
|
||||
const RENDERED_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]);
|
||||
|
||||
function defaultViewMode(filePath: string): PreviewViewMode {
|
||||
const dot = filePath.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
|
||||
return RENDERED_EXTENSIONS.has(ext) ? "rendered" : "source";
|
||||
}
|
||||
|
||||
const LG_BREAKPOINT = 1024;
|
||||
const CHECK_INTERVAL = 8 * 60 * 60 * 1000; // 8 hours
|
||||
@@ -77,9 +89,37 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [streamingSessionId, setStreamingSessionId] = useState("");
|
||||
const [pendingApprovalSessionId, setPendingApprovalSessionId] = useState("");
|
||||
|
||||
// --- Doc Preview state ---
|
||||
const [previewFile, setPreviewFileRaw] = useState<string | null>(null);
|
||||
const [previewViewMode, setPreviewViewMode] = useState<PreviewViewMode>("source");
|
||||
const [docPreviewWidth, setDocPreviewWidth] = useState(() => {
|
||||
if (typeof window === "undefined") return 480;
|
||||
return parseInt(localStorage.getItem("codepilot_docpreview_width") || "480");
|
||||
});
|
||||
|
||||
const setPreviewFile = useCallback((path: string | null) => {
|
||||
setPreviewFileRaw(path);
|
||||
if (path) {
|
||||
setPreviewViewMode(defaultViewMode(path));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDocPreviewResize = useCallback((delta: number) => {
|
||||
setDocPreviewWidth((w) => Math.min(DOCPREVIEW_MAX, Math.max(DOCPREVIEW_MIN, w - delta)));
|
||||
}, []);
|
||||
const handleDocPreviewResizeEnd = useCallback(() => {
|
||||
setDocPreviewWidth((w) => {
|
||||
localStorage.setItem("codepilot_docpreview_width", String(w));
|
||||
return w;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-open panel on chat detail routes, close on others
|
||||
useEffect(() => {
|
||||
setPanelOpenRaw(isChatDetailRoute);
|
||||
if (!isChatDetailRoute) {
|
||||
setPreviewFileRaw(null);
|
||||
}
|
||||
}, [isChatDetailRoute]);
|
||||
|
||||
const setPanelOpen = useCallback((open: boolean) => {
|
||||
@@ -186,8 +226,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
setStreamingSessionId,
|
||||
pendingApprovalSessionId,
|
||||
setPendingApprovalSessionId,
|
||||
previewFile,
|
||||
setPreviewFile,
|
||||
previewViewMode,
|
||||
setPreviewViewMode,
|
||||
}),
|
||||
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId]
|
||||
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, previewFile, setPreviewFile, previewViewMode]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -213,6 +257,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
/>
|
||||
<main className="relative flex-1 overflow-hidden">{children}</main>
|
||||
</div>
|
||||
{isChatDetailRoute && previewFile && (
|
||||
<ResizeHandle side="right" onResize={handleDocPreviewResize} onResizeEnd={handleDocPreviewResizeEnd} />
|
||||
)}
|
||||
{isChatDetailRoute && previewFile && (
|
||||
<DocPreview
|
||||
filePath={previewFile}
|
||||
viewMode={previewViewMode}
|
||||
onViewModeChange={setPreviewViewMode}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
width={docPreviewWidth}
|
||||
/>
|
||||
)}
|
||||
{isChatDetailRoute && panelOpen && (
|
||||
<ResizeHandle side="right" onResize={handleRightPanelResize} onResizeEnd={handleRightPanelResizeEnd} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"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 handleCopyPath = async () => {
|
||||
await navigator.clipboard.writeText(filePath);
|
||||
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={handleCopyPath}>
|
||||
{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 path</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>
|
||||
);
|
||||
}
|
||||
@@ -12,15 +12,13 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanel } from "@/hooks/usePanel";
|
||||
import { FileTree } from "@/components/project/FileTree";
|
||||
import { FilePreview } from "@/components/project/FilePreview";
|
||||
|
||||
interface RightPanelProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function RightPanel({ width }: RightPanelProps) {
|
||||
const { panelOpen, setPanelOpen, workingDirectory, sessionId, sessionTitle, setSessionTitle } = usePanel();
|
||||
const [previewPath, setPreviewPath] = useState<string | null>(null);
|
||||
const { panelOpen, setPanelOpen, workingDirectory, sessionId, sessionTitle, setSessionTitle, previewFile, setPreviewFile } = usePanel();
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -68,6 +66,15 @@ export function RightPanel({ width }: RightPanelProps) {
|
||||
}
|
||||
}, [handleSaveName]);
|
||||
|
||||
const handleFileSelect = useCallback((path: string) => {
|
||||
// Toggle: clicking the same file closes the preview
|
||||
if (previewFile === path) {
|
||||
setPreviewFile(null);
|
||||
} else {
|
||||
setPreviewFile(path);
|
||||
}
|
||||
}, [previewFile, setPreviewFile]);
|
||||
|
||||
if (!panelOpen) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 bg-background p-2">
|
||||
@@ -88,14 +95,6 @@ export function RightPanel({ width }: RightPanelProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
setPreviewPath(path);
|
||||
};
|
||||
|
||||
const handleBackToTree = () => {
|
||||
setPreviewPath(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="hidden h-full shrink-0 flex-col overflow-hidden bg-background lg:flex" style={{ width: width ?? 288 }}>
|
||||
{/* Header */}
|
||||
@@ -153,18 +152,14 @@ export function RightPanel({ width }: RightPanelProps) {
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
{/* Files */}
|
||||
{/* Files — always show FileTree */}
|
||||
<div className="flex flex-col min-h-0">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70 mb-1.5 block">Files</span>
|
||||
<div className="overflow-hidden">
|
||||
{previewPath ? (
|
||||
<FilePreview filePath={previewPath} onBack={handleBackToTree} />
|
||||
) : (
|
||||
<FileTree
|
||||
workingDirectory={workingDirectory}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
)}
|
||||
<FileTree
|
||||
workingDirectory={workingDirectory}
|
||||
onFileSelect={handleFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createContext, useContext } from "react";
|
||||
|
||||
export type PanelContent = "files" | "tasks";
|
||||
|
||||
export type PreviewViewMode = "source" | "rendered";
|
||||
|
||||
export interface PanelContextValue {
|
||||
panelOpen: boolean;
|
||||
setPanelOpen: (open: boolean) => void;
|
||||
@@ -19,6 +21,10 @@ export interface PanelContextValue {
|
||||
setStreamingSessionId: (id: string) => void;
|
||||
pendingApprovalSessionId: string;
|
||||
setPendingApprovalSessionId: (id: string) => void;
|
||||
previewFile: string | null;
|
||||
setPreviewFile: (path: string | null) => void;
|
||||
previewViewMode: PreviewViewMode;
|
||||
setPreviewViewMode: (mode: PreviewViewMode) => void;
|
||||
}
|
||||
|
||||
export const PanelContext = createContext<PanelContextValue | null>(null);
|
||||
|
||||
Reference in new issue
Block a user