import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Folder, File, ChevronLeft, Download, Trash2, Loader2, HardDrive, RefreshCw, Film, FileBox, FileText, Image, Search, ArrowUpDown, CheckSquare, Square, MinusSquare, Box, } from 'lucide-react'; import { api } from '../api/client'; import { parseUTCDate } from '../utils/date'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { ModelViewer } from './ModelViewer'; import { GcodeToolpathViewer } from './GcodeToolpathViewer'; import type { PlateMetadata } from '../types/plates'; import { useToast } from '../contexts/ToastContext'; import { formatFileSize } from '../utils/file'; interface FileManagerModalProps { printerId: number; printerName: string; onClose: () => void; } type PrinterViewerTab = '3d' | 'gcode'; interface PrinterFileViewerModalProps { printerId: number; filePath: string; filename: string; onClose: () => void; } function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: PrinterFileViewerModalProps) { const [activeTab, setActiveTab] = useState(null); const [plates, setPlates] = useState([]); const [platesLoading, setPlatesLoading] = useState(false); const [selectedPlateId, setSelectedPlateId] = useState(null); const ext = filename.toLowerCase().split('.').pop() || ''; const hasModel = ext === '3mf' || ext === 'stl'; const hasGcode = ext === 'gcode' || ext === '3mf'; useEffect(() => { setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null); }, [hasModel, hasGcode]); useEffect(() => { setPlates([]); setSelectedPlateId(null); if (!hasModel) return; setPlatesLoading(true); api.getPrinterFilePlates(printerId, filePath) .then((data) => setPlates(data.plates || [])) .catch(() => setPlates([])) .finally(() => setPlatesLoading(false)); }, [filePath, hasModel, printerId]); const hasMultiplePlates = plates.length > 1; const selectedPlate = selectedPlateId == null ? null : plates.find((plate) => plate.index === selectedPlateId) ?? null; return (
e.stopPropagation()} >

{filename}

{activeTab === '3d' && hasModel ? (
{hasMultiplePlates && (
Plates {platesLoading && }
{plates.map((plate) => ( ))}
{selectedPlate && (
Plate {selectedPlate.index} {selectedPlate.print_time_seconds != null && ( ETA {Math.round(selectedPlate.print_time_seconds / 60)} min )} {selectedPlate.filament_used_grams != null && ( {selectedPlate.filament_used_grams.toFixed(1)} g )} {selectedPlate.filaments.length > 0 && ( {selectedPlate.filaments.length} filament{selectedPlate.filaments.length !== 1 ? 's' : ''} )}
)}
)}
) : activeTab === 'gcode' && hasGcode ? ( ) : (
No preview available for this file
)}
); } function formatStorageSize(bytes: number): string { if (bytes === 0) return '0 GB'; const gb = bytes / (1024 * 1024 * 1024); if (gb >= 1) { return `${gb.toFixed(1)} GB`; } const mb = bytes / (1024 * 1024); return `${mb.toFixed(0)} MB`; } function getFileIcon(filename: string, isDirectory: boolean) { if (isDirectory) return Folder; const ext = filename.toLowerCase().split('.').pop() || ''; switch (ext) { case '3mf': return FileBox; case 'gcode': return FileText; case 'mp4': case 'avi': return Film; case 'png': case 'jpg': case 'jpeg': return Image; default: return File; } } type SortOption = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'; const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'name-asc', label: 'Name (A-Z)' }, { value: 'name-desc', label: 'Name (Z-A)' }, { value: 'size-asc', label: 'Size (smallest)' }, { value: 'size-desc', label: 'Size (largest)' }, { value: 'date-asc', label: 'Date (oldest)' }, { value: 'date-desc', label: 'Date (newest)' }, ]; export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const [currentPath, setCurrentPath] = useState('/'); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [filesToDelete, setFilesToDelete] = useState([]); const [sortBy, setSortBy] = useState('name-asc'); const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null); const [viewerFile, setViewerFile] = useState<{ path: string; name: string } | null>(null); const selectionAnchorRef = useRef(null); const downloadAbortRef = useRef(null); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // No auto-poll: every refetch opens a fresh FTPS connection (TLS handshake // and all) to the printer, and a 30s interval saturated fragile printer // controllers like the P1S — MQTT, FTP and the camera all timed out // together while this modal sat open (#1480). A printer's file list only // changes on upload / delete (the mutations below invalidate the query) // or when a print finishes; the manual Refresh button covers the rest. const { data, isLoading, refetch } = useQuery({ queryKey: ['printerFiles', printerId, currentPath], queryFn: () => api.getPrinterFiles(printerId, currentPath), }); const { data: storageData } = useQuery({ queryKey: ['printerStorage', printerId], queryFn: () => api.getPrinterStorage(printerId), staleTime: 30000, // Cache for 30 seconds }); const visibleFiles = useMemo(() => [...(data?.files ?? [])] .filter((file) => !searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())) .sort((a, b) => { if (a.is_directory && !b.is_directory) return -1; if (!a.is_directory && b.is_directory) return 1; switch (sortBy) { case 'name-asc': return a.name.localeCompare(b.name); case 'name-desc': return b.name.localeCompare(a.name); case 'size-asc': return a.size - b.size; case 'size-desc': return b.size - a.size; case 'date-asc': { const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0; const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0; return aTime - bTime; } case 'date-desc': { const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0; const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0; return bTime - aTime; } default: return a.name.localeCompare(b.name); } }), [data?.files, searchQuery, sortBy]); // Drop selections the user can no longer see -- but only when the listing is // real. An unreachable printer answers with an empty file list and a warning, // and treating that as "those files are gone" would throw away a selection // the user made moments ago because one poll happened to fail. const listingIsReal = !!data && !data.warnings?.includes('printer_unavailable'); useEffect(() => { if (!listingIsReal) return; const visiblePaths = new Set(visibleFiles.filter(file => !file.is_directory).map(file => file.path)); setSelectedFiles(current => new Set([...current].filter(path => visiblePaths.has(path)))); if (selectionAnchorRef.current && !visiblePaths.has(selectionAnchorRef.current)) { selectionAnchorRef.current = null; } }, [visibleFiles, listingIsReal]); useEffect(() => () => downloadAbortRef.current?.abort(), []); const deleteMutation = useMutation({ mutationFn: async (paths: string[]) => { // Delete files one by one for (const path of paths) { await api.deletePrinterFile(printerId, path); } }, onSuccess: () => { showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length })); queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] }); setSelectedFiles(new Set()); selectionAnchorRef.current = null; setFilesToDelete([]); }, onError: (error: Error) => { showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error'); }, }); const navigateToFolder = (path: string) => { setCurrentPath(path); setSelectedFiles(new Set()); selectionAnchorRef.current = null; }; const navigateUp = () => { if (currentPath === '/') return; const parts = currentPath.split('/').filter(Boolean); parts.pop(); setCurrentPath(parts.length ? '/' + parts.join('/') : '/'); setSelectedFiles(new Set()); selectionAnchorRef.current = null; }; const toggleFileSelection = (path: string, e: React.MouseEvent) => { e.stopPropagation(); const selectablePaths = visibleFiles.filter(file => !file.is_directory).map(file => file.path); const anchorIndex = selectionAnchorRef.current ? selectablePaths.indexOf(selectionAnchorRef.current) : -1; const targetIndex = selectablePaths.indexOf(path); const extendsRange = e.shiftKey && anchorIndex !== -1 && targetIndex !== -1; // Moved out of the state updater deliberately: React may run an updater // more than once, and a ref assignment is not the kind of thing that // survives being replayed by accident. if (!extendsRange) selectionAnchorRef.current = path; setSelectedFiles(prev => { const next = new Set(prev); if (extendsRange) { const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); selectablePaths.slice(start, end + 1).forEach(rangePath => next.add(rangePath)); } else if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }; const selectAllFiles = () => { const filePaths = visibleFiles.filter(file => !file.is_directory).map(file => file.path); setSelectedFiles(new Set(filePaths)); selectionAnchorRef.current = null; }; const deselectAllFiles = () => { setSelectedFiles(new Set()); selectionAnchorRef.current = null; }; const handleDownload = async () => { if (selectedFiles.size === 0) return; const paths = visibleFiles.filter(file => !file.is_directory && selectedFiles.has(file.path)).map(file => file.path); if (paths.length === 0) return; const controller = new AbortController(); downloadAbortRef.current = controller; setDownloadProgress({ current: 0, total: paths.length }); try { const sizes = Object.fromEntries(paths.map(path => [ path, data?.files.find(file => file.path === path)?.size ?? 0, ])); const result = await api.downloadPrinterFilesAsZip( printerId, paths, sizes, paths.length === 1 ? data?.files.find(file => file.path === paths[0])?.name ?? 'printer-file' : `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`, paths.length > 1, controller.signal, (completed, total) => setDownloadProgress({ current: completed, total }), ); if (result.failed > 0) { showToast(t('printerFiles.zipPartial', { successful: result.successful, total: result.requested, }), 'warning'); } else { showToast(t('printerFiles.zipStarted', { count: result.successful })); } setSelectedFiles(new Set()); selectionAnchorRef.current = null; } catch (error) { showToast(t('printerFiles.downloadFailed', { error: error instanceof Error ? error.message : t('printerFiles.unknownError'), }), 'error'); } finally { if (downloadAbortRef.current === controller) downloadAbortRef.current = null; setDownloadProgress(null); } }; const handleDelete = () => { if (selectedFiles.size === 0) return; setFilesToDelete( visibleFiles.filter(file => !file.is_directory && selectedFiles.has(file.path)).map(file => file.path), ); }; // Quick navigation buttons for common directories const quickDirs = [ { path: '/', label: 'Root' }, { path: '/cache', label: 'Cache' }, { path: '/model', label: 'Models' }, { path: '/timelapse', label: 'Timelapse' }, ]; return (
e.stopPropagation()} > {/* Header */}

{t('printerFiles.title')}

{printerName}

{/* Storage info */} {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
{storageData.used_bytes != null && ( {t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)} )} {storageData.used_bytes != null && storageData.free_bytes != null && ( | )} {storageData.free_bytes != null && ( {t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)} )}
)}
{/* Quick Navigation */}
{quickDirs.map((dir) => ( ))}
setSearchQuery(e.target.value)} className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" />
{/* Path breadcrumb */}
{currentPath}
{/* File list */}
{isLoading ? (
) : data?.warnings?.includes('printer_unavailable') ? (
{t('printerFiles.printerUnavailable')}
) : !data?.files?.length ? (
{t('printerFiles.noFiles')}
) : (
{visibleFiles.map((file) => { const FileIcon = getFileIcon(file.name, file.is_directory); const isSelected = selectedFiles.has(file.path); return (
{ if (file.is_directory) { navigateToFolder(file.path); } else { toggleFileSelection(file.path, event); } }} > {/* Checkbox for files only */} {!file.is_directory ? ( ) : null} {file.name} {!file.is_directory && (
{formatFileSize(file.size)} {(file.name.toLowerCase().endsWith('.3mf') || file.name.toLowerCase().endsWith('.gcode') || file.name.toLowerCase().endsWith('.stl')) && ( )}
)} {file.is_directory && ( )}
); })}
)}
{/* Action bar */}
{selectedFiles.size > 0 ? `${selectedFiles.size} selected` : searchQuery ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items` : `${data?.files?.length || 0} items` }
{/* Select All / Deselect All */} {data?.files?.some(f => !f.is_directory) && (
{selectedFiles.size > 0 ? ( ) : ( )}
)}
{/* Delete Confirmation Modal */} {filesToDelete.length > 0 && ( 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')} message={ filesToDelete.length > 1 ? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length }) : t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() }) } confirmText={t('common.delete')} variant="danger" onConfirm={() => { deleteMutation.mutate(filesToDelete); }} onCancel={() => setFilesToDelete([])} /> )} {viewerFile && ( setViewerFile(null)} /> )}
); }