Skip to content

Commit 8c6e7bb

Browse files
7418claude
andcommitted
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>
1 parent f335c7f commit 8c6e7bb

File tree

6 files changed

+356
-25
lines changed

6 files changed

+356
-25
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.8.0",
3+
"version": "0.9.0",
44
"private": true,
55
"author": {
66
"name": "op7418",

src/components/layout/AppShell.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@ import { ChatListPanel } from "./ChatListPanel";
88
import { RightPanel } from "./RightPanel";
99
import { ResizeHandle } from "./ResizeHandle";
1010
import { UpdateDialog } from "./UpdateDialog";
11-
import { PanelContext, type PanelContent } from "@/hooks/usePanel";
11+
import { DocPreview } from "./DocPreview";
12+
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
1213
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
1314

1415
const CHATLIST_MIN = 180;
1516
const CHATLIST_MAX = 400;
1617
const RIGHTPANEL_MIN = 200;
1718
const RIGHTPANEL_MAX = 480;
19+
const DOCPREVIEW_MIN = 320;
20+
const DOCPREVIEW_MAX = 800;
21+
22+
/** Extensions that default to "rendered" view mode */
23+
const RENDERED_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]);
24+
25+
function defaultViewMode(filePath: string): PreviewViewMode {
26+
const dot = filePath.lastIndexOf(".");
27+
const ext = dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
28+
return RENDERED_EXTENSIONS.has(ext) ? "rendered" : "source";
29+
}
1830

1931
const LG_BREAKPOINT = 1024;
2032
const CHECK_INTERVAL = 8 * 60 * 60 * 1000; // 8 hours
@@ -77,9 +89,37 @@ export function AppShell({ children }: { children: React.ReactNode }) {
7789
const [streamingSessionId, setStreamingSessionId] = useState("");
7890
const [pendingApprovalSessionId, setPendingApprovalSessionId] = useState("");
7991

92+
// --- Doc Preview state ---
93+
const [previewFile, setPreviewFileRaw] = useState<string | null>(null);
94+
const [previewViewMode, setPreviewViewMode] = useState<PreviewViewMode>("source");
95+
const [docPreviewWidth, setDocPreviewWidth] = useState(() => {
96+
if (typeof window === "undefined") return 480;
97+
return parseInt(localStorage.getItem("codepilot_docpreview_width") || "480");
98+
});
99+
100+
const setPreviewFile = useCallback((path: string | null) => {
101+
setPreviewFileRaw(path);
102+
if (path) {
103+
setPreviewViewMode(defaultViewMode(path));
104+
}
105+
}, []);
106+
107+
const handleDocPreviewResize = useCallback((delta: number) => {
108+
setDocPreviewWidth((w) => Math.min(DOCPREVIEW_MAX, Math.max(DOCPREVIEW_MIN, w - delta)));
109+
}, []);
110+
const handleDocPreviewResizeEnd = useCallback(() => {
111+
setDocPreviewWidth((w) => {
112+
localStorage.setItem("codepilot_docpreview_width", String(w));
113+
return w;
114+
});
115+
}, []);
116+
80117
// Auto-open panel on chat detail routes, close on others
81118
useEffect(() => {
82119
setPanelOpenRaw(isChatDetailRoute);
120+
if (!isChatDetailRoute) {
121+
setPreviewFileRaw(null);
122+
}
83123
}, [isChatDetailRoute]);
84124

85125
const setPanelOpen = useCallback((open: boolean) => {
@@ -186,8 +226,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
186226
setStreamingSessionId,
187227
pendingApprovalSessionId,
188228
setPendingApprovalSessionId,
229+
previewFile,
230+
setPreviewFile,
231+
previewViewMode,
232+
setPreviewViewMode,
189233
}),
190-
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId]
234+
[panelOpen, setPanelOpen, panelContent, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, previewFile, setPreviewFile, previewViewMode]
191235
);
192236

193237
return (
@@ -213,6 +257,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
213257
/>
214258
<main className="relative flex-1 overflow-hidden">{children}</main>
215259
</div>
260+
{isChatDetailRoute && previewFile && (
261+
<ResizeHandle side="right" onResize={handleDocPreviewResize} onResizeEnd={handleDocPreviewResizeEnd} />
262+
)}
263+
{isChatDetailRoute && previewFile && (
264+
<DocPreview
265+
filePath={previewFile}
266+
viewMode={previewViewMode}
267+
onViewModeChange={setPreviewViewMode}
268+
onClose={() => setPreviewFile(null)}
269+
width={docPreviewWidth}
270+
/>
271+
)}
216272
{isChatDetailRoute && panelOpen && (
217273
<ResizeHandle side="right" onResize={handleRightPanelResize} onResizeEnd={handleRightPanelResizeEnd} />
218274
)}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"use client";
2+
3+
import { useState, useEffect, useMemo } from "react";
4+
import { useTheme } from "next-themes";
5+
import { HugeiconsIcon } from "@hugeicons/react";
6+
import { Cancel01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
7+
import { Button } from "@/components/ui/button";
8+
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
9+
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
10+
import { atomOneLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
11+
import { Streamdown } from "streamdown";
12+
import { cjk } from "@streamdown/cjk";
13+
import { code } from "@streamdown/code";
14+
import { math } from "@streamdown/math";
15+
import { mermaid } from "@streamdown/mermaid";
16+
import type { FilePreview as FilePreviewType } from "@/types";
17+
18+
const streamdownPlugins = { cjk, code, math, mermaid };
19+
20+
type ViewMode = "source" | "rendered";
21+
22+
interface DocPreviewProps {
23+
filePath: string;
24+
viewMode: ViewMode;
25+
onViewModeChange: (mode: ViewMode) => void;
26+
onClose: () => void;
27+
width: number;
28+
}
29+
30+
/** Extensions that support a rendered preview */
31+
const RENDERABLE_EXTENSIONS = new Set([".md", ".mdx", ".html", ".htm"]);
32+
33+
function getExtension(filePath: string): string {
34+
const dot = filePath.lastIndexOf(".");
35+
return dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
36+
}
37+
38+
function isRenderable(filePath: string): boolean {
39+
return RENDERABLE_EXTENSIONS.has(getExtension(filePath));
40+
}
41+
42+
function isHtml(filePath: string): boolean {
43+
const ext = getExtension(filePath);
44+
return ext === ".html" || ext === ".htm";
45+
}
46+
47+
export function DocPreview({
48+
filePath,
49+
viewMode,
50+
onViewModeChange,
51+
onClose,
52+
width,
53+
}: DocPreviewProps) {
54+
const { resolvedTheme } = useTheme();
55+
const isDark = resolvedTheme === "dark";
56+
const [preview, setPreview] = useState<FilePreviewType | null>(null);
57+
const [loading, setLoading] = useState(true);
58+
const [error, setError] = useState<string | null>(null);
59+
const [copied, setCopied] = useState(false);
60+
61+
useEffect(() => {
62+
let cancelled = false;
63+
64+
async function loadPreview() {
65+
setLoading(true);
66+
setError(null);
67+
try {
68+
const res = await fetch(
69+
`/api/files/preview?path=${encodeURIComponent(filePath)}&maxLines=500`
70+
);
71+
if (!res.ok) {
72+
const data = await res.json();
73+
throw new Error(data.error || "Failed to load file");
74+
}
75+
const data = await res.json();
76+
if (!cancelled) {
77+
setPreview(data.preview);
78+
}
79+
} catch (err) {
80+
if (!cancelled) {
81+
setError(err instanceof Error ? err.message : "Failed to load file");
82+
}
83+
} finally {
84+
if (!cancelled) {
85+
setLoading(false);
86+
}
87+
}
88+
}
89+
90+
loadPreview();
91+
return () => {
92+
cancelled = true;
93+
};
94+
}, [filePath]);
95+
96+
const handleCopyPath = async () => {
97+
await navigator.clipboard.writeText(filePath);
98+
setCopied(true);
99+
setTimeout(() => setCopied(false), 2000);
100+
};
101+
102+
const fileName = filePath.split("/").pop() || filePath;
103+
104+
// Build breadcrumb — show last 3 segments
105+
const breadcrumb = useMemo(() => {
106+
const segments = filePath.split("/").filter(Boolean);
107+
const display = segments.slice(-3);
108+
const prefix = display.length < segments.length ? ".../" : "";
109+
return prefix + display.join("/");
110+
}, [filePath]);
111+
112+
const canRender = isRenderable(filePath);
113+
114+
return (
115+
<div
116+
className="hidden h-full shrink-0 flex-col overflow-hidden border-l border-border/40 bg-background lg:flex"
117+
style={{ width }}
118+
>
119+
{/* Header */}
120+
<div className="flex h-10 shrink-0 items-center gap-2 px-3">
121+
<div className="min-w-0 flex-1">
122+
<p className="truncate text-sm font-medium">{fileName}</p>
123+
</div>
124+
125+
{canRender && (
126+
<ViewModeToggle value={viewMode} onChange={onViewModeChange} />
127+
)}
128+
129+
<Button variant="ghost" size="icon-sm" onClick={handleCopyPath}>
130+
{copied ? (
131+
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5 text-green-500" />
132+
) : (
133+
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
134+
)}
135+
<span className="sr-only">Copy path</span>
136+
</Button>
137+
138+
<Button variant="ghost" size="icon-sm" onClick={onClose}>
139+
<HugeiconsIcon icon={Cancel01Icon} className="h-3.5 w-3.5" />
140+
<span className="sr-only">Close preview</span>
141+
</Button>
142+
</div>
143+
144+
{/* Breadcrumb + language — subtle, no border */}
145+
<div className="flex shrink-0 items-center gap-2 px-3 pb-2">
146+
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/60">
147+
{breadcrumb}
148+
</p>
149+
{preview && (
150+
<span className="shrink-0 text-[10px] text-muted-foreground/50">
151+
{preview.language}
152+
</span>
153+
)}
154+
</div>
155+
156+
{/* Content */}
157+
<div className="flex-1 min-h-0 overflow-auto">
158+
{loading ? (
159+
<div className="flex items-center justify-center py-12">
160+
<HugeiconsIcon
161+
icon={Loading02Icon}
162+
className="h-5 w-5 animate-spin text-muted-foreground"
163+
/>
164+
</div>
165+
) : error ? (
166+
<div className="px-4 py-8 text-center">
167+
<p className="text-sm text-destructive">{error}</p>
168+
</div>
169+
) : preview ? (
170+
viewMode === "rendered" && canRender ? (
171+
<RenderedView content={preview.content} filePath={filePath} />
172+
) : (
173+
<SourceView preview={preview} isDark={isDark} />
174+
)
175+
) : null}
176+
</div>
177+
</div>
178+
);
179+
}
180+
181+
/** Capsule toggle for Source / Preview view mode */
182+
function ViewModeToggle({
183+
value,
184+
onChange,
185+
}: {
186+
value: ViewMode;
187+
onChange: (v: ViewMode) => void;
188+
}) {
189+
return (
190+
<div className="flex h-6 items-center rounded-full bg-muted p-0.5 text-[11px]">
191+
<button
192+
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
193+
value === "source"
194+
? "bg-background text-foreground shadow-sm"
195+
: "text-muted-foreground hover:text-foreground"
196+
}`}
197+
onClick={() => onChange("source")}
198+
>
199+
Source
200+
</button>
201+
<button
202+
className={`rounded-full px-2 py-0.5 font-medium transition-colors ${
203+
value === "rendered"
204+
? "bg-background text-foreground shadow-sm"
205+
: "text-muted-foreground hover:text-foreground"
206+
}`}
207+
onClick={() => onChange("rendered")}
208+
>
209+
Preview
210+
</button>
211+
</div>
212+
);
213+
}
214+
215+
/** Source code view using react-syntax-highlighter */
216+
function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) {
217+
return (
218+
<div className="text-xs">
219+
<SyntaxHighlighter
220+
language={preview.language}
221+
style={isDark ? atomOneDark : atomOneLight}
222+
showLineNumbers
223+
customStyle={{
224+
margin: 0,
225+
padding: "8px",
226+
borderRadius: 0,
227+
fontSize: "11px",
228+
lineHeight: "1.5",
229+
background: "transparent",
230+
}}
231+
lineNumberStyle={{
232+
minWidth: "2.5em",
233+
paddingRight: "8px",
234+
color: isDark ? "#636d83" : "#9ca3af",
235+
userSelect: "none",
236+
}}
237+
>
238+
{preview.content}
239+
</SyntaxHighlighter>
240+
</div>
241+
);
242+
}
243+
244+
/** Rendered view for markdown / HTML files */
245+
function RenderedView({
246+
content,
247+
filePath,
248+
}: {
249+
content: string;
250+
filePath: string;
251+
}) {
252+
if (isHtml(filePath)) {
253+
return (
254+
<iframe
255+
srcDoc={content}
256+
sandbox=""
257+
className="h-full w-full border-0"
258+
title="HTML Preview"
259+
/>
260+
);
261+
}
262+
263+
// Markdown / MDX
264+
return (
265+
<div className="p-4 overflow-x-hidden break-words">
266+
<Streamdown
267+
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
268+
plugins={streamdownPlugins}
269+
>
270+
{content}
271+
</Streamdown>
272+
</div>
273+
);
274+
}

0 commit comments

Comments
 (0)