"use client"; import { useEffect, useState, useRef, useCallback, useMemo, type CSSProperties, type MouseEvent } from "react"; import { Prism as SyntaxHighlighter, createElement as renderSyntaxNode, type SyntaxHighlighterProps, } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import ReactMarkdown from "react-markdown"; import { useTheme } from "@/hooks/useTheme"; import { DOCX_PREVIEW_MAX_BYTES, getFileExt, isAudioPath, isDocumentPreviewPath, isImagePath, } from "@/lib/file-types"; import { encodeFilePathForApi, getFileDirectory, getFileName, getRelativeFilePath } from "@/lib/file-paths"; import { resolveLocalFileHref } from "@/lib/file-links"; import { parseFrontmatter } from "@/lib/frontmatter"; import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { CodeBlock, MermaidBlock } from "./MermaidBlock"; import { FrontmatterCard } from "./FrontmatterCard"; import { parseUnifiedPatch } from "@/lib/patch"; import type { GitFileDiffResponse } from "@/lib/git-types"; import { useI18n } from "@/hooks/useI18n"; import { resolveInitialFileDisplayMode, type FileViewerDisplayMode as DisplayMode, type FileViewerState, } from "@/lib/file-viewer-state"; export type { FileViewerState } from "@/lib/file-viewer-state"; interface Props { filePath: string; cwd?: string; sourceSessionId?: string | null; onOpenFile?: (filePath: string) => void; onMentionLines?: (relativePath: string, startLine: number, endLine: number) => void; /** Insert this file's relative path into the chat input (@ mention). */ onAtMention?: (relativePath: string, isDir: boolean) => void; gitRefreshKey?: number; initialDisplayMode?: DisplayMode; initialState?: FileViewerState; onStateChange?: (state: FileViewerState) => void; watchEnabled?: boolean; } interface FileData { content: string; language: string; size: number; } const DISPLAY_MODE_LABELS: Record = { source: "Source", preview: "Preview", diff: "Diff", }; const FILE_CODE_STYLE: CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 13, lineHeight: 1.6, }; const FILE_LINE_NUMBER_STYLE: CSSProperties = { width: 48, minWidth: 48, padding: "0 10px", textAlign: "right", color: "var(--text-dim)", background: "var(--bg-panel)", borderRight: "1px solid var(--border)", fontFamily: "var(--font-mono)", fontSize: 11, fontStyle: "normal", fontVariantNumeric: "tabular-nums", lineHeight: "20.8px", userSelect: "none", flexShrink: 0, verticalAlign: "top", }; type SourceCodeRendererProps = Parameters>[0] & { wrapLines: boolean; }; interface SelectedLineRange { startLine: number; endLine: number; } function MentionIcon() { return ( ); } function closestSourceLine(node: Node): HTMLElement | null { const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; return element?.closest(".file-source-line[data-line-number]") ?? null; } function getSelectedSourceLineRange(root: HTMLElement, selection: Selection | null): SelectedLineRange | null { if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null; let startElement = closestSourceLine(range.startContainer); let endElement = closestSourceLine(range.endContainer); if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) return null; let startLine = Number(startElement.dataset.lineNumber); let endLine = Number(endElement.dataset.lineNumber); if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) return null; if (startLine < endLine) { // Browser ranges can start at the end of the preceding line or end at the // start of the following line. Exclude either boundary line when none of // its source text is actually selected. const startContent = startElement.querySelector(".file-source-line-content"); if (startContent?.contains(range.startContainer)) { const selectedSuffix = document.createRange(); selectedSuffix.selectNodeContents(startContent); selectedSuffix.setStart(range.startContainer, range.startOffset); if (selectedSuffix.toString().length === 0) { const nextLine = startElement.nextElementSibling; if (nextLine instanceof HTMLElement && nextLine.matches(".file-source-line[data-line-number]")) { startElement = nextLine; startLine = Number(startElement.dataset.lineNumber); } } } const endContent = endElement.querySelector(".file-source-line-content"); if (endContent?.contains(range.endContainer)) { const selectedPrefix = document.createRange(); selectedPrefix.selectNodeContents(endContent); selectedPrefix.setEnd(range.endContainer, range.endOffset); if (selectedPrefix.toString().length === 0) { const previousLine = endElement.previousElementSibling; if (previousLine instanceof HTMLElement && previousLine.matches(".file-source-line[data-line-number]")) { endElement = previousLine; endLine = Number(endElement.dataset.lineNumber); } } } } if (startLine > endLine) return null; return { startLine, endLine }; } function SourceCodeRenderer({ rows, stylesheet, useInlineStyles, wrapLines }: SourceCodeRendererProps) { return rows.map((row, lineIndex) => { const children = row.children ?? []; const firstChildClasses = children[0]?.properties?.className; const hasLineNumber = Array.isArray(firstChildClasses) && firstChildClasses.includes("react-syntax-highlighter-line-number"); const lineNumberNode = hasLineNumber ? children[0] : null; const contentNodes = hasLineNumber ? children.slice(1) : children; return ( {lineNumberNode && renderSyntaxNode({ node: lineNumberNode, stylesheet, useInlineStyles, key: `source-line-number-${lineIndex}`, })} {contentNodes.map((node, tokenIndex) => renderSyntaxNode({ node, stylesheet, useInlineStyles, key: `source-token-${lineIndex}-${tokenIndex}`, }))} ); }); } function getFileApiUrl( filePath: string, type: "read" | "download" | "meta" | "preview" | "watch", sourceSessionId?: string | null, params: Record = {}, ): string { const encoded = encodeFilePathForApi(filePath); const searchParams = new URLSearchParams({ type }); if (sourceSessionId) searchParams.set("sessionId", sourceSessionId); for (const [key, value] of Object.entries(params)) { if (value !== undefined) searchParams.set(key, String(value)); } return `/api/files/${encoded}?${searchParams.toString()}`; } function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) { const { t } = useI18n(); return ( ); } type DiffLine = { type: "unchanged" | "removed" | "added"; text: string; oldLineNo: number | null; newLineNo: number | null; }; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function diffLines(patch: string): DiffLine[] { const files = parseUnifiedPatch(patch); if (!files) return []; return files.flatMap((file) => file.rows.flatMap((row): DiffLine[] => { if (row.type === "hunk") return []; if (row.left.type === "context" && row.right.type === "context") { return [{ type: "unchanged", text: row.right.text, oldLineNo: row.left.lineNo, newLineNo: row.right.lineNo, }]; } const lines: DiffLine[] = []; if (row.left.type === "removed") { lines.push({ type: "removed", text: row.left.text, oldLineNo: row.left.lineNo, newLineNo: null, }); } if (row.right.type === "added") { lines.push({ type: "added", text: row.right.text, oldLineNo: null, newLineNo: row.right.lineNo, }); } return lines; })); } function DiffView({ patch }: { patch: string }) { const { t } = useI18n(); const diff = diffLines(patch); const hasChanges = diff.some((l) => l.type !== "unchanged"); if (!hasChanges) { return (
{t("i18n.noChanges")}
); } // Render with context: show 3 lines around each change, collapse the rest const CONTEXT = 3; const changed = new Set(diff.flatMap((l, i) => (l.type !== "unchanged" ? [i] : []))); const visible = new Set(); for (const ci of changed) { for (let j = Math.max(0, ci - CONTEXT); j <= Math.min(diff.length - 1, ci + CONTEXT); j++) { visible.add(j); } } const segments: Array<{ hidden: true; count: number } | { hidden: false; lines: DiffLine[] }> = []; let i = 0; while (i < diff.length) { if (visible.has(i)) { const block: DiffLine[] = []; while (i < diff.length && visible.has(i)) { block.push(diff[i]); i++; } segments.push({ hidden: false, lines: block }); } else { let count = 0; while (i < diff.length && !visible.has(i)) { count++; i++; } segments.push({ hidden: true, count }); } } return (
{segments.map((seg, si) => { if (seg.hidden) { const result = (
... {seg.count} unchanged lines ...
); return result; } const lines = seg.lines.map((line, li) => { const bg = line.type === "added" ? "rgba(0,200,80,0.12)" : line.type === "removed" ? "rgba(240,60,60,0.14)" : "transparent"; const prefix = line.type === "added" ? "+" : line.type === "removed" ? "-" : " "; const prefixColor = line.type === "added" ? "#4ade80" : line.type === "removed" ? "#f87171" : "var(--text-dim)"; return (
{line.type === "removed" ? line.oldLineNo : line.newLineNo} {prefix} {line.text || "\u00a0"}
); }); return
{lines}
; })}
); } function ImageViewer({ filePath, cwd, sourceSessionId, watchEnabled = true }: Props) { const { t } = useI18n(); const [watching, setWatching] = useState(false); const [bust, setBust] = useState(0); const [size, setSize] = useState(null); const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); const [error, setError] = useState(null); const esRef = useRef(null); const syncRequestRef = useRef(0); const ext = getFileName(filePath).toLowerCase().split(".").pop() ?? ""; useEffect(() => { setBust(0); setSize(null); setNaturalSize(null); setError(null); setWatching(false); }, [filePath, sourceSessionId]); useEffect(() => { setWatching(false); if (esRef.current) { esRef.current.close(); esRef.current = null; } if (!watchEnabled) return; let active = true; const synchronize = () => { const requestId = ++syncRequestRef.current; fetch(getFileApiUrl(filePath, "meta", sourceSessionId)) .then((response) => response.json()) .then((next: { size?: number; error?: string }) => { if (!active || requestId !== syncRequestRef.current) return; if (next.error) { setError(next.error); return; } if (typeof next.size === "number") setSize(next.size); setNaturalSize(null); setError(null); setBust((value) => value + 1); }) .catch((nextError) => { if (active && requestId === syncRequestRef.current) setError(String(nextError)); }); }; const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => { setWatching(true); synchronize(); }); es.addEventListener("change", (e) => { syncRequestRef.current += 1; try { const d = JSON.parse((e as MessageEvent).data) as { size?: number }; if (typeof d.size === "number") setSize(d.size); } catch { /* ignore */ } setNaturalSize(null); setError(null); setBust((b) => b + 1); }); const markDisconnected = () => { setWatching(false); }; es.addEventListener("error", markDisconnected); es.onerror = markDisconnected; return () => { active = false; es.close(); if (esRef.current === es) esRef.current = null; }; }, [filePath, sourceSessionId, watchEnabled]); const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined); const formatSizeStr = size != null ? formatSize(size) : null; return (
{getRelativeFilePath(filePath, cwd)} {ext || "image"} {naturalSize && {naturalSize.w} × {naturalSize.h}} {formatSizeStr && {formatSizeStr}} {watching ? "live" : "static"}
{error ? (
{error}
) : ( // eslint-disable-next-line @next/next/no-img-element {filePath} { const img = e.currentTarget; setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }); }} onError={() => setError("Failed to load image")} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain", boxShadow: "0 2px 8px rgba(0,0,0,0.15)", }} /> )}
); } function formatDuration(seconds: number): string { if (!Number.isFinite(seconds)) return ""; const totalSeconds = Math.round(seconds); const mins = Math.floor(totalSeconds / 60); const secs = totalSeconds % 60; return `${mins}:${String(secs).padStart(2, "0")}`; } function AudioViewer({ filePath, cwd, sourceSessionId, watchEnabled = true }: Props) { const { t } = useI18n(); const [watching, setWatching] = useState(false); const [bust, setBust] = useState(0); const [size, setSize] = useState(null); const [duration, setDuration] = useState(null); const [error, setError] = useState(null); const esRef = useRef(null); const syncRequestRef = useRef(0); const ext = getFileName(filePath).toLowerCase().split(".").pop() ?? ""; useEffect(() => { setBust(0); setSize(null); setDuration(null); setError(null); setWatching(false); }, [filePath, sourceSessionId]); useEffect(() => { setWatching(false); if (esRef.current) { esRef.current.close(); esRef.current = null; } if (!watchEnabled) return; let active = true; const synchronize = () => { const requestId = ++syncRequestRef.current; fetch(getFileApiUrl(filePath, "meta", sourceSessionId)) .then((response) => response.json()) .then((next: { size?: number; error?: string }) => { if (!active || requestId !== syncRequestRef.current) return; if (next.error) { setError(next.error); return; } if (typeof next.size === "number") setSize(next.size); setDuration(null); setError(null); setBust((value) => value + 1); }) .catch((nextError) => { if (active && requestId === syncRequestRef.current) setError(String(nextError)); }); }; const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => { setWatching(true); synchronize(); }); es.addEventListener("change", (e) => { syncRequestRef.current += 1; try { const d = JSON.parse((e as MessageEvent).data) as { size?: number }; if (typeof d.size === "number") setSize(d.size); } catch { /* ignore */ } setDuration(null); setError(null); setBust((b) => b + 1); }); const markDisconnected = () => { setWatching(false); }; es.addEventListener("error", markDisconnected); es.onerror = markDisconnected; return () => { active = false; es.close(); if (esRef.current === es) esRef.current = null; }; }, [filePath, sourceSessionId, watchEnabled]); const src = getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined); return (
{getRelativeFilePath(filePath, cwd)} {ext || "audio"} {duration != null && {formatDuration(duration)}} {size != null && {formatSize(size)}} {watching ? "live" : "static"}
{error && (
{error}
)}
); } function DocumentViewer({ filePath, cwd, sourceSessionId, watchEnabled = true }: Props) { const { t } = useI18n(); const [watching, setWatching] = useState(false); const [bust, setBust] = useState(0); const [size, setSize] = useState(null); const [error, setError] = useState(null); const esRef = useRef(null); const syncRequestRef = useRef(0); const ext = getFileExt(filePath); const isPdf = ext === "pdf"; const previewUrl = isPdf ? getFileApiUrl(filePath, "read", sourceSessionId, bust ? { v: bust } : undefined) : getFileApiUrl(filePath, "preview", sourceSessionId, bust ? { v: bust } : undefined); useEffect(() => { setBust(0); setSize(null); setError(null); setWatching(false); let active = true; const requestId = ++syncRequestRef.current; fetch(getFileApiUrl(filePath, "meta", sourceSessionId)) .then((r) => r.json()) .then((d: { size?: number; error?: string }) => { if (!active || requestId !== syncRequestRef.current) return; if (d.error) setError(d.error); if (typeof d.size === "number") { setSize(d.size); if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) { setError("DOCX too large for preview (>10MB)"); } } }) .catch((nextError) => { if (active && requestId === syncRequestRef.current) setError(String(nextError)); }); return () => { active = false; }; }, [filePath, isPdf, sourceSessionId]); useEffect(() => { setWatching(false); if (esRef.current) { esRef.current.close(); esRef.current = null; } if (!watchEnabled) return; let active = true; const synchronize = () => { const requestId = ++syncRequestRef.current; fetch(getFileApiUrl(filePath, "meta", sourceSessionId)) .then((r) => r.json()) .then((d: { size?: number; error?: string }) => { if (!active || requestId !== syncRequestRef.current) return; if (d.error) { setError(d.error); return; } if (typeof d.size === "number") { setSize(d.size); if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) { setError("DOCX too large for preview (>10MB)"); return; } } setError(null); setBust((value) => value + 1); }) .catch((nextError) => { if (active && requestId === syncRequestRef.current) setError(String(nextError)); }); }; const es = new EventSource(getFileApiUrl(filePath, "watch", sourceSessionId)); esRef.current = es; es.addEventListener("connected", () => { setWatching(true); synchronize(); }); es.addEventListener("change", (e) => { syncRequestRef.current += 1; try { const d = JSON.parse((e as MessageEvent).data) as { size?: number }; if (typeof d.size === "number") { setSize(d.size); if (!isPdf && d.size > DOCX_PREVIEW_MAX_BYTES) { setError("DOCX too large for preview (>10MB)"); return; } } } catch { /* ignore */ } setError(null); setBust((b) => b + 1); }); const markDisconnected = () => { setWatching(false); }; es.addEventListener("error", markDisconnected); es.onerror = markDisconnected; return () => { active = false; es.close(); if (esRef.current === es) esRef.current = null; }; }, [filePath, isPdf, sourceSessionId, watchEnabled]); return (
{getRelativeFilePath(filePath, cwd)} {ext === "docx" ? "docx preview" : "pdf"} {size != null && {formatSize(size)}} {watching ? "live" : "static"}
{error ? (
{error}
) : (