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, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, GripVertical } 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 } from '../utils/downloadActions'; import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads'; import { readClipboardDownloadUrls } from '../utils/clipboard'; import { useTranslation } from 'react-i18next'; import { sortDownloads, type DownloadSortColumn, type DownloadSortConfig } from '../utils/downloadTableSorting'; import { COLUMN_ALIGNMENTS_STORAGE_KEY, COLUMN_ALIGNMENT_JUSTIFY, COLUMN_MINIMUMS, COLUMN_ORDER_STORAGE_KEY, COLUMN_WIDTHS_STORAGE_KEY, DEFAULT_COLUMN_ALIGNMENTS, DEFAULT_COLUMN_ORDER, DEFAULT_COLUMN_WIDTHS, buildColumnGridTemplate, columnIndex, getColumnGridColumn, normalizeColumnAlignments, normalizeColumnOrder, normalizeColumnWidths, type DownloadColumnAlignment, type DownloadTableColumnKey } from '../utils/downloadTableColumns'; interface DownloadTableProps { filter: SidebarFilter; } const persistColumnWidths = (widths: number[]): void => { try { window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(widths)); } catch { // Local storage can be unavailable in restricted WebView contexts. } }; const persistColumnOrder = (order: DownloadTableColumnKey[]): void => { try { window.localStorage.setItem(COLUMN_ORDER_STORAGE_KEY, JSON.stringify(order)); } catch { // Local storage can be unavailable in restricted WebView contexts. } }; const persistColumnAlignments = (alignments: Record): void => { try { window.localStorage.setItem(COLUMN_ALIGNMENTS_STORAGE_KEY, JSON.stringify(alignments)); } catch { // Local storage can be unavailable in restricted WebView contexts. } }; interface ColumnDragState { key: DownloadTableColumnKey; startX: number; pointerX: number; offsetX: number; top: number; width: number; dropIndex: number; markerX: number; } interface ColumnMenuState { key: DownloadTableColumnKey; x: number; y: number; } interface ColumnLayoutBounds { left: number; right: number; } export const DownloadTable: React.FC = ({ filter }) => { const { t } = useTranslation(); 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 [headerAnimationParent, setHeaderAnimationEnabled] = useAutoAnimate({ duration: 140, easing: 'ease-out', }); 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 resizeCleanupRef = useRef<(() => void) | null>(null); const dragGestureRef = useRef<{ key: DownloadTableColumnKey; pointerId: number; startX: number; startY: number; active: boolean; } | null>(null); const headerElementsRef = useRef(new Map()); const headerContainerRef = useRef(null); const headerRef = useCallback((element: HTMLDivElement | null) => { headerContainerRef.current = element; headerAnimationParent(element); }, [headerAnimationParent]); const columnDragStateRef = useRef(null); const columnDragOrderRef = useRef(null); const columnDragLayoutRef = useRef | null>(null); const columnDragTargetRef = useRef(null); const columnDragCaptureTargetRef = useRef(null); const columnDragCapturePointerIdRef = useRef(null); const columnDragCleanupRef = useRef<(() => void) | null>(null); const columnDropFlashTimerRef = useRef(null); const suppressHeaderClickRef = useRef(false); 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 normalizeColumnWidths(stored); } catch { return [...DEFAULT_COLUMN_WIDTHS]; } }); const [columnOrder, setColumnOrder] = useState(() => { try { return normalizeColumnOrder(JSON.parse(window.localStorage.getItem(COLUMN_ORDER_STORAGE_KEY) || 'null')); } catch { return [...DEFAULT_COLUMN_ORDER]; } }); const [columnAlignments, setColumnAlignments] = useState(() => { try { return normalizeColumnAlignments(JSON.parse(window.localStorage.getItem(COLUMN_ALIGNMENTS_STORAGE_KEY) || 'null')); } catch { return { ...DEFAULT_COLUMN_ALIGNMENTS }; } }); const [columnDragState, setColumnDragState] = useState(null); const [columnDragOrder, setColumnDragOrder] = useState(null); const [columnMenu, setColumnMenu] = useState(null); const [columnDropFlashKey, setColumnDropFlashKey] = useState(null); const [, setMenuViewportVersion] = useState(0); const columnWidthsRef = useRef(columnWidths); const columnOrderRef = useRef(columnOrder); const normalizedColumnWidths = columnWidthsRef.current.map((width, index) => Math.max(COLUMN_MINIMUMS[index], width) ); const orderedColumns = columnDragOrderRef.current ?? columnDragOrder ?? columnOrder; const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths); const tableMinWidth = normalizedColumnWidths.reduce( (total, width) => total + width, 0 ); const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))'; const downloadsViewRef = useRef(null); const updateColumnDragState = (next: ColumnDragState | null) => { columnDragStateRef.current = next; setColumnDragState(next); }; const reorderColumn = ( order: DownloadTableColumnKey[], key: DownloadTableColumnKey, dropIndex: number ): DownloadTableColumnKey[] => { const remainingColumns = order.filter(columnKey => columnKey !== key); remainingColumns.splice(dropIndex, 0, key); return remainingColumns; }; const getColumnLayoutBounds = (element: HTMLDivElement) => { // AutoAnimate transforms the visual box; offsetLeft/offsetWidth retain the // stable grid geometry needed for hit-testing during a live reorder. const container = headerContainerRef.current; if (!container) { const rect = element.getBoundingClientRect(); return { left: rect.left, right: rect.right }; } const containerRect = container.getBoundingClientRect(); return { left: containerRect.left + element.offsetLeft, right: containerRect.left + element.offsetLeft + element.offsetWidth, }; }; const captureColumnLayout = (order: DownloadTableColumnKey[]) => { const layout = new Map(); for (const key of order) { const element = headerElementsRef.current.get(key); if (element) layout.set(key, getColumnLayoutBounds(element)); } return layout; }; const getColumnDropPosition = ( key: DownloadTableColumnKey, pointerX: number, order: DownloadTableColumnKey[] = columnDragOrderRef.current ?? columnOrderRef.current ) => { const remainingColumns = order.filter(columnKey => columnKey !== key); const targetIndex = remainingColumns.findIndex(columnKey => { const element = headerElementsRef.current.get(columnKey); if (!element) return false; const bounds = columnDragLayoutRef.current?.get(columnKey) ?? getColumnLayoutBounds(element); return pointerX < bounds.left + (bounds.right - bounds.left) / 2; }); const dropIndex = targetIndex === -1 ? remainingColumns.length : targetIndex; const markerElement = dropIndex < remainingColumns.length ? headerElementsRef.current.get(remainingColumns[dropIndex]) : headerElementsRef.current.get(remainingColumns[remainingColumns.length - 1]); const markerBounds = markerElement ? getColumnLayoutBounds(markerElement) : null; const markerX = markerBounds ? dropIndex < remainingColumns.length ? markerBounds.left : markerBounds.right : pointerX; return { dropIndex, markerX }; }; const finishColumnDrag = (key: DownloadTableColumnKey, pointerId: number, cancelled = false) => { const gesture = dragGestureRef.current; if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return; const current = columnDragStateRef.current; const nextOrder = columnDragOrderRef.current ?? columnOrderRef.current; const captureTarget = columnDragCaptureTargetRef.current; dragGestureRef.current = null; columnDragTargetRef.current = null; columnDragCaptureTargetRef.current = null; columnDragCapturePointerIdRef.current = null; columnDragOrderRef.current = null; columnDragLayoutRef.current = null; columnDragCleanupRef.current?.(); columnDragCleanupRef.current = null; updateColumnDragState(null); setColumnDragOrder(null); setHeaderAnimationEnabled(true); if (captureTarget?.hasPointerCapture(pointerId)) { captureTarget.releasePointerCapture(pointerId); } if (cancelled || !gesture.active || !current) { suppressHeaderClickRef.current = false; return; } window.setTimeout(() => { suppressHeaderClickRef.current = false; }, 0); if (nextOrder.join('|') === columnOrderRef.current.join('|')) return; columnOrderRef.current = nextOrder; setColumnOrder(nextOrder); persistColumnOrder(nextOrder); if (columnDropFlashTimerRef.current !== null) { window.clearTimeout(columnDropFlashTimerRef.current); } setColumnDropFlashKey(key); columnDropFlashTimerRef.current = window.setTimeout(() => { setColumnDropFlashKey(null); columnDropFlashTimerRef.current = null; }, 260); }; const handleColumnPointerMove = ( key: DownloadTableColumnKey, pointerId: number, clientX: number, clientY: number, preventDefault?: () => void ) => { const gesture = dragGestureRef.current; if (!gesture || gesture.key !== key || gesture.pointerId !== pointerId) return; const distance = Math.hypot(clientX - gesture.startX, clientY - gesture.startY); if (!gesture.active) { if (distance < 5) return; gesture.active = true; suppressHeaderClickRef.current = true; const target = columnDragTargetRef.current; const rect = target?.getBoundingClientRect(); if (!rect) return; // The header children are reordered in the live grid while dragging. // AutoAnimate's MutationObserver must not animate the same structural // changes that drive pointer hit-testing; it is restored on completion. setHeaderAnimationEnabled(false); const currentOrder = columnOrderRef.current; columnDragLayoutRef.current = captureColumnLayout(currentOrder); columnDragOrderRef.current = [...currentOrder]; setColumnDragOrder([...currentOrder]); const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder); updateColumnDragState({ key, startX: gesture.startX, pointerX: clientX, offsetX: clientX - rect.left, top: rect.top, width: rect.width, dropIndex, markerX, }); } else { const current = columnDragStateRef.current; if (!current) return; const currentOrder = columnDragOrderRef.current ?? columnOrderRef.current; const { dropIndex, markerX } = getColumnDropPosition(key, clientX, currentOrder); const nextOrder = reorderColumn(currentOrder, key, dropIndex); const orderChanged = nextOrder.some((columnKey, index) => columnKey !== currentOrder[index]); if (orderChanged) { columnDragOrderRef.current = nextOrder; setColumnDragOrder(nextOrder); } updateColumnDragState({ ...current, pointerX: clientX, dropIndex: nextOrder.indexOf(key), markerX, }); } preventDefault?.(); }; const handleColumnPointerDown = (key: DownloadTableColumnKey, event: React.PointerEvent) => { if (event.button !== 0) return; if (event.target instanceof Element && event.target.closest('.column-resize-handle, .download-column-options')) return; const existingGesture = dragGestureRef.current; if (existingGesture) { finishColumnDrag(existingGesture.key, existingGesture.pointerId, true); } const target = event.currentTarget; const pointerId = event.pointerId; const captureTarget = headerContainerRef.current; columnDragTargetRef.current = target; columnDragCaptureTargetRef.current = captureTarget; columnDragCapturePointerIdRef.current = pointerId; if (captureTarget) { try { // Capture on the stable grid parent, never on a column that is // structurally moved during the live reorder. captureTarget.setPointerCapture(pointerId); } catch { // Window listeners below still handle browsers that reject capture // after the pointer has already been cancelled. } } columnDragLayoutRef.current = null; dragGestureRef.current = { key, pointerId, startX: event.clientX, startY: event.clientY, active: false, }; const pointerMove = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId !== pointerId) return; handleColumnPointerMove( key, pointerEvent.pointerId, pointerEvent.clientX, pointerEvent.clientY, () => pointerEvent.preventDefault() ); }; const pointerUp = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId === pointerId) { finishColumnDrag(key, pointerEvent.pointerId); } }; const pointerCancel = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId === pointerId) { finishColumnDrag(key, pointerEvent.pointerId, true); } }; const lostPointerCapture = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId === pointerId) { finishColumnDrag(key, pointerEvent.pointerId, true); } }; window.addEventListener('pointermove', pointerMove); window.addEventListener('pointerup', pointerUp); window.addEventListener('pointercancel', pointerCancel); captureTarget?.addEventListener('lostpointercapture', lostPointerCapture); columnDragCleanupRef.current = () => { window.removeEventListener('pointermove', pointerMove); window.removeEventListener('pointerup', pointerUp); window.removeEventListener('pointercancel', pointerCancel); captureTarget?.removeEventListener('lostpointercapture', lostPointerCapture); }; }; const startColumnResize = (key: DownloadTableColumnKey, event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); resizeCleanupRef.current?.(); const index = columnIndex(key); const startX = event.clientX; const startWidth = columnWidthsRef.current[index]; const handlePointerMove = (moveEvent: PointerEvent) => { const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX); const nextWidths = columnWidthsRef.current.map((width, columnIndex) => columnIndex === index ? nextWidth : width ); columnWidthsRef.current = nextWidths; setColumnWidths(nextWidths); }; const handlePointerUp = () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); window.removeEventListener('blur', handlePointerUp); document.removeEventListener('visibilitychange', handlePointerUp); persistColumnWidths(columnWidthsRef.current); document.body.classList.remove('is-column-resizing'); resizeCleanupRef.current = null; }; resizeCleanupRef.current = handlePointerUp; document.body.classList.add('is-column-resizing'); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); window.addEventListener('blur', handlePointerUp); document.addEventListener('visibilitychange', handlePointerUp); }; const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => { const workspaceRect = downloadsViewRef.current?.getBoundingClientRect(); const minX = Math.max(8, (workspaceRect?.left ?? 0) + 8); const minY = Math.max(8, (workspaceRect?.top ?? 0) + 8); const maxX = Math.max(minX, Math.min( window.innerWidth - menuWidth - 8, (workspaceRect?.right ?? window.innerWidth) - menuWidth - 8 )); const maxY = Math.max(minY, Math.min( window.innerHeight - menuHeight - 8, (workspaceRect?.bottom ?? window.innerHeight) - menuHeight - 8 )); return { x: Math.min(Math.max(minX, x), maxX), y: Math.min(Math.max(minY, y), maxY), }; }, []); const openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => { const position = clampMenuPosition(x, y, 188, 220); setContextMenu(null); setColumnMenu({ key, ...position, }); }; const setColumnAlignment = (alignment: DownloadColumnAlignment) => { if (!columnMenu) return; const key = columnMenu.key; setColumnAlignments(current => { const next = { ...current, [key]: alignment }; persistColumnAlignments(next); return next; }); setColumnMenu(null); }; const resetColumnLayout = () => { const widths = [...DEFAULT_COLUMN_WIDTHS]; const order = [...DEFAULT_COLUMN_ORDER]; const alignments = { ...DEFAULT_COLUMN_ALIGNMENTS }; setColumnWidths(widths); columnWidthsRef.current = widths; columnOrderRef.current = order; setColumnOrder(order); setColumnAlignments(alignments); persistColumnWidths(widths); persistColumnOrder(order); persistColumnAlignments(alignments); setColumnMenu(null); }; useEffect(() => { const handleCloseMenu = () => { setContextMenu(null); setColumnMenu(null); }; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setContextMenu(null); setColumnMenu(null); } }; window.addEventListener('click', handleCloseMenu); window.addEventListener('keydown', handleEscape); return () => { window.removeEventListener('click', handleCloseMenu); window.removeEventListener('keydown', handleEscape); }; }, []); useEffect(() => () => { resizeCleanupRef.current?.(); columnDragCleanupRef.current?.(); columnDragCleanupRef.current = null; const captureTarget = columnDragCaptureTargetRef.current; const capturePointerId = columnDragCapturePointerIdRef.current; columnDragTargetRef.current = null; columnDragCaptureTargetRef.current = null; columnDragCapturePointerIdRef.current = null; if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) { captureTarget.releasePointerCapture(capturePointerId); } if (columnDropFlashTimerRef.current !== null) { window.clearTimeout(columnDropFlashTimerRef.current); } }, []); useEffect(() => { const cancelColumnDrag = () => { if (!dragGestureRef.current) return; columnDragCleanupRef.current?.(); columnDragCleanupRef.current = null; const captureTarget = columnDragCaptureTargetRef.current; const capturePointerId = columnDragCapturePointerIdRef.current; columnDragTargetRef.current = null; columnDragCaptureTargetRef.current = null; columnDragCapturePointerIdRef.current = null; if (captureTarget && capturePointerId !== null && captureTarget.hasPointerCapture(capturePointerId)) { captureTarget.releasePointerCapture(capturePointerId); } dragGestureRef.current = null; columnDragStateRef.current = null; columnDragOrderRef.current = null; columnDragLayoutRef.current = null; setHeaderAnimationEnabled(true); setColumnDragState(null); setColumnDragOrder(null); suppressHeaderClickRef.current = false; }; window.addEventListener('blur', cancelColumnDrag); window.addEventListener('resize', cancelColumnDrag); document.addEventListener('visibilitychange', cancelColumnDrag); return () => { window.removeEventListener('blur', cancelColumnDrag); window.removeEventListener('resize', cancelColumnDrag); document.removeEventListener('visibilitychange', cancelColumnDrag); }; }, []); useEffect(() => { if (!columnMenu && !contextMenu) return; const updateMenuViewport = () => setMenuViewportVersion(version => version + 1); window.addEventListener('resize', updateMenuViewport); return () => window.removeEventListener('resize', updateMenuViewport); }, [columnMenu, contextMenu]); 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(t($ => $.downloadTable.openFileFailed), 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(t($ => $.downloadTable.revealFileFailed), 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); } setColumnMenu(null); setContextMenu({ ...menu, ...clampMenuPosition(menu.x, menu.y, 200, 300), }); }, [clampMenuPosition]); 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 : t($ => $.downloadTable.unknownQueue); } switch (filter) { case 'all': return t($ => $.downloadTable.allDownloads); case 'active': return t($ => $.downloadTable.active); case 'completed': return t($ => $.downloadTable.completed); case 'unfinished': return t($ => $.downloadTable.unfinished); case 'Musics': return t($ => $.navigation.categories.musics); case 'Movies': return t($ => $.navigation.categories.movies); case 'Compressed': return t($ => $.navigation.categories.compressed); case 'Documents': return t($ => $.navigation.categories.documents); case 'Pictures': return t($ => $.navigation.categories.pictures); case 'Applications': return t($ => $.navigation.categories.applications); case 'Other': return t($ => $.navigation.categories.other); 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(t($ => $.downloadTable.nonResumableOne)); if (!confirmPause) { return; } } try { await useDownloadStore.getState().pauseDownload(id); } catch (e) { console.error("Failed to pause:", e); showInteractionError(t($ => $.downloadTable.pauseFailed), e); } }, [showInteractionError]); const handleResume = useCallback(async (item: DownloadItem) => { try { const resumed = await useDownloadStore.getState().resumeDownload(item.id); if (!resumed) { throw new Error(t($ => $.downloadTable.backendRejectedStart)); } } catch (error) { console.error("Failed to resume:", error); showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: 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 ; } }, []); const columnDefinitions: Array<{ key: DownloadTableColumnKey; label: string }> = [ { key: 'File Name', label: t($ => $.downloadTable.headers.fileName) }, { key: 'Size', label: t($ => $.downloadTable.headers.size) }, { key: 'Status', label: t($ => $.downloadTable.headers.status) }, { key: 'Speed', label: t($ => $.downloadTable.headers.speed) }, { key: 'ETA', label: t($ => $.downloadTable.headers.eta) }, { key: 'Date Added', label: t($ => $.downloadTable.headers.dateAdded) }, ]; const columnLabels = new Map(columnDefinitions.map(column => [column.key, column.label])); const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], } as React.CSSProperties); const columnMenuPosition = columnMenu ? clampMenuPosition(columnMenu.x, columnMenu.y, 188, 220) : null; const contextMenuPosition = contextMenu ? clampMenuPosition(contextMenu.x, contextMenu.y, 200, 300) : null; return (
Firelink
{getFilterTitle()} {sortedDownloads.length}
{orderedColumns.map(key => { const label = columnLabels.get(key) ?? key; const activeSort = isQueueFilter ? queueSortConfig : sortConfig; const isDragging = columnDragState?.key === key; const isDropFlashing = columnDropFlashKey === key; return (
{ if (element) headerElementsRef.current.set(key, element); else headerElementsRef.current.delete(key); }} data-column-key={key} role="columnheader" aria-sort={activeSort?.column === key ? activeSort.direction === 'asc' ? 'ascending' : 'descending' : 'none'} className={`download-column-header group ${isDragging ? 'is-dragging' : ''} ${isDropFlashing ? 'is-drop-flashing' : ''}`} style={{ ...columnAlignmentStyle(key), gridColumn: getColumnGridColumn(key, orderedColumns), }} onContextMenu={event => { event.preventDefault(); event.stopPropagation(); openColumnMenu(key, event.clientX, event.clientY); }} onPointerDown={event => handleColumnPointerDown(key, event)} > ); })}
{columnDragState && ( <> {columnMenu && (
event.stopPropagation()} >
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
{t($ => $.downloadTable.columnOptions.alignContent)}
{([ ['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ] as const).map(([alignment, Icon]) => ( ))}
)} {/* 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' && ( )}
)}
)}
); };