import React, { useState, useEffect } from "react"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { FileText, Image as ImageIcon, Film, Music, File as FileIcon, Code, AlertCircle, Download, Save, RotateCcw, Search, X, ChevronUp, ChevronDown, Replace, } from "lucide-react"; import { SiJavascript, SiTypescript, SiPython, SiOracle, SiCplusplus, SiC, SiDotnet, SiPhp, SiRuby, SiGo, SiRust, SiHtml5, SiCss3, SiSass, SiLess, SiJson, SiXml, SiYaml, SiToml, SiShell, SiVuedotjs, SiSvelte, SiMarkdown, SiGnubash, SiMysql, SiDocker, } from "react-icons/si"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { languages, loadLanguage } from "@uiw/codemirror-extensions-langs"; import { EditorView } from "@codemirror/view"; interface FileItem { name: string; type: "file" | "directory" | "link"; path: string; size?: number; modified?: string; permissions?: string; owner?: string; group?: string; } interface FileViewerProps { file: FileItem; content?: string; savedContent?: string; isLoading?: boolean; isEditable?: boolean; onContentChange?: (content: string) => void; onSave?: (content: string) => void; onDownload?: () => void; } // Get official icon for programming languages function getLanguageIcon(filename: string): React.ReactNode { const ext = filename.split(".").pop()?.toLowerCase() || ""; const baseName = filename.toLowerCase(); // Special filename handling if (["dockerfile"].includes(baseName)) { return ; } if (["makefile", "rakefile", "gemfile"].includes(baseName)) { return ; } const iconMap: Record = { js: , jsx: , ts: , tsx: , py: , java: , cpp: , c: , cs: , php: , rb: , go: , rs: , html: , css: , scss: , sass: , less: , json: , xml: , yaml: , yml: , toml: , sql: , sh: , bash: , zsh: , vue: , svelte: , md: , conf: , ini: , }; return iconMap[ext] || ; } // Get file type and icon function getFileType(filename: string): { type: string; icon: React.ReactNode; color: string; } { const ext = filename.split(".").pop()?.toLowerCase() || ""; const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]; const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]; const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"]; const textExts = ["txt", "readme"]; const codeExts = [ "js", "ts", "jsx", "tsx", "py", "java", "cpp", "c", "cs", "php", "rb", "go", "rs", "html", "css", "scss", "less", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "sh", "bash", "zsh", "sql", "vue", "svelte", "md", ]; if (imageExts.includes(ext)) { return { type: "image", icon: , color: "text-green-500", }; } else if (videoExts.includes(ext)) { return { type: "video", icon: , color: "text-purple-500", }; } else if (audioExts.includes(ext)) { return { type: "audio", icon: , color: "text-pink-500", }; } else if (textExts.includes(ext)) { return { type: "text", icon: , color: "text-blue-500", }; } else if (codeExts.includes(ext)) { return { type: "code", icon: getLanguageIcon(filename), color: "text-yellow-500", }; } else { return { type: "unknown", icon: , color: "text-gray-500", }; } } // Get CodeMirror language extension function getLanguageExtension(filename: string) { const ext = filename.split(".").pop()?.toLowerCase() || ""; const baseName = filename.toLowerCase(); // Special filename handling if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) { return loadLanguage(baseName); } // Map by file extension const langMap: Record = { js: "javascript", jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python", java: "java", cpp: "cpp", c: "c", cs: "csharp", php: "php", rb: "ruby", go: "go", rs: "rust", html: "html", css: "css", scss: "sass", less: "less", json: "json", xml: "xml", yaml: "yaml", yml: "yaml", toml: "toml", sql: "sql", sh: "shell", bash: "shell", zsh: "shell", vue: "vue", svelte: "svelte", md: "markdown", conf: "shell", ini: "properties", }; const language = langMap[ext]; return language ? loadLanguage(language) : null; } // Format file size function formatFileSize(bytes?: number): string { if (!bytes) return "Unknown size"; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; } export function FileViewer({ file, content = "", savedContent = "", isLoading = false, isEditable = false, onContentChange, onSave, onDownload, }: FileViewerProps) { const { t } = useTranslation(); const [editedContent, setEditedContent] = useState(content); const [originalContent, setOriginalContent] = useState( savedContent || content, ); const [hasChanges, setHasChanges] = useState(false); const [showLargeFileWarning, setShowLargeFileWarning] = useState(false); const [forceShowAsText, setForceShowAsText] = useState(false); const [showSearchPanel, setShowSearchPanel] = useState(false); const [searchText, setSearchText] = useState(""); const [replaceText, setReplaceText] = useState(""); const [showReplacePanel, setShowReplacePanel] = useState(false); const [searchMatches, setSearchMatches] = useState< { start: number; end: number }[] >([]); const [currentMatchIndex, setCurrentMatchIndex] = useState(-1); const fileTypeInfo = getFileType(file.name); // File size limits - remove hard limits, support large file handling const WARNING_SIZE = 50 * 1024 * 1024; // 50MB warning const MAX_SIZE = Number.MAX_SAFE_INTEGER; // Remove hard limits // Check if should display as text const shouldShowAsText = fileTypeInfo.type === "text" || fileTypeInfo.type === "code" || (fileTypeInfo.type === "unknown" && (forceShowAsText || !file.size || file.size <= WARNING_SIZE)); // Check if file is too large const isLargeFile = file.size && file.size > WARNING_SIZE; const isTooLarge = file.size && file.size > MAX_SIZE; // Sync external content changes useEffect(() => { setEditedContent(content); // Only update originalContent when savedContent is updated if (savedContent) { setOriginalContent(savedContent); } setHasChanges(content !== (savedContent || content)); // If unknown file type and file is large, show warning if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) { setShowLargeFileWarning(true); } else { setShowLargeFileWarning(false); } }, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]); // Handle content changes const handleContentChange = (newContent: string) => { setEditedContent(newContent); setHasChanges(newContent !== originalContent); onContentChange?.(newContent); }; // Save file const handleSave = () => { onSave?.(editedContent); // Note: Don't update originalContent here, as it will be updated via savedContent prop }; // Revert file const handleRevert = () => { setEditedContent(originalContent); setHasChanges(false); onContentChange?.(originalContent); }; // Search matching functionality const findMatches = (text: string) => { if (!text) { setSearchMatches([]); setCurrentMatchIndex(-1); return; } const matches: { start: number; end: number }[] = []; const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); let match; while ((match = regex.exec(editedContent)) !== null) { matches.push({ start: match.index, end: match.index + match[0].length, }); // Avoid infinite loop if (match.index === regex.lastIndex) regex.lastIndex++; } setSearchMatches(matches); setCurrentMatchIndex(matches.length > 0 ? 0 : -1); }; // Search navigation const goToNextMatch = () => { if (searchMatches.length === 0) return; setCurrentMatchIndex((prev) => (prev + 1) % searchMatches.length); }; const goToPrevMatch = () => { if (searchMatches.length === 0) return; setCurrentMatchIndex( (prev) => (prev - 1 + searchMatches.length) % searchMatches.length, ); }; // Replace functionality const handleFindReplace = ( findText: string, replaceWithText: string, replaceAll: boolean = false, ) => { if (!findText) return; let newContent = editedContent; if (replaceAll) { newContent = newContent.replace( new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), replaceWithText, ); } else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) { // Replace current match const match = searchMatches[currentMatchIndex]; newContent = editedContent.substring(0, match.start) + replaceWithText + editedContent.substring(match.end); } setEditedContent(newContent); setHasChanges(newContent !== originalContent); onContentChange?.(newContent); // Re-search to update matches setTimeout(() => findMatches(findText), 0); }; const handleFind = () => { setShowSearchPanel(true); setShowReplacePanel(false); }; const handleReplace = () => { setShowSearchPanel(true); setShowReplacePanel(true); }; // Render highlighted text const renderHighlightedText = (text: string) => { if (!searchText || searchMatches.length === 0) { return text; } const parts: React.ReactNode[] = []; let lastIndex = 0; searchMatches.forEach((match, index) => { // Add text before match if (match.start > lastIndex) { parts.push(text.substring(lastIndex, match.start)); } // Add highlighted match text const isCurrentMatch = index === currentMatchIndex; parts.push( {text.substring(match.start, match.end)} , ); lastIndex = match.end; }); // Add final text if (lastIndex < text.length) { parts.push(text.substring(lastIndex)); } return parts; }; // Handle user confirmation to open large file const handleConfirmOpenAsText = () => { setForceShowAsText(true); setShowLargeFileWarning(false); }; // Handle user rejection to open large file const handleCancelOpenAsText = () => { setShowLargeFileWarning(false); }; if (isLoading) { return (

Loading file...

); } return (
{/* File info header */}
{fileTypeInfo.icon}

{file.name}

{formatFileSize(file.size)} {file.modified && Modified: {file.modified}} {fileTypeInfo.type.toUpperCase()}
{/* Edit toolbar - display directly, no toggle needed */} {isEditable && ( <> )} {hasChanges && ( <> )} {onDownload && ( )}
{/* Search and replace panel */} {showSearchPanel && (
{ setSearchText(e.target.value); findMatches(e.target.value); }} className="w-48 h-8" />
{searchMatches.length > 0 ? `${currentMatchIndex + 1}/${searchMatches.length}` : searchText ? "0/0" : ""}
{showReplacePanel && (
setReplaceText(e.target.value)} className="w-48 h-8" />
)}
)} {/* File content */}
{/* Large file warning dialog */} {showLargeFileWarning && (

Large File Warning

This file is {formatFileSize(file.size)} in size, which may cause performance issues when opened as text.

{isTooLarge ? (

File is too large (> 10MB) and cannot be opened as text for security reasons.

) : (

Do you want to continue opening this file as text? This may slow down your browser.

)}
{!isTooLarge && ( )}
)} {/* Image preview */} {fileTypeInfo.type === "image" && !showLargeFileWarning && (
{file.name} { (e.target as HTMLElement).style.display = "none"; // Show error message instead }} />
)} {/* Text and code file preview */} {shouldShowAsText && !showLargeFileWarning && (
{fileTypeInfo.type === "code" ? ( // Code files use CodeMirror
{searchText && searchMatches.length > 0 ? ( // When there are search results, show read-only highlighted text (with line numbers)
{/* Line number column */}
{editedContent.split("\n").map((_, index) => (
{index + 1}
))}
{/* Code content */}
{renderHighlightedText(editedContent)}
) : ( // Show CodeMirror editor when no search handleContentChange(value)} extensions={[ ...(getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []), EditorView.theme({ "&": { height: "100%", }, ".cm-scroller": { overflow: "auto", }, ".cm-editor": { height: "100%", }, }), ]} theme="dark" basicSetup={{ lineNumbers: true, foldGutter: true, dropCursor: false, allowMultipleSelections: false, indentOnInput: true, bracketMatching: true, closeBrackets: true, autocompletion: true, highlightSelectionMatches: false, scrollPastEnd: false, }} className="h-full overflow-auto" readOnly={!isEditable} /> )}
) : ( // Plain text files
{isEditable ? (
{searchText && searchMatches.length > 0 ? ( // When there are search results, show read-only highlighted text
{renderHighlightedText(editedContent)}
) : ( // Use CodeMirror for all text files (unified editor experience) handleContentChange(value)} extensions={[ ...(getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []), EditorView.theme({ "&": { height: "100%", }, ".cm-scroller": { overflow: "auto", }, ".cm-editor": { height: "100%", }, }), ]} theme={oneDark} editable={isEditable} placeholder={t("fileManager.startTyping")} className="h-full text-sm" basicSetup={{ lineNumbers: true, foldGutter: true, dropCursor: false, allowMultipleSelections: false, highlightSelectionMatches: false, searchKeymap: true, scrollPastEnd: false, }} /> )}
) : ( // Only show as read-only for non-editable files (media files)
{editedContent || content || "File is empty"}
)}
)}
)} {/* Video file preview */} {fileTypeInfo.type === "video" && !showLargeFileWarning && (
)} {/* Audio file preview */} {fileTypeInfo.type === "audio" && !showLargeFileWarning && (
)} {/* Unknown file type - only show when cannot display as text and no warning */} {fileTypeInfo.type === "unknown" && !shouldShowAsText && !showLargeFileWarning && (

Cannot preview this file type

This file type is not supported for preview. You can download it to view in an external application.

{onDownload && ( )}
)}
{/* Bottom status bar */}
{file.path} {hasChanges && ( ● Unsaved changes )}
); }