"use client"; import { Loader2, Save, X } from "lucide-react"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useMemo, useState } from "react"; import { CodeEditor } from "@/components/sf/code-editor"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useEditorFontSize } from "@/lib/use-editor-font-size"; import { cn } from "@/lib/utils"; /* ── Language detection ── */ const EXT_TO_LANG: Record = { ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript", json: "json", jsonc: "jsonc", md: "markdown", mdx: "mdx", css: "css", scss: "scss", less: "less", html: "html", htm: "html", xml: "xml", svg: "xml", yaml: "yaml", yml: "yaml", toml: "toml", sh: "bash", bash: "bash", zsh: "bash", fish: "fish", py: "python", rb: "ruby", rs: "rust", go: "go", java: "java", kt: "kotlin", swift: "swift", c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp", php: "php", sql: "sql", graphql: "graphql", gql: "graphql", dockerfile: "dockerfile", makefile: "makefile", lua: "lua", vim: "viml", r: "r", tex: "latex", diff: "diff", ini: "ini", conf: "ini", env: "dotenv", }; const SPECIAL_FILENAMES: Record = { Dockerfile: "dockerfile", Makefile: "makefile", Containerfile: "dockerfile", Justfile: "makefile", Rakefile: "ruby", Gemfile: "ruby", ".env": "dotenv", ".env.local": "dotenv", ".env.example": "dotenv", ".eslintrc": "json", ".prettierrc": "json", "tsconfig.json": "jsonc", "jsconfig.json": "jsonc", }; function detectLanguage(filepath: string): string | null { const filename = filepath.split("/").pop() ?? ""; // Check special filenames first if (SPECIAL_FILENAMES[filename]) return SPECIAL_FILENAMES[filename]; const ext = filename.includes(".") ? filename.split(".").pop()?.toLowerCase() : null; if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext]; return null; } function isMarkdown(filepath: string): boolean { const ext = filepath.split(".").pop()?.toLowerCase(); return ext === "md" || ext === "mdx"; } /* ── Shiki singleton ── */ type ShikiHighlighter = { codeToTokens: ( code: string, options: { lang: string; theme: string }, ) => { tokens: Array< Array<{ color?: string; content: string; fontStyle?: number; offset?: number; }> >; bg?: string; fg?: string; }; }; let highlighterPromise: Promise | null = null; async function getHighlighter(): Promise { if (!highlighterPromise) { highlighterPromise = import("shiki") .then((mod) => mod.createHighlighter({ themes: ["github-dark-default", "github-light-default"], langs: [ "typescript", "tsx", "javascript", "jsx", "json", "jsonc", "markdown", "mdx", "css", "scss", "less", "html", "xml", "yaml", "toml", "bash", "python", "ruby", "rust", "go", "java", "kotlin", "swift", "c", "cpp", "csharp", "php", "sql", "graphql", "dockerfile", "makefile", "lua", "diff", "ini", "dotenv", ], }) as Promise, ) .catch((err) => { // Reset so the next call retries instead of returning a rejected promise forever highlighterPromise = null; throw err; }) as Promise; } return highlighterPromise!; } function HighlightedCode({ code, highlighter, lang, theme, className, }: { code: string; highlighter: ShikiHighlighter; lang: string; theme: string; className?: string; }) { const highlighted = highlighter.codeToTokens(code, { lang, theme }); return (
			
				{highlighted.tokens.map((line, lineNumber) => (
					 token.content).join("")}`}
					>
						{line.map((token, tokenIndex) => (
							
								{token.content}
							
						))}
						{lineNumber < highlighted.tokens.length - 1 ? "\n" : null}
					
				))}
			
		
); } /* ── Code viewer (syntax highlighted) ── */ function CodeViewer({ content, filepath, shikiTheme = "github-dark-default", }: { content: string; filepath: string; shikiTheme?: string; }) { const [highlighted, setHighlighted] = useState(null); const [ready, setReady] = useState(false); const lang = detectLanguage(filepath); useEffect(() => { let cancelled = false; if (!lang) { const readyTimer = window.setTimeout(() => { setReady(true); }, 0); return () => window.clearTimeout(readyTimer); } getHighlighter() .then((highlighter) => { if (cancelled) return; try { setHighlighted( , ); } catch { // Language not loaded or unsupported — fall back to plain setHighlighted(null); } setReady(true); }) .catch(() => { if (!cancelled) setReady(true); }); return () => { cancelled = true; }; }, [content, lang, shikiTheme]); if (!ready) { return (
Highlighting…
); } if (highlighted) { return highlighted; } // Fallback: plain text with line numbers return ; } /* ── Plain text viewer with line numbers ── */ function PlainViewer({ content }: { content: string }) { const lines = useMemo(() => content.split("\n"), [content]); const gutterWidth = String(lines.length).length; return (
{lines.map((line, i) => ( ))}
{i + 1} {line || " "}
); } /* ── Markdown viewer ── */ function MarkdownViewer({ content, shikiTheme = "github-dark-default", }: { content: string; filepath: string; shikiTheme?: string; }) { const [rendered, setRendered] = useState(null); const [ready, setReady] = useState(false); useEffect(() => { let cancelled = false; // Dynamic import to keep the main bundle lean Promise.all([ import("react-markdown"), import("remark-gfm"), getHighlighter(), ]) .then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => { if (cancelled) return; const ReactMarkdown = ReactMarkdownMod.default; const remarkGfm = remarkGfmMod.default; setRendered( ); } catch { // Fall through to default rendering } } // Inline code or unknown language const isInline = !className && !String(children).includes("\n"); if (isInline) { return ( {children} ); } return (
										{children}
									
); }, pre({ children }) { // Unwrap
 since code blocks handle their own wrapper
								return <>{children};
							},
							table({ children }) {
								return (
									
{children}
); }, th({ children }) { return ( {children} ); }, td({ children }) { return ( {children} ); }, a({ href, children }) { return ( {children} ); }, img({ src, alt }) { return ( 🖼 {alt || (typeof src === "string" ? src : "") || "image"} ); }, }} > {content} , ); setReady(true); }) .catch(() => { if (!cancelled) setReady(true); }); return () => { cancelled = true; }; }, [content, shikiTheme]); if (!ready) { return (
Rendering…
); } if (!rendered) { return ; } return
{rendered}
; } /* ── Inline diff viewer — shows before/after with red/green line highlights ── */ function computeDiffLines( before: string, after: string, ): Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string; }> { const oldLines = before.split("\n"); const newLines = after.split("\n"); const result: Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string; }> = []; // Simple LCS-based diff for inline display const n = oldLines.length; const m = newLines.length; // For files that are too large, fall back to showing just additions/removals if (n + m > 5000) { oldLines.forEach((l, i) => result.push({ type: "remove", lineNum: i + 1, text: l }), ); newLines.forEach((l, i) => result.push({ type: "add", lineNum: i + 1, text: l }), ); return result; } // Build edit script using O(ND) algorithm (simplified Myers) const max = n + m; const v = new Int32Array(2 * max + 1); const trace: Int32Array[] = []; outer: for (let d = 0; d <= max; d++) { const vCopy = new Int32Array(v); trace.push(vCopy); for (let k = -d; k <= d; k += 2) { let x: number; if (k === -d || (k !== d && v[k - 1 + max] < v[k + 1 + max])) { x = v[k + 1 + max]; } else { x = v[k - 1 + max] + 1; } let y = x - k; while (x < n && y < m && oldLines[x] === newLines[y]) { x++; y++; } v[k + max] = x; if (x >= n && y >= m) break outer; } } // Backtrack to produce diff type Edit = { type: "add" | "remove" | "context"; oldIdx: number; newIdx: number; }; const edits: Edit[] = []; let x = n, y = m; for (let d = trace.length - 1; d >= 0; d--) { const vPrev = trace[d]; const k = x - y; let prevK: number; if (k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max])) { prevK = k + 1; } else { prevK = k - 1; } const prevX = vPrev[prevK + max]; const prevY = prevX - prevK; // Diag moves = context lines while (x > prevX && y > prevY) { x--; y--; edits.push({ type: "context", oldIdx: x, newIdx: y }); } if (d > 0) { if (x === prevX) { // Insert y--; edits.push({ type: "add", oldIdx: x, newIdx: y }); } else { // Delete x--; edits.push({ type: "remove", oldIdx: x, newIdx: y }); } } } edits.reverse(); // Convert to output lines, showing only changed regions with ±3 lines of context const CONTEXT = 3; const important = new Set(); edits.forEach((e, i) => { if (e.type !== "context") { for ( let j = Math.max(0, i - CONTEXT); j <= Math.min(edits.length - 1, i + CONTEXT); j++ ) { important.add(j); } } }); let lastIncluded = -1; for (let i = 0; i < edits.length; i++) { if (!important.has(i)) continue; if (lastIncluded >= 0 && i - lastIncluded > 1) { result.push({ type: "context", lineNum: null, text: "···" }); } const e = edits[i]; if (e.type === "context") { result.push({ type: "context", lineNum: e.newIdx + 1, text: newLines[e.newIdx], }); } else if (e.type === "remove") { result.push({ type: "remove", lineNum: e.oldIdx + 1, text: oldLines[e.oldIdx], }); } else { result.push({ type: "add", lineNum: e.newIdx + 1, text: newLines[e.newIdx], }); } lastIncluded = i; } return result; } function InlineDiffViewer({ before, after, }: { before: string; after: string; onDismiss?: () => void; }) { const lines = useMemo(() => computeDiffLines(before, after), [before, after]); return (
{lines.map((line, i) => ( ))}
{line.type === "add" ? ( + ) : line.type === "remove" ? ( ) : null} {line.lineNum ?? ""} {line.text || " "}
); } /* ── Read-only content renderer (shared between standalone and tab modes) ── */ function ReadOnlyContent({ content, filepath, fontSize, shikiTheme, }: { content: string; filepath: string; fontSize?: number; shikiTheme?: string; }) { return (
{isMarkdown(filepath) ? ( ) : ( )}
); } /* ── Exported component ── */ interface FileContentViewerProps { content: string; filepath: string; className?: string; /** Required for editing — the root context for the file */ root?: "sf" | "project"; /** Required for editing — the relative path within the root */ path?: string; /** Required for editing — called with new content when the user saves */ onSave?: (newContent: string) => Promise; /** When set, shows an inline diff overlay (before/after content) */ diff?: { before: string; after: string }; /** Called to dismiss the diff overlay */ onDismissDiff?: () => void; /** When true, MD files default to Edit tab so the raw changes are visible */ agentOpened?: boolean; } export function FileContentViewer({ content, filepath, className, root, path, onSave, diff, onDismissDiff, agentOpened, }: FileContentViewerProps) { const canEdit = root !== undefined && path !== undefined && onSave !== undefined; // ── Dirty state tracking ── const [editContent, setEditContent] = useState(content); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); // Reset edit content when the source content changes (e.g. after save + re-fetch) useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local copy when prop changes setEditContent(content); }, [content]); const isDirty = editContent !== content; const [fontSize] = useEditorFontSize(); const { resolvedTheme } = useTheme(); const shikiTheme = resolvedTheme === "light" ? "github-light-default" : "github-dark-default"; const language = detectLanguage(filepath); const handleSave = useCallback(async () => { if (!onSave || !isDirty || isSaving) return; setIsSaving(true); setSaveError(null); try { await onSave(editContent); } catch (err) { setSaveError(err instanceof Error ? err.message : "Failed to save"); } finally { setIsSaving(false); } }, [onSave, isDirty, isSaving, editContent]); // ── Ctrl+S / Cmd+S keyboard shortcut ── useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); handleSave(); } }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [handleSave]); // ── Read-only mode (backward compatible) ── if (!canEdit) { return (
); } // ── Diff overlay mode: agent just edited this file ── if (diff) { return (
{filepath} Changed
); } // ── Editable mode: markdown keeps View/Edit tabs ── if (isMarkdown(filepath)) { return (
{filepath} View Edit {/* Save button */}
{saveError && ( {saveError} )}
); } // ── Editable mode: non-markdown gets single CodeEditor view ── return (
{/* Header bar with filepath and save button */}
{filepath}
{saveError && ( {saveError} )}
{/* CodeEditor fills remaining space */}
); }