import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { SidebarFilter } from './Sidebar'; import { useAutoAnimate } from '@formkit/auto-animate/react'; import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, 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, isTransferActiveStatus } from '../utils/downloads'; import { readClipboardDownloadUrls } from '../utils/clipboard'; import { sortDownloads, type DownloadSortColumn, type DownloadSortConfig } from '../utils/downloadTableSorting'; 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, openDeleteModal, redownload, moveInQueue } = useDownloadStore(); const { addToast } = useToast(); const isMac = navigator.userAgent.includes('Mac'); const [isReadingClipboard, setIsReadingClipboard] = useState(false); const clipboardReadInFlightRef = useRef(false); const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [animationParent] = useAutoAnimate(); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const [sortConfig, setSortConfig] = useState({ column: 'Date Added', direction: 'desc' }); const [queueSortConfig, setQueueSortConfig] = useState(null); const selectedIdsRef = useRef(selectedIds); const lastSelectedIdRef = useRef(lastSelectedId); const sortedDownloadsRef = useRef([]); selectedIdsRef.current = selectedIds; lastSelectedIdRef.current = lastSelectedId; 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) { if ((e.key === 'a' || e.key === 'A') && (e.metaKey || e.ctrlKey)) { e.preventDefault(); const allIds = sortedDownloadsRef.current.map(d => d.id); setSelectedIds(new Set(allIds)); return; } if (e.key === 'Delete' || e.key === 'Backspace') { if (!activeEl || !activeEl.closest('.sidebar-inner')) { if (selectedIdsRef.current.size > 0) { handleDelete(Array.from(selectedIdsRef.current)); } } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); const showInteractionError = useCallback((message: string, error: unknown) => { const detail = error instanceof Error ? error.message : String(error); addToast({ message: `${message}: ${detail}`, variant: 'error', isActionable: true }); }, [addToast]); const getDownloadPath = useCallback(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 = useCallback((id: string) => { useDownloadStore.getState().setSelectedPropertiesDownloadId(id); }, []); const openDownloadFile = useCallback(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); } }, [getDownloadPath, openProperties, showInteractionError]); 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 = useCallback((item: DownloadItem) => { if (item.status === 'completed') { void openDownloadFile(item); return; } openProperties(item.id); }, [openDownloadFile, openProperties]); const isQueueFilter = filter.startsWith('queue:'); const filteredDownloads = useMemo(() => downloads.filter((d: DownloadItem) => { if (isQueueFilter) { return d.queueId === filter.replace('queue:', '') && d.status !== 'completed'; } switch (filter) { case 'all': return true; case 'active': return isTransferActiveStatus(d.status); case 'completed': return d.status === 'completed'; case 'unfinished': return d.status !== 'completed'; default: return d.category === filter; } }), [downloads, filter, isQueueFilter]); // Queue views use the persisted queue order until the user explicitly sorts // a column. This keeps move-up/down controls truthful while still making // every header a working sort target. const sortedDownloads = useMemo(() => isQueueFilter && !queueSortConfig ? [...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; const positionComparison = (left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER); return positionComparison || left.id.localeCompare(right.id); }) : sortDownloads(filteredDownloads, isQueueFilter ? queueSortConfig! : sortConfig), [filteredDownloads, isQueueFilter, queueSortConfig, sortConfig]); // Each row used to derive this by filtering and sorting the complete store // independently. That made a 1000-entry playlist perform O(n^2 log n) work // on every download update. Compute the same queue membership once and pass // the resulting position to rows instead. const queuePositionsByDownloadId = useMemo(() => { const grouped = new Map(); for (const download of downloads) { if ( download.status === 'completed' || (isActiveDownloadStatus(download.status) && download.status !== 'queued') ) { continue; } const queueId = download.queueId || MAIN_QUEUE_ID; const queueItems = grouped.get(queueId) || []; queueItems.push(download); grouped.set(queueId, queueItems); } const positions = new Map(); for (const queueItems of grouped.values()) { queueItems.sort((left, right) => (left.queuePosition ?? Number.MAX_SAFE_INTEGER) - (right.queuePosition ?? Number.MAX_SAFE_INTEGER) || left.id.localeCompare(right.id) ); queueItems.forEach((download, index) => { positions.set(download.id, { index, length: queueItems.length }); }); } return positions; }, [downloads]); sortedDownloadsRef.current = sortedDownloads; useEffect(() => { const visibleIds = new Set(sortedDownloads.map(download => download.id)); setSelectedIds(current => { const next = new Set(Array.from(current).filter(id => visibleIds.has(id))); return next.size === current.size ? current : next; }); setLastSelectedId(current => current && visibleIds.has(current) ? current : null); }, [sortedDownloads]); useEffect(() => { setQueueSortConfig(null); }, [filter, isQueueFilter]); const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => { if (e.detail === 2) { handleDownloadDoubleClick(item); return; } const currentSortedDownloads = sortedDownloadsRef.current; const currentSelectedIds = selectedIdsRef.current; const currentLastSelectedId = lastSelectedIdRef.current; if (e.shiftKey && currentLastSelectedId) { const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id); const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId); if (currentIndex !== -1 && lastIndex !== -1) { const start = Math.min(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex); const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set(); for (let i = start; i <= end; i++) { newSelected.add(currentSortedDownloads[i].id); } setSelectedIds(newSelected); } } else if (e.metaKey || e.ctrlKey) { const newSelected = new Set(currentSelectedIds); if (newSelected.has(item.id)) { newSelected.delete(item.id); } else { newSelected.add(item.id); } setSelectedIds(newSelected); setLastSelectedId(item.id); } else { setSelectedIds(new Set([item.id])); setLastSelectedId(item.id); } }, [handleDownloadDoubleClick]); const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => { if (!selectedIdsRef.current.has(menu.id)) { setSelectedIds(new Set([menu.id])); setLastSelectedId(menu.id); } setContextMenu(menu); }, []); const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => { const ids = selectedIdsRef.current.has(id) ? Array.from(selectedIdsRef.current) : id; void moveInQueue(ids, direction); }, [moveInQueue]); const handleSort = (column: DownloadSortColumn) => { const update = (current: DownloadSortConfig | null): DownloadSortConfig => current?.column === column ? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' } : { column, direction: 'asc' }; if (isQueueFilter) { setQueueSortConfig(update); } else { setSortConfig(current => update(current)); } }; 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 = useCallback(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 useDownloadStore.getState().pauseDownload(id); } catch (e) { console.error("Failed to pause:", e); showInteractionError('Could not pause download', e); } }, [showInteractionError]); const handleResume = useCallback(async (item: DownloadItem) => { try { const resumed = await useDownloadStore.getState().resumeDownload(item.id); if (!resumed) { throw new Error('The backend rejected the start/resume request.'); } } catch (error) { console.error("Failed to resume:", error); showInteractionError(`Could not resume ${item.fileName}`, error); } }, [showInteractionError]); const resumeItemsSequentially = async (items: DownloadItem[]) => { for (const item of items) { const current = useDownloadStore.getState().downloads.find(download => download.id === item.id); if (current && canStartDownload(current.status)) { await handleResume(current); } } }; const handleDelete = (ids: string | string[]) => { openDeleteModal(ids); }; const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null; const handleAddDownload = async () => { if (clipboardReadInFlightRef.current) return; clipboardReadInFlightRef.current = true; setIsReadingClipboard(true); const store = useDownloadStore.getState(); const initialModalState = { isOpen: store.isAddModalOpen, requestVersion: store.pendingAddRequestVersion, }; try { const urls = await readClipboardDownloadUrls(); if (!isMountedRef.current) return; const currentStore = useDownloadStore.getState(); // Do not append a late clipboard result to a newer extension, deep-link, // paste, or modal request that arrived while the OS clipboard was read. if ( currentStore.isAddModalOpen !== initialModalState.isOpen || currentStore.pendingAddRequestVersion !== initialModalState.requestVersion ) { return; } if (urls.length > 0) { currentStore.openAddModalWithUrls(urls.join('\n')); } else { currentStore.toggleAddModal(true); } } catch (error) { console.warn('Could not read clipboard for Add Download:', error); if (!isMountedRef.current) return; const currentStore = useDownloadStore.getState(); if ( currentStore.isAddModalOpen === initialModalState.isOpen && currentStore.pendingAddRequestVersion === initialModalState.requestVersion ) { currentStore.toggleAddModal(true); } } finally { clipboardReadInFlightRef.current = false; if (isMountedRef.current) setIsReadingClipboard(false); } }; const getCategoryIcon = useCallback((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 (
Firelink
{getFilterTitle()} {sortedDownloads.length}
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
handleSort(label as DownloadSortColumn)} >
{label} {(isQueueFilter ? queueSortConfig : sortConfig)?.column === label && ( (isQueueFilter ? queueSortConfig : sortConfig)?.direction === 'asc' ? : )}
startColumnResize(index, event)} />
))}
{sortedDownloads.length === 0 ? (
) : ( <> {sortedDownloads.map((d, index) => ( ))}
)}
{/* Floating Context Menu */} {contextMenu && contextItem && (
e.stopPropagation()} > {selectedIds.size > 1 ? (() => { const selectedDownloads = Array.from(selectedIds) .map(id => downloads.find(d => d.id === id)) .filter((item): item is DownloadItem => !!item); const itemsToResume = selectedDownloads.filter(d => canStartDownload(d.status)); const itemsToPause = selectedDownloads.filter(d => canPauseDownload(d.status)); const itemsToQueue = selectedDownloads.filter(d => d.status !== 'completed'); return ( <> {/* Multi-Select Context Menu */} {itemsToResume.length > 0 && ( )} {itemsToPause.length > 0 && ( )} {(itemsToResume.length > 0 || itemsToPause.length > 0) && (
)} {itemsToQueue.length > 0 && (
{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' && ( )}
)}
)}
); };