import React, { useState, useEffect } from 'react'; import { useDownloadStore, DownloadItem } from '../store/useDownloadStore'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'; import { DownloadItem as DownloadItemComponent } from './DownloadItem'; import { invokeCommand as invoke } from '../ipc'; import { resolveCategoryDestination, resolveDownloadFilePath } from '../utils/downloadLocations'; import { canPauseDownload, canRedownload, canStartDownload, startActionLabel } from '../utils/downloadActions'; import { isActiveDownloadStatus } from '../utils/downloads'; interface DownloadTableProps { filter: SidebarFilter; } const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170]; const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; export const DownloadTable: React.FC = ({ filter }) => { const { downloads, queues, assignToQueue, toggleAddModal, openDeleteModal, redownload } = useDownloadStore(); const { isSidebarVisible, toggleSidebar } = useSettingsStore(); const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const [sortConfig, setSortConfig] = useState<{ column: string; direction: 'asc' | 'desc' } | null>(null); const [columnWidths, setColumnWidths] = useState(() => { try { const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null'); return Array.isArray(stored) && stored.length === DEFAULT_COLUMN_WIDTHS.length && stored.every(value => typeof value === 'number' && Number.isFinite(value)) ? stored : DEFAULT_COLUMN_WIDTHS; } catch { return DEFAULT_COLUMN_WIDTHS; } }); const columnMinimums = [0, 58, 92, 58, 48, 112]; const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' '); const startColumnResize = (index: number, event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); const startX = event.clientX; const startWidth = columnWidths[index]; const handlePointerMove = (moveEvent: PointerEvent) => { const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX); setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width)); }; const handlePointerUp = () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); document.body.classList.remove('is-resizing'); }; document.body.classList.add('is-resizing'); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); }; useEffect(() => { const handleCloseMenu = () => setContextMenu(null); const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setContextMenu(null); }; window.addEventListener('click', handleCloseMenu); window.addEventListener('keydown', handleEscape); return () => { window.removeEventListener('click', handleCloseMenu); window.removeEventListener('keydown', handleEscape); }; }, []); useEffect(() => { window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths)); }, [columnWidths]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.querySelector('.app-modal-backdrop') || document.querySelector('.app-modal')) return; const activeEl = document.activeElement as HTMLElement | null; const isInput = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable); if (!isInput && (e.key === 'Delete' || e.key === 'Backspace')) { if (!activeEl || !activeEl.closest('.sidebar-inner')) { if (selectedIds.size > 0) { handleDelete(Array.from(selectedIds)); } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedIds]); const showInteractionError = (message: string, error: unknown) => { const detail = error instanceof Error ? error.message : String(error); addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true }); }; const getDownloadPath = async (item: DownloadItem) => { const fileName = item.fileName?.trim(); if (!fileName) return null; const settings = useSettingsStore.getState(); const destination = item.destination || await resolveCategoryDestination(settings, item.category); return resolveDownloadFilePath(destination, fileName); }; const openProperties = (id: string) => { useDownloadStore.getState().setSelectedPropertiesDownloadId(id); }; const openDownloadFile = async (item: DownloadItem) => { if (item.status !== 'completed') { openProperties(item.id); return; } const fullPath = await getDownloadPath(item); if (!fullPath) { openProperties(item.id); return; } try { await invoke('open_downloaded_file', { path: fullPath }); } catch (error) { console.error("Failed to open file:", error); showInteractionError('Could not open downloaded file', error); } }; const revealDownloadFile = async (item: DownloadItem) => { const pathToReveal = await getDownloadPath(item); if (!pathToReveal) { openProperties(item.id); return; } try { await invoke('reveal_in_file_manager', { path: pathToReveal }); } catch (error) { console.error("Failed to show in Finder:", error); showInteractionError('Could not show download in Finder', error); } }; const handleDownloadDoubleClick = (item: DownloadItem) => { if (item.status === 'completed') { void openDownloadFile(item); return; } openProperties(item.id); }; const filteredDownloads = downloads.filter((d: DownloadItem) => { if (filter.startsWith('queue:')) { return d.queueId === filter.replace('queue:', '') && d.status !== 'completed'; } switch (filter) { case 'all': return true; case 'active': return d.status === 'downloading'; case 'completed': return d.status === 'completed'; case 'unfinished': return d.status !== 'completed'; default: return d.category === filter; } }); const parseSpeed = (speedStr?: string) => { if (!speedStr || speedStr === '-') return 0; const val = parseFloat(speedStr); if (speedStr.includes('KB/s')) return val * 1024; if (speedStr.includes('MB/s')) return val * 1024 * 1024; if (speedStr.includes('GB/s')) return val * 1024 * 1024 * 1024; return val; }; const parseEta = (etaStr?: string) => { if (!etaStr || etaStr === '-') return Infinity; let seconds = 0; const hours = etaStr.match(/(\d+)h/); const minutes = etaStr.match(/(\d+)m/); const secs = etaStr.match(/(\d+)s/); if (hours) seconds += parseInt(hours[1]) * 3600; if (minutes) seconds += parseInt(minutes[1]) * 60; if (secs) seconds += parseInt(secs[1]); return seconds; }; // Sort by queue position when viewing a specific queue so the visual // order matches the queue order and move-up/down buttons reflect reality. const sortedDownloads = filter.startsWith('queue:') ? [...filteredDownloads].sort((left, right) => { const leftActive = isActiveDownloadStatus(left.status) && left.status !== 'queued'; const rightActive = isActiveDownloadStatus(right.status) && right.status !== 'queued'; if (leftActive && !rightActive) return -1; if (!leftActive && rightActive) return 1; return (left.queuePosition ?? 0) - (right.queuePosition ?? 0); }) : sortConfig ? [...filteredDownloads].sort((a, b) => { let comparison = 0; switch (sortConfig.column) { case 'File Name': comparison = (a.fileName || a.url || '').localeCompare(b.fileName || b.url || ''); break; case 'Size': comparison = parseInt(a.size || '0', 10) - parseInt(b.size || '0', 10); break; case 'Status': comparison = a.status.localeCompare(b.status); break; case 'Speed': comparison = parseSpeed(a.speed) - parseSpeed(b.speed); break; case 'ETA': comparison = parseEta(a.eta) - parseEta(b.eta); break; case 'Date Added': comparison = new Date(a.dateAdded || 0).getTime() - new Date(b.dateAdded || 0).getTime(); break; } return sortConfig.direction === 'asc' ? comparison : -comparison; }) : filteredDownloads; const handleItemClick = (e: React.MouseEvent, item: DownloadItem) => { if (e.detail === 2) { handleDownloadDoubleClick(item); return; } if (e.metaKey || e.ctrlKey) { const newSelected = new Set(selectedIds); if (newSelected.has(item.id)) { newSelected.delete(item.id); } else { newSelected.add(item.id); } setSelectedIds(newSelected); setLastSelectedId(item.id); } else if (e.shiftKey && lastSelectedId) { const currentIndex = sortedDownloads.findIndex(d => d.id === item.id); const lastIndex = sortedDownloads.findIndex(d => d.id === lastSelectedId); if (currentIndex !== -1 && lastIndex !== -1) { const start = Math.min(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex); const newSelected = new Set(selectedIds); for (let i = start; i <= end; i++) { newSelected.add(sortedDownloads[i].id); } setSelectedIds(newSelected); } } else { setSelectedIds(new Set([item.id])); setLastSelectedId(item.id); } }; const handleContextMenu = (menu: { x: number; y: number; id: string }) => { if (!selectedIds.has(menu.id)) { setSelectedIds(new Set([menu.id])); setLastSelectedId(menu.id); } setContextMenu(menu); }; const handleSort = (column: string) => { if (filter.startsWith('queue:')) return; // Disable custom sorting in queues setSortConfig(current => { if (current?.column === column) { if (current.direction === 'desc') return null; // Reset sort return { column, direction: 'desc' }; } return { column, direction: 'asc' }; }); }; const getFilterTitle = () => { if (filter.startsWith('queue:')) { const qid = filter.replace('queue:', ''); const queue = useDownloadStore.getState().queues.find(q => q.id === qid); return queue ? queue.name : 'Unknown Queue'; } switch (filter) { case 'all': return 'All Downloads'; case 'active': return 'Active'; case 'completed': return 'Completed'; case 'unfinished': return 'Unfinished'; default: return filter; } }; const handlePause = async (id: string, skipConfirm = false) => { const download = useDownloadStore.getState().downloads.find(d => d.id === id); if (!skipConfirm && download && download.resumable === false) { const confirmPause = window.confirm("This download does not support resuming. If you pause it, you will have to start over again later. Are you sure you want to pause?"); if (!confirmPause) { return; } } try { await invoke('pause_download', { id }); } catch (e) { console.error("Failed to pause:", e); showInteractionError('Could not pause download', e); } }; const handleResume = (item: DownloadItem) => { useDownloadStore.getState().resumeDownload(item.id); }; const handleDelete = (ids: string | string[]) => { openDeleteModal(ids); }; const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null; const getCategoryIcon = (category: string) => { switch(category) { case 'Musics': return ; case 'Movies': return ; case 'Documents': return ; case 'Applications': return ; case 'Pictures': return ; case 'Compressed': return ; case 'Other': return ; default: return ; } } return (
{!isSidebarVisible && ( )}
Firelink
{getFilterTitle()} {sortedDownloads.length}
{sortedDownloads.length === 0 ? (
) : ( <>
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
handleSort(label)} > {label} {sortConfig?.column === label && ( sortConfig.direction === 'asc' ? : )}
startColumnResize(index, event)} />
))}
{sortedDownloads.map((d, index) => ( ))}
)}
{/* Floating Context Menu */} {contextMenu && contextItem && (
e.stopPropagation()} > {selectedIds.size > 1 ? ( <> {/* Multi-Select Context Menu */}
{queues.map(q => ( ))}
) : ( <> {/* Single-Select Context Menu */} {contextItem.status === 'completed' && ( )}
{canPauseDownload(contextItem.status) && ( )} {canStartDownload(contextItem.status) && ( )} {canRedownload(contextItem.status) && ( )} {contextItem.status !== 'completed' && (
{queues.map(q => ( ))}
)}
{contextItem.status === 'completed' && ( )}
)}
)}
); };