import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import {Separator} from '@/components/ui/separator.tsx'; import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react'; import {ScrollArea} from '@/components/ui/scroll-area.tsx'; import {cn} from '@/lib/utils.ts'; import {Input} from '@/components/ui/input.tsx'; import {Button} from '@/components/ui/button.tsx'; import {toast} from 'sonner'; import {useTranslation} from 'react-i18next'; import { listSSHFiles, renameSSHItem, deleteSSHItem, getFileManagerRecent, getFileManagerPinned, addFileManagerPinned, removeFileManagerPinned, readSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios.ts'; import type { SSHHost, FileManagerLeftSidebarProps } from '../../../types/index.js'; const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( {onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: { onSelectView?: (view: string) => void; onOpenFile: (file: any) => void; tabs: any[]; host: SSHHost; onOperationComplete?: () => void; onError?: (error: string) => void; onSuccess?: (message: string) => void; onPathChange?: (path: string) => void; onDeleteItem?: (item: any) => void; }, ref ) { const {t} = useTranslation(); const [currentPath, setCurrentPath] = useState('/'); const [files, setFiles] = useState([]); const pathInputRef = useRef(null); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [fileSearch, setFileSearch] = useState(''); const [debouncedFileSearch, setDebouncedFileSearch] = useState(''); useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); return () => clearTimeout(handler); }, [search]); useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200); return () => clearTimeout(handler); }, [fileSearch]); const [sshSessionId, setSshSessionId] = useState(null); const [filesLoading, setFilesLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [connectingSSH, setConnectingSSH] = useState(false); const [connectionCache, setConnectionCache] = useState>({}); const [fetchingFiles, setFetchingFiles] = useState(false); const [contextMenu, setContextMenu] = useState<{ visible: boolean; x: number; y: number; item: any; }>({ visible: false, x: 0, y: 0, item: null }); const [renamingItem, setRenamingItem] = useState<{ item: any; newName: string; } | null>(null); useEffect(() => { const nextPath = host?.defaultPath || '/'; setCurrentPath(nextPath); onPathChange?.(nextPath); (async () => { await connectToSSH(host); })(); }, [host?.id]); async function connectToSSH(server: SSHHost): Promise { const sessionId = server.id.toString(); const cached = connectionCache[sessionId]; if (cached && Date.now() - cached.timestamp < 30000) { setSshSessionId(cached.sessionId); return cached.sessionId; } if (connectingSSH) { return null; } setConnectingSSH(true); try { if (!server.password && !server.key) { toast.error(t('common.noAuthCredentials')); return null; } const connectionConfig = { hostId: server.id, ip: server.ip, port: server.port, username: server.username, password: server.password, sshKey: server.key, keyPassword: server.keyPassword, authType: server.authType, credentialId: server.credentialId, userId: server.userId, }; await connectSSH(sessionId, connectionConfig); setSshSessionId(sessionId); setConnectionCache(prev => ({ ...prev, [sessionId]: {sessionId, timestamp: Date.now()} })); return sessionId; } catch (err: any) { toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH')); setSshSessionId(null); return null; } finally { setConnectingSSH(false); } } async function fetchFiles() { if (fetchingFiles) { return; } setFetchingFiles(true); setFiles([]); setFilesLoading(true); try { let pinnedFiles: any[] = []; try { if (host) { pinnedFiles = await getFileManagerPinned(host.id); } } catch (err) { } if (host && sshSessionId) { let res: any[] = []; try { const status = await getSSHStatus(sshSessionId); if (!status.connected) { const newSessionId = await connectToSSH(host); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw new Error(t('fileManager.failedToReconnectSSH')); } } else { res = await listSSHFiles(sshSessionId, currentPath); } } catch (sessionErr) { const newSessionId = await connectToSSH(host); if (newSessionId) { setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { throw sessionErr; } } const processedFiles = (res || []).map((f: any) => { const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name; const isPinned = pinnedFiles.some(pinned => pinned.path === filePath); return { ...f, path: filePath, isPinned, isSSH: true, sshSessionId: sshSessionId }; }); setFiles(processedFiles); } } catch (err: any) { setFiles([]); toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles')); } finally { setFilesLoading(false); setFetchingFiles(false); } } useEffect(() => { if (host && sshSessionId && !connectingSSH && !fetchingFiles) { const timeoutId = setTimeout(() => { fetchFiles(); }, 100); return () => clearTimeout(timeoutId); } }, [currentPath, host, sshSessionId]); useImperativeHandle(ref, () => ({ openFolder: async (_server: SSHHost, path: string) => { if (connectingSSH || fetchingFiles) { return; } if (currentPath === path) { setTimeout(() => fetchFiles(), 100); return; } setFetchingFiles(false); setFilesLoading(false); setFiles([]); setCurrentPath(path); onPathChange?.(path); if (!sshSessionId) { const sessionId = await connectToSSH(host); if (sessionId) setSshSessionId(sessionId); } }, fetchFiles: () => { if (host && sshSessionId) { fetchFiles(); } }, getCurrentPath: () => currentPath })); useEffect(() => { if (pathInputRef.current) { pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; } }, [currentPath]); const filteredFiles = files.filter(file => { const q = debouncedFileSearch.trim().toLowerCase(); if (!q) return true; return file.name.toLowerCase().includes(q); }); const handleContextMenu = (e: React.MouseEvent, item: any) => { e.preventDefault(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const menuWidth = 160; const menuHeight = 80; let x = e.clientX; let y = e.clientY; if (x + menuWidth > viewportWidth) { x = e.clientX - menuWidth; } if (y + menuHeight > viewportHeight) { y = e.clientY - menuHeight; } if (x < 0) { x = 0; } if (y < 0) { y = 0; } setContextMenu({ visible: true, x, y, item }); }; const closeContextMenu = () => { setContextMenu({ visible: false, x: 0, y: 0, item: null }); }; const handleRename = async (item: any, newName: string) => { if (!sshSessionId || !newName.trim() || newName === item.name) { setRenamingItem(null); return; } try { await renameSSHItem(sshSessionId, item.path, newName.trim()); toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`); setRenamingItem(null); if (onOperationComplete) { onOperationComplete(); } else { fetchFiles(); } } catch (error: any) { toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem')); } }; const handleDelete = async (item: any) => { if (!sshSessionId) return; try { await deleteSSHItem(sshSessionId, item.path, item.type === 'directory'); toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`); if (onOperationComplete) { onOperationComplete(); } else { fetchFiles(); } } catch (error: any) { toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); } }; const startRename = (item: any) => { setRenamingItem({ item, newName: item.name }); closeContextMenu(); }; const startDelete = (item: any) => { onDeleteItem?.(item); closeContextMenu(); }; useEffect(() => { const handleClickOutside = () => closeContextMenu(); document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); }, []); const handlePathChange = (newPath: string) => { setCurrentPath(newPath); onPathChange?.(newPath); }; return (
{host && (
handlePathChange(e.target.value)} className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light" />
setFileSearch(e.target.value)} />
{connectingSSH || filesLoading ? (
{t('common.loading')}
) : filteredFiles.length === 0 ? (
{t('fileManager.noFilesOrFoldersFound')}
) : (
{filteredFiles.map((item: any) => { const isOpen = (tabs || []).some((t: any) => t.id === item.path); const isRenaming = renamingItem?.item?.path === item.path; const isDeleting = false; return (
!isOpen && handleContextMenu(e, item)} > {isRenaming ? (
{item.type === 'directory' ? : } setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)} className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') { handleRename(item, renamingItem.newName); } else if (e.key === 'Escape') { setRenamingItem(null); } }} onBlur={() => handleRename(item, renamingItem.newName)} />
) : ( <>
!isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({ name: item.name, path: item.path, isSSH: item.isSSH, sshSessionId: item.sshSessionId }))} > {item.type === 'directory' ? : } {item.name}
{item.type === 'file' && ( )} {!isOpen && ( )}
)}
); })}
)}
)}
{contextMenu.visible && contextMenu.item && (
)}
); }); export {FileManagerLeftSidebar};