diff --git a/package-lock.json b/package-lock.json index 0eb062d..9f82601 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 37a9b45..0ab5592 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.8.0", + "version": "0.9.0", "private": true, "author": { "name": "op7418", diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 7d4d5e2..055ff86 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -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(null); + const [previewViewMode, setPreviewViewMode] = useState("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 }) { />
{children}
+ {isChatDetailRoute && previewFile && ( + + )} + {isChatDetailRoute && previewFile && ( + setPreviewFile(null)} + width={docPreviewWidth} + /> + )} {isChatDetailRoute && panelOpen && ( )} diff --git a/src/components/layout/DocPreview.tsx b/src/components/layout/DocPreview.tsx new file mode 100644 index 0000000..06cea04 --- /dev/null +++ b/src/components/layout/DocPreview.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+
+

{fileName}

+
+ + {canRender && ( + + )} + + + + +
+ + {/* Breadcrumb + language — subtle, no border */} +
+

+ {breadcrumb} +

+ {preview && ( + + {preview.language} + + )} +
+ + {/* Content */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : preview ? ( + viewMode === "rendered" && canRender ? ( + + ) : ( + + ) + ) : null} +
+
+ ); +} + +/** Capsule toggle for Source / Preview view mode */ +function ViewModeToggle({ + value, + onChange, +}: { + value: ViewMode; + onChange: (v: ViewMode) => void; +}) { + return ( +
+ + +
+ ); +} + +/** Source code view using react-syntax-highlighter */ +function SourceView({ preview, isDark }: { preview: FilePreviewType; isDark: boolean }) { + return ( +
+ + {preview.content} + +
+ ); +} + +/** Rendered view for markdown / HTML files */ +function RenderedView({ + content, + filePath, +}: { + content: string; + filePath: string; +}) { + if (isHtml(filePath)) { + return ( +