import React, { useState, useEffect, useRef } 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, Eye, Edit, Save, RotateCcw, Keyboard, Search, } 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 CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { loadLanguage } from "@uiw/codemirror-extensions-langs"; import { EditorView, keymap } from "@codemirror/view"; import { searchKeymap, search, openSearchPanel } from "@codemirror/search"; import { defaultKeymap, history, historyKeymap, toggleComment, } from "@codemirror/commands"; import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; import { PhotoProvider, PhotoView } from "react-photo-view"; import "react-photo-view/dist/react-photo-view.css"; import ReactPlayer from "react-player"; import AudioPlayer from "react-h5-audio-player"; import "react-h5-audio-player/lib/styles.css"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism"; import { Document, Page, pdfjs } from "react-pdf"; pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js"; 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; onRevert?: () => void; onDownload?: () => void; onMediaDimensionsChange?: (dimensions: { width: number; height: number; }) => void; } function getLanguageIcon(filename: string): React.ReactNode { const ext = filename.split(".").pop()?.toLowerCase() || ""; const baseName = filename.toLowerCase(); 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] || ; } 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 markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"]; const pdfExts = ["pdf"]; 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", ]; 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 (markdownExts.includes(ext)) { return { type: "markdown", icon: , color: "text-blue-600", }; } else if (pdfExts.includes(ext)) { return { type: "pdf", icon: , color: "text-red-600", }; } 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", }; } } function getLanguageExtension(filename: string) { const ext = filename.split(".").pop()?.toLowerCase() || ""; const baseName = filename.toLowerCase(); if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) { return loadLanguage(baseName); } 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; } function formatFileSize(bytes?: number, t?: any): string { if (!bytes) return t ? t("fileManager.unknownSize") : "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, onRevert, onDownload, onMediaDimensionsChange, }: 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 [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false); const [editorFocused, setEditorFocused] = useState(false); const [imageLoadError, setImageLoadError] = useState(false); const [imageLoading, setImageLoading] = useState(true); const [numPages, setNumPages] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [pdfScale, setPdfScale] = useState(1.2); const [pdfError, setPdfError] = useState(false); const [markdownEditMode, setMarkdownEditMode] = useState(false); const editorRef = useRef(null); const fileTypeInfo = getFileType(file.name); const WARNING_SIZE = 50 * 1024 * 1024; const MAX_SIZE = Number.MAX_SAFE_INTEGER; const shouldShowAsText = fileTypeInfo.type === "text" || fileTypeInfo.type === "code" || (fileTypeInfo.type === "unknown" && (forceShowAsText || !file.size || file.size <= WARNING_SIZE)); const isLargeFile = file.size && file.size > WARNING_SIZE; const isTooLarge = file.size && file.size > MAX_SIZE; useEffect(() => { setEditedContent(content); if (savedContent) { setOriginalContent(savedContent); } setHasChanges(content !== savedContent); if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) { setShowLargeFileWarning(true); } else { setShowLargeFileWarning(false); } if ( fileTypeInfo.type === "image" && file.name.toLowerCase().endsWith(".svg") && content ) { setImageLoading(false); setImageLoadError(false); } }, [ content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText, file.name, ]); const handleContentChange = (newContent: string) => { setEditedContent(newContent); setHasChanges(newContent !== savedContent); onContentChange?.(newContent); }; const handleSave = () => { onSave?.(editedContent); }; const handleRevert = () => { if (onRevert) { onRevert(); } else { setEditedContent(savedContent); setHasChanges(false); } }; useEffect(() => { if (!editorFocused || !isEditable) return; const handleKeyDown = (e: KeyboardEvent) => { const isCtrl = e.ctrlKey || e.metaKey; if (isCtrl && e.key.toLowerCase() === "s") { e.preventDefault(); e.stopPropagation(); handleSave(); } }; document.addEventListener("keydown", handleKeyDown, true); return () => { document.removeEventListener("keydown", handleKeyDown, true); }; }, [editorFocused, isEditable, handleSave]); const handleConfirmOpenAsText = () => { setForceShowAsText(true); setShowLargeFileWarning(false); }; const handleCancelOpenAsText = () => { setShowLargeFileWarning(false); }; if (isLoading) { return (

Loading file...

); } return (
{fileTypeInfo.icon}

{file.name}

{formatFileSize(file.size, t)} {file.modified && ( {t("fileManager.modified")}: {file.modified} )} {fileTypeInfo.type.toUpperCase()}
{isEditable && ( )} {isEditable && ( )} {hasChanges && ( <> )} {onDownload && ( )}
{showKeyboardShortcuts && isEditable && (

{t("fileManager.keyboardShortcuts")}

{t("fileManager.searchAndReplace")}

{t("fileManager.search")} Ctrl+F
{t("fileManager.replace")} Ctrl+H
{t("fileManager.findNext")} F3
{t("fileManager.findPrevious")} Shift+F3

{t("fileManager.editing")}

{t("fileManager.save")} Ctrl+S
{t("fileManager.selectAll")} Ctrl+A
{t("fileManager.undo")} Ctrl+Z
{t("fileManager.redo")} Ctrl+Y
{t("fileManager.toggleComment")} Ctrl+/
{t("fileManager.autoComplete")} Ctrl+Space
{t("fileManager.moveLineUp")} Alt+↑
{t("fileManager.moveLineDown")} Alt+↓
)}
{showLargeFileWarning && (

{t("fileManager.largeFileWarning")}

{t("fileManager.largeFileWarningDesc", { size: formatFileSize(file.size, t), })}

{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 && ( )}
)} {fileTypeInfo.type === "image" && !showLargeFileWarning && (
{imageLoadError ? (

{t("fileManager.imageLoadError")}

{file.name}

{onDownload && ( )}
) : file.name.toLowerCase().endsWith(".svg") ? (
{ setImageLoading(false); setImageLoadError(false); }} /> ) : ( {file.name} { setImageLoading(false); setImageLoadError(false); const img = e.currentTarget; if ( onMediaDimensionsChange && img.naturalWidth && img.naturalHeight ) { onMediaDimensionsChange({ width: img.naturalWidth, height: img.naturalHeight, }); } }} onError={() => { setImageLoading(false); setImageLoadError(true); }} /> )} {imageLoading && !imageLoadError && (

Loading image...

)}
)} {shouldShowAsText && !showLargeFileWarning && (
{isEditable ? ( handleContentChange(value)} onFocus={() => setEditorFocused(true)} onBlur={() => setEditorFocused(false)} extensions={[ ...(getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []), history(), search(), autocompletion(), keymap.of([ ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...completionKeymap, { key: "Mod-/", run: toggleComment, preventDefault: true, }, { key: "Mod-h", run: () => { return false; }, preventDefault: true, }, ]), EditorView.theme({ "&": { height: "100%", }, ".cm-scroller": { overflow: "auto", }, ".cm-editor": { height: "100%", }, }), ]} theme={oneDark} placeholder={t("fileManager.startTyping")} className="h-full" basicSetup={{ lineNumbers: true, foldGutter: true, dropCursor: false, allowMultipleSelections: false, indentOnInput: true, bracketMatching: true, closeBrackets: true, autocompletion: true, highlightSelectionMatches: false, scrollPastEnd: false, }} /> ) : (
{editedContent || content || t("fileManager.fileIsEmpty")}
)}
)} {fileTypeInfo.type === "video" && !showLargeFileWarning && (
{(() => { const ext = file.name.split(".").pop()?.toLowerCase() || ""; const mimeType = (() => { switch (ext) { case "mp4": return "video/mp4"; case "webm": return "video/webm"; case "mkv": return "video/x-matroska"; case "avi": return "video/x-msvideo"; case "mov": return "video/quicktime"; case "wmv": return "video/x-ms-wmv"; case "flv": return "video/x-flv"; default: return "video/mp4"; } })(); const videoUrl = `data:${mimeType};base64,${content}`; return (
); })()}
)} {fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
{markdownEditMode ? ( <>