← Назад
import { authFetch } from '../../lib/api'; import { useState, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Folder, FolderOpen, FileText, ChevronRight, ChevronDown, Loader2, AlertCircle, Copy, Check, Download, } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, DialogBody } from '../ui/dialog'; interface FileNode { name: string; type: 'file' | 'dir'; path: string; size?: number; ext?: string; isText?: boolean; children?: FileNode[]; } interface FilesData { path: string; tree: FileNode[]; } interface FilesModalProps { open: boolean; onOpenChange: (open: boolean) => void; projectId: string | null; } const PROJECT_NAMES: Record<string, string> = { system: 'Dashboard', 'bender-bot': 'Bender Bot', piewell: 'Piewell.com', 'futures-screener': 'Futures Screener', affiliate: 'Affiliate', ideas: 'Ideas', alphapulse: 'AlphaPulse', 'options-screener': 'Опционный скринер', 'youtube-ai-channel': 'YouTube AI Channel', 'trading-bot': 'Trading Bot', }; const FILE_ICONS: Record<string, string> = { '.js': '🟨', '.mjs': '🟨', '.cjs': '🟨', '.ts': '🔷', '.tsx': '⚛️', '.jsx': '⚛️', '.json': '📋', '.md': '📝', '.txt': '📄', '.yaml': '⚙️', '.yml': '⚙️', '.toml': '⚙️', '.ini': '⚙️', '.cfg': '⚙️', '.conf': '⚙️', '.sh': '🔧', '.bash': '🔧', '.zsh': '🔧', '.py': '🐍', '.rb': '💎', '.php': '🐘', '.go': '🐹', '.rs': '🦀', '.css': '🎨', '.scss': '🎨', '.sass': '🎨', '.less': '🎨', '.html': '🌐', '.xml': '🌐', '.svg': '🖼️', '.sql': '🗃️', '.graphql': '📡', '.prisma': '🔺', '.env': '🔒', '.gitignore': '🚫', '.dockerignore': '🚫', '.dockerfile': '🐳', '.lock': '🔒', '.c': '⚙️', '.cpp': '⚙️', '.h': '⚙️', '.java': '☕', '.cs': '🔵', }; function getFileIcon(ext?: string): string { if (!ext) return '📄'; return FILE_ICONS[ext] || '📄'; } function formatSize(size?: number): string { if (!size) return ''; if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; } function FileTreeNode({ node, selectedPath, onSelectFile, depth = 0, }: { node: FileNode; selectedPath: string | null; onSelectFile: (path: string) => void; depth?: number; }) { const [expanded, setExpanded] = useState(depth < 1); const indent = depth * 14 + 8; if (node.type === 'dir') { return ( <div> <button onClick={() => setExpanded((v) => !v)} className="flex items-center gap-1.5 w-full text-left py-1 rounded hover:bg-white/5 text-sm transition-colors" style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }} > {expanded ? <ChevronDown className="w-3 h-3 text-gray-500 shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-500 shrink-0" />} {expanded ? <FolderOpen className="w-3.5 h-3.5 text-yellow-400 shrink-0" /> : <Folder className="w-3.5 h-3.5 text-yellow-500 shrink-0" />} <span className="text-gray-300 truncate">{node.name}</span> </button> {expanded && node.children && node.children.length > 0 && ( <div> {node.children.map((child) => ( <FileTreeNode key={child.path} node={child} selectedPath={selectedPath} onSelectFile={onSelectFile} depth={depth + 1} /> ))} </div> )} </div> ); } const isSelected = selectedPath === node.path; const isText = node.isText !== false; return ( <button onClick={() => isText && onSelectFile(node.path)} disabled={!isText} title={isText ? node.path : `${node.name} (binary)`} className={`flex items-center gap-1.5 w-full text-left py-1 rounded text-sm transition-colors ${ isSelected ? 'bg-primary/20 text-primary' : 'hover:bg-white/5 text-gray-400 hover:text-gray-200' } ${!isText ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`} style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }} > <span className="text-sm leading-none shrink-0">{getFileIcon(node.ext)}</span> <span className="truncate flex-1">{node.name}</span> {node.size !== undefined && node.size > 0 && ( <span className="text-xs text-gray-600 shrink-0 ml-1">{formatSize(node.size)}</span> )} </button> ); } export function FilesModal({ open, onOpenChange, projectId }: FilesModalProps) { const [selectedFile, setSelectedFile] = useState<string | null>(null); const [copied, setCopied] = useState(false); const handleCopy = useCallback((text: string) => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }, []); const handleDownload = useCallback((filePath: string) => { const token = localStorage.getItem('dashboard_token') || ''; const url = `/api/download?file=${encodeURIComponent(filePath.replace(/^\/home\/app\//, ''))}`; fetch(url, { headers: { Authorization: `Bearer ${token}` } }) .then(res => res.blob()) .then(blob => { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filePath.split('/').pop() || 'file'; a.click(); URL.revokeObjectURL(a.href); }); }, []); const { data: filesData, isLoading: treeLoading, error: treeError, } = useQuery<FilesData, Error>({ queryKey: ['files-tree', projectId], queryFn: async () => { const res = await authFetch(`/api/projects/${projectId}/files`); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error || `HTTP ${res.status}`); } return res.json(); }, enabled: open && !!projectId, staleTime: 30000, }); const { data: fileContent, isLoading: contentLoading, error: contentError, } = useQuery<{ content: string; size: number; path: string }, Error>({ queryKey: ['file-content', projectId, selectedFile], queryFn: async () => { const res = await authFetch( `/api/projects/${projectId}/files/content?path=${encodeURIComponent(selectedFile!)}`, ); if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown error' })); throw new Error(err.error || `HTTP ${res.status}`); } return res.json(); }, enabled: open && !!projectId, staleTime: 30000, }); return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-6xl h-[90vh] overflow-hidden flex flex-col p-6"> <DialogHeader className="shrink-0"> <DialogTitle> Файлы: {projectId ? (PROJECT_NAMES[projectId] ?? projectId) : ''} </DialogTitle> <DialogClose onClick={() => onOpenChange(false)} /> </DialogHeader> <DialogBody className="flex-1 min-h-0 overflow-hidden py-4 -mx-6 px-6 flex flex-col"> {/* Loading tree */} {treeLoading && ( <div className="flex items-center justify-center py-20"> <Loader2 className="w-8 h-8 animate-spin text-primary" /> </div> )} {/* Tree error */} {treeError && ( <div className="flex items-center justify-center py-20"> <div className="text-center space-y-3 max-w-md"> <AlertCircle className="w-10 h-10 text-danger mx-auto" /> <div className="text-danger font-semibold">Папка проекта не настроена</div> <div className="text-xs text-gray-400 font-mono bg-black/30 px-4 py-3 rounded-lg text-left whitespace-pre-wrap"> {treeError.message} </div> </div> </div> )} {/* Main split view */} {!treeLoading && !treeError && filesData && ( <div className="flex flex-col md:flex-row flex-1 min-h-0 gap-4"> {/* LEFT: file tree */} <div className="w-full md:w-64 md:shrink-0 flex flex-col bg-white/[0.03] rounded-xl border border-white/10 min-h-0 max-h-48 md:max-h-none"> <div className="px-3 py-2 text-xs text-gray-600 font-mono truncate border-b border-white/5 shrink-0" title={filesData.path} > {filesData.path} </div> <div className="flex-1 overflow-y-auto p-1"> {filesData.tree.length === 0 ? ( <div className="text-sm text-gray-500 px-2 py-4">Папка пуста</div> ) : ( filesData.tree.map((node) => ( <FileTreeNode key={node.path} node={node} selectedPath={selectedFile} onSelectFile={setSelectedFile} /> )) )} </div> </div> {/* RIGHT: content viewer */} <div className="flex-1 flex flex-col bg-black/20 rounded-xl border border-white/10 min-h-0 min-w-0"> {!selectedFile && ( <div className="flex-1 flex items-center justify-center text-gray-600"> <div className="text-center"> <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" /> <div className="text-sm">Выберите файл для просмотра</div> </div> </div> )} {selectedFile && contentLoading && ( <div className="flex-1 flex items-center justify-center"> <Loader2 className="w-6 h-6 animate-spin text-primary" /> </div> )} {selectedFile && contentError && ( <div className="flex-1 flex items-center justify-center"> <div className="text-danger text-sm text-center px-4"> {contentError.message} </div> </div> )} {selectedFile && fileContent && !contentLoading && ( <> {/* File header — две строки: путь + кнопки */} <div className="flex flex-col gap-1 px-4 py-2 border-b border-white/10 shrink-0"> <span className="text-xs text-gray-400 font-mono truncate">{fileContent.path}</span> <div className="flex items-center gap-2"> <span className="text-xs text-gray-600">{formatSize(fileContent.size)}</span> <button onClick={() => handleCopy(fileContent.content)} className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors" > {copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />} {copied ? 'Скопировано' : 'Копировать'} </button> <button onClick={() => handleDownload(fileContent.path)} className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-primary/20 hover:bg-primary/30 text-primary transition-colors" > <Download className="w-3 h-3" /> Скачать </button> </div> </div> {/* Scrollable content — оба направления */} <pre className="flex-1 overflow-x-auto overflow-y-auto p-4 text-xs text-gray-200 font-mono leading-relaxed whitespace-pre"> {fileContent.content} </pre> </> )} </div> </div> )} </DialogBody> </DialogContent> </Dialog> ); }