import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; 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, ArrowDownCircle, ArrowUp, ArrowDown, 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 { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary'; 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'; import { moveSelectedBlockToIndex } from '../utils/queueOrdering'; export interface DownloadTableStatusSummary { summary: DownloadSummary; } interface DownloadTableProps { filter: SidebarFilter; onSummaryChange?: (summary: DownloadTableStatusSummary | null) => void; } 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. } }; const QUEUE_ROW_REORDER_DURATION = 180; const QUEUE_ROW_REORDER_EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)'; const COLUMN_REORDER_DURATION = QUEUE_ROW_REORDER_DURATION; const COLUMN_REORDER_EASING = QUEUE_ROW_REORDER_EASING; 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; } interface QueueDragState { pointerId: number; sourceId: string; queueId: string; ids: string[]; startX: number; startY: number; pointerY: number; pointerOffsetY: number; active: boolean; targetIndex: number; markerTop: number; } interface QueueDragRowGeometry { top: number; height: number; space: number; } interface QueueDragGeometry { listTop: number; scrollTop: number; rows: Map; } interface ColumnMotionLayout { element: HTMLElement; left: number; } export const DownloadTable: React.FC = ({ filter, onSummaryChange }) => { const { t } = useTranslation(); const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue, moveManyInQueueToPosition } = useDownloadStore(); const progressMap = useDownloadProgressStore(state => state.progressMap); 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 [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; }, []); 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 columnMotionFirstRef = useRef(null); const columnMotionElementsRef = useRef(new Set()); const columnMotionFrameRef = useRef(null); const columnMotionTimerRef = useRef(null); const columnMotionGenerationRef = useRef(0); const suppressHeaderClickRef = useRef(false); const selectedIdsRef = useRef(selectedIds); const lastSelectedIdRef = useRef(lastSelectedId); const sortedDownloadsRef = useRef([]); const queueListElementRef = useRef(null); const queueRowsContainerElementRef = useRef(null); const queueReorderableDownloadsRef = useRef([]); const queueDragItemsRef = useRef([]); const queueDragBaseItemsRef = useRef([]); const queueDragStateRef = useRef(null); const queueDragCleanupRef = useRef<(() => void) | null>(null); const queueDragCaptureTargetRef = useRef(null); const queueDragCapturePointerIdRef = useRef(null); const queueDragPreviewOrderRef = useRef(null); const queueDragGeometryRef = useRef(null); const queueDragPreviewElementRef = useRef(null); const queueRowTopsRef = useRef(new Map()); const queueRowHeightsRef = useRef(new Map()); const queueRowAnimationFrameRef = useRef(null); const queueRowAnimationTimerRef = useRef(null); const queueRowAnimationActiveRef = useRef(false); const queueRowAnimationGenerationRef = useRef(0); const queueReorderPendingCountRef = useRef(0); const suppressQueueClickRef = useRef(false); const [queueDragState, setQueueDragState] = useState(null); const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState(null); useEffect(() => { const className = 'is-queue-dragging'; if (queueDragState?.active) { document.body.classList.add(className); } else { document.body.classList.remove(className); } return () => document.body.classList.remove(className); }, [queueDragState?.active]); 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 columnMotionOrderKey = orderedColumns.join('\u0001'); 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 queueListRef = useCallback((element: HTMLDivElement | null) => { queueListElementRef.current = element; }, []); const queueRowsContainerRef = useCallback((element: HTMLDivElement | null) => { queueRowsContainerElementRef.current = element; }, []); const captureColumnMotionLayout = (): ColumnMotionLayout[] => { const header = headerContainerRef.current; const body = queueRowsContainerElementRef.current; const verticalViewport = queueListElementRef.current?.getBoundingClientRect(); const headerElements = header ? Array.from(header.children).filter( (element): element is HTMLElement => element instanceof HTMLElement && Boolean(element.dataset.columnKey) ) : []; const bodyElements: HTMLElement[] = []; if (body) { for (const row of Array.from(body.children)) { if (!(row instanceof HTMLElement) || !row.dataset.downloadId) continue; if (verticalViewport) { const rowRect = row.getBoundingClientRect(); if (rowRect.bottom < verticalViewport.top || rowRect.top > verticalViewport.bottom) continue; } const motionElement = row.querySelector('.download-row-motion'); if (!motionElement) continue; bodyElements.push(...Array.from(motionElement.children).filter( (element): element is HTMLElement => element instanceof HTMLElement && Boolean(element.dataset.columnKey) )); } } return [...headerElements, ...bodyElements].map(element => ({ element, left: element.getBoundingClientRect().left, })); }; const clearColumnMotionStyles = () => { columnMotionGenerationRef.current += 1; if (columnMotionFrameRef.current !== null) { window.cancelAnimationFrame(columnMotionFrameRef.current); columnMotionFrameRef.current = null; } if (columnMotionTimerRef.current !== null) { window.clearTimeout(columnMotionTimerRef.current); columnMotionTimerRef.current = null; } for (const element of columnMotionElementsRef.current) { element.style.removeProperty('transition'); element.style.removeProperty('transform'); element.style.removeProperty('will-change'); } columnMotionElementsRef.current.clear(); }; const prepareColumnMotion = () => { const prefersReducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) { clearColumnMotionStyles(); columnMotionFirstRef.current = null; return; } // Capture the visual position before cancelling an in-flight transition. // The next FLIP pass can then continue from what the user actually sees // instead of snapping back to the previous logical grid position. const first = captureColumnMotionLayout(); clearColumnMotionStyles(); columnMotionFirstRef.current = first; }; useLayoutEffect(() => { const first = columnMotionFirstRef.current; columnMotionFirstRef.current = null; if (!first || first.length === 0) return; const prefersReducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) return; const moved = first.flatMap(({ element, left }) => { if (!element.isConnected) return []; const deltaX = left - element.getBoundingClientRect().left; return Math.abs(deltaX) > 0.5 ? [{ element, deltaX }] : []; }); if (moved.length === 0) return; const generation = ++columnMotionGenerationRef.current; for (const { element, deltaX } of moved) { columnMotionElementsRef.current.add(element); element.style.willChange = 'transform'; element.style.transition = 'none'; element.style.transform = `translate3d(${deltaX}px, 0, 0)`; } // Commit the inverse transforms before releasing them on the next frame so // React's grid reflow becomes a smooth physical-LTR motion for headers and // every visible row cell alike. void headerContainerRef.current?.offsetHeight; void queueRowsContainerElementRef.current?.offsetHeight; columnMotionFrameRef.current = window.requestAnimationFrame(() => { columnMotionFrameRef.current = null; if (columnMotionGenerationRef.current !== generation) return; for (const { element } of moved) { element.style.transition = `transform ${COLUMN_REORDER_DURATION}ms ${COLUMN_REORDER_EASING}`; element.style.transform = 'translate3d(0, 0, 0)'; } }); columnMotionTimerRef.current = window.setTimeout(() => { columnMotionTimerRef.current = null; if (columnMotionGenerationRef.current !== generation) return; for (const { element } of moved) { element.style.removeProperty('transition'); element.style.removeProperty('transform'); element.style.removeProperty('will-change'); columnMotionElementsRef.current.delete(element); } }, COLUMN_REORDER_DURATION + 60); }, [columnMotionOrderKey]); 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) => { // CSS FLIP transforms affect 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; const shouldAnimateCancel = cancelled && gesture.active && Boolean(current); if (shouldAnimateCancel) { prepareColumnMotion(); } 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); 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 captureTarget = columnDragCaptureTargetRef.current; if (captureTarget) { try { // Delay capture until the gesture is a real drag. A click on the // sortable header button must retain its original target so the // browser can dispatch the button click normally. captureTarget.setPointerCapture(pointerId); } catch { // Window listeners below still handle browsers that reject capture // after the pointer has already been cancelled. } } const target = columnDragTargetRef.current; const rect = target?.getBoundingClientRect(); if (!rect) return; // The header children are reordered in the live grid while dragging. // The custom FLIP pass animates the visual boxes while offset geometry // remains stable for pointer hit-testing. 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) { prepareColumnMotion(); 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; 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 }; if (order.join('|') !== columnOrderRef.current.join('|')) { prepareColumnMotion(); } 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?.(); columnMotionFirstRef.current = null; clearColumnMotionStyles(); try { queueDragCleanupRef.current?.(); } catch (error) { console.error('Failed to clean up queue drag listeners during unmount:', error); } columnDragCleanupRef.current = null; queueDragCleanupRef.current = null; queueDragStateRef.current = null; const queueCaptureTarget = queueDragCaptureTargetRef.current; const queueCapturePointerId = queueDragCapturePointerIdRef.current; queueDragCaptureTargetRef.current = null; queueDragCapturePointerIdRef.current = null; try { if (queueCaptureTarget && queueCapturePointerId !== null && queueCaptureTarget.hasPointerCapture(queueCapturePointerId)) { queueCaptureTarget.releasePointerCapture(queueCapturePointerId); } } catch (error) { console.warn('Failed to release queue pointer capture during unmount:', error); } 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 = () => { const gesture = dragGestureRef.current; if (!gesture) return; if (gesture.active && columnDragStateRef.current) { prepareColumnMotion(); } 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; 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 queueRowsById = (list: HTMLDivElement): Map => { const rows = new Map(); const rowsContainer = queueRowsContainerElementRef.current; if (!rowsContainer || !list.contains(rowsContainer)) return rows; for (const element of Array.from(rowsContainer.children)) { if (!(element instanceof HTMLElement)) continue; const id = element.dataset.downloadId; if (id) rows.set(id, element); } return rows; }; const queueRowForId = (id: string): HTMLElement | null => { const list = queueListElementRef.current; if (!list) return null; return queueRowsById(list).get(id) || null; }; const captureQueueDragGeometry = (items: DownloadItem[]): QueueDragGeometry | null => { const list = queueListElementRef.current; if (!list) return null; const listRect = list.getBoundingClientRect(); const rowsById = queueRowsById(list); const rows = new Map(); for (const item of items) { const row = rowsById.get(item.id); if (!row) continue; const rect = row.getBoundingClientRect(); const styles = window.getComputedStyle(row); const marginTop = Number.parseFloat(styles.marginTop) || 0; const marginBottom = Number.parseFloat(styles.marginBottom) || 0; rows.set(item.id, { top: rect.top, height: rect.height, space: rect.height + marginTop + marginBottom, }); } return { listTop: listRect.top, scrollTop: list.scrollTop, rows, }; }; const queueDropPosition = ( clientY: number, items: DownloadItem[], selectedIds: ReadonlySet ): { targetIndex: number; markerTop: number } => { const list = queueListElementRef.current; if (!list || items.length === 0) return { targetIndex: 0, markerTop: 0 }; const geometry = queueDragGeometryRef.current; if (!geometry) return { targetIndex: 0, markerTop: 0 }; // Hit-test against the geometry captured before the preview starts. The // Rendered rows may be in the middle of a FLIP transition; their // transformed client rects are visual output, not stable drop slots. // Keeping the logical slots immutable prevents marker jitter and makes // upward and downward drops use the same coordinate system. The pointer // is compared with the original row centers so a drag started in a row // does not immediately jump past that row when its space is removed. const scrollDelta = list.scrollTop - geometry.scrollTop; let selectedSpaceBefore = 0; let remainingIndex = 0; let markerTop = 0; for (const item of items) { const row = geometry.rows.get(item.id); if (!row) continue; const markerSlotTop = row.top - geometry.listTop + geometry.scrollTop - selectedSpaceBefore; const rowTop = row.top - scrollDelta; if (clientY < rowTop + row.height / 2) { markerTop = markerSlotTop; break; } if (selectedIds.has(item.id)) { // Before and after a selected row are the same logical insertion slot // once that row is removed from the list. markerTop = markerSlotTop; selectedSpaceBefore += row.space; } else { markerTop = markerSlotTop + row.height; remainingIndex += 1; } } return { targetIndex: remainingIndex, markerTop: Math.max(0, markerTop) }; }; const queueDragPreviewTopForPointer = (pointerY: number): number => { const list = queueListElementRef.current; const geometry = queueDragGeometryRef.current; const drag = queueDragStateRef.current; if (!list || !geometry || !drag) return 0; return Math.max( 0, pointerY - geometry.listTop + list.scrollTop - drag.pointerOffsetY ); }; const updateQueueDragPreviewPosition = (pointerY: number) => { const preview = queueDragPreviewElementRef.current; if (!preview) return; preview.style.top = `${queueDragPreviewTopForPointer(pointerY)}px`; }; const clearQueueDragPreview = () => { queueDragPreviewOrderRef.current = null; queueDragGeometryRef.current = null; queueDragBaseItemsRef.current = []; queueDragItemsRef.current = queueReorderableDownloadsRef.current; if (isMountedRef.current) { setQueueDragPreviewOrder(null); } }; const releaseQueuePointerCapture = () => { const captureTarget = queueDragCaptureTargetRef.current; const capturePointerId = queueDragCapturePointerIdRef.current; queueDragCaptureTargetRef.current = null; queueDragCapturePointerIdRef.current = null; if (!captureTarget || capturePointerId === null) return; try { if (captureTarget.hasPointerCapture(capturePointerId)) { captureTarget.releasePointerCapture(capturePointerId); } } catch (error) { // A row can be removed or its WebView can lose the pointer between the // terminal event and cleanup. Listener/state cleanup must still finish. console.warn('Failed to release queue pointer capture:', error); } }; const trackQueueReorderOperation = (operation: () => Promise): Promise => { queueReorderPendingCountRef.current += 1; return Promise.resolve() .then(operation) .finally(() => { queueReorderPendingCountRef.current = Math.max(0, queueReorderPendingCountRef.current - 1); }); }; const queueDragBeforeId = ( previewOrder: string[] | null, selectedIds: ReadonlySet ): string | null => { if (!previewOrder) return null; let lastSelectedIndex = -1; previewOrder.forEach((id, index) => { if (selectedIds.has(id)) lastSelectedIndex = index; }); if (lastSelectedIndex === -1) return null; return previewOrder.slice(lastSelectedIndex + 1).find(id => !selectedIds.has(id)) ?? null; }; const finishQueueDrag = (cancelled = false) => { const current = queueDragStateRef.current; if (!current) return; const cleanup = queueDragCleanupRef.current; queueDragCleanupRef.current = null; queueDragStateRef.current = null; if (isMountedRef.current) { setQueueDragState(null); } try { cleanup?.(); } catch (error) { console.error('Failed to clean up queue drag listeners:', error); } finally { releaseQueuePointerCapture(); } if (current.active) { suppressQueueClickRef.current = true; window.setTimeout(() => { suppressQueueClickRef.current = false; }, 0); } if (!cancelled && current.active) { const committedPreviewOrder = queueDragPreviewOrderRef.current; const targetBeforeId = queueDragBeforeId( committedPreviewOrder, new Set(current.ids) ); const reorderOperation = trackQueueReorderOperation( () => moveManyInQueueToPosition( current.ids, current.queueId, current.targetIndex, targetBeforeId ) ); void reorderOperation .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)) .finally(() => { // Keep the optimistic order visible until the store has accepted or // rolled back the atomic backend move. A second drag must own its // own preview and cannot be cleared by this completion callback. if (queueDragPreviewOrderRef.current === committedPreviewOrder) { clearQueueDragPreview(); } }); } else { clearQueueDragPreview(); } window.requestAnimationFrame(() => { if (!isMountedRef.current) return; queueRowForId(current.sourceId)?.focus({ preventScroll: true }); }); }; const handleQueueDragStart = ( id: string, event: React.PointerEvent ) => { // Keep pointer capture on the persistent list instead of the row being // reordered. React moves row nodes during the live preview; a row-owned // capture can be lost when that node changes position in the DOM. const captureTarget = queueListElementRef.current ?? event.currentTarget; const pointerId = event.pointerId; if ( !queueReorderingEnabled || event.button !== 0 || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || queueDragStateRef.current || queueDragPreviewOrderRef.current || queueReorderPendingCountRef.current > 0 ) return; if ( event.target instanceof Element && event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions') ) { return; } const currentDownloads = useDownloadStore.getState().downloads; const source = currentDownloads.find(download => download.id === id); if (!source) return; const reorderableById = new Map( queueReorderableDownloadsRef.current.map(download => [download.id, download]) ); if (!reorderableById.has(id)) return; const currentSelectedIds = selectedIdsRef.current; const ids = (currentSelectedIds.has(id) ? Array.from(currentSelectedIds) : [id]) .filter(selectedId => { const selected = reorderableById.get(selectedId); return selected && (selected.queueId || MAIN_QUEUE_ID) === (source.queueId || MAIN_QUEUE_ID); }); if (!ids.includes(id)) return; if (!currentSelectedIds.has(id)) { setSelectedIds(new Set([id])); setLastSelectedId(id); } queueDragCleanupRef.current?.(); clearQueueDragPreview(); const queueId = source.queueId || MAIN_QUEUE_ID; const selectedIdSet = new Set(ids); const sourceRect = event.currentTarget.getBoundingClientRect(); const initialItems = queueDragItemsRef.current .filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId); queueDragBaseItemsRef.current = initialItems; queueDragGeometryRef.current = captureQueueDragGeometry(initialItems); const initialPosition = queueDropPosition(event.clientY, initialItems, selectedIdSet); const initialState: QueueDragState = { pointerId: event.pointerId, sourceId: id, queueId, ids, startX: event.clientX, startY: event.clientY, pointerY: event.clientY, pointerOffsetY: event.clientY - sourceRect.top, active: false, ...initialPosition }; queueDragStateRef.current = initialState; setQueueDragState(initialState); queueDragCaptureTargetRef.current = captureTarget; queueDragCapturePointerIdRef.current = pointerId; try { captureTarget.setPointerCapture(pointerId); } catch { // The pointer may have been cancelled between pointerdown and capture. // Window-level listeners remain the best-effort cleanup fallback. } let pointerMoveFrame: number | null = null; let pendingPointerPosition: { clientX: number; clientY: number } | null = null; const applyPointerMove = (clientX: number, clientY: number) => { const drag = queueDragStateRef.current; if (!drag) return; const distance = Math.hypot( clientX - drag.startX, clientY - drag.startY ); if (!drag.active && distance < 5) return; const baseItems = queueDragBaseItemsRef.current; const selectedIdSet = new Set(drag.ids); queueDragStateRef.current = { ...drag, pointerY: clientY }; const nextPosition = queueDropPosition(clientY, baseItems, selectedIdSet); const previewItems = moveSelectedBlockToIndex( baseItems, selectedIdSet, nextPosition.targetIndex ); const nextPreviewOrder = previewItems.map(item => item.id); const currentPreviewOrder = queueDragPreviewOrderRef.current; const previewChanged = currentPreviewOrder === null || currentPreviewOrder.length !== nextPreviewOrder.length || currentPreviewOrder.some((itemId, index) => itemId !== nextPreviewOrder[index]); if (previewChanged) { queueDragItemsRef.current = previewItems; queueDragPreviewOrderRef.current = nextPreviewOrder; setQueueDragPreviewOrder(nextPreviewOrder); } suppressQueueClickRef.current = true; updateQueueDragPreviewPosition(clientY); if ( !drag.active || previewChanged || nextPosition.markerTop !== drag.markerTop ) { const nextState = { ...queueDragStateRef.current, ...nextPosition, active: true }; nextState.pointerY = clientY; queueDragStateRef.current = nextState; setQueueDragState(nextState); } }; const flushPointerMove = () => { if (pointerMoveFrame !== null) { window.cancelAnimationFrame(pointerMoveFrame); pointerMoveFrame = null; } const pending = pendingPointerPosition; pendingPointerPosition = null; if (pending) applyPointerMove(pending.clientX, pending.clientY); }; const pointerMove = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId !== pointerId) return; pendingPointerPosition = { clientX: pointerEvent.clientX, clientY: pointerEvent.clientY, }; pointerEvent.preventDefault(); if (pointerMoveFrame === null) { pointerMoveFrame = window.requestAnimationFrame(() => { pointerMoveFrame = null; const pending = pendingPointerPosition; pendingPointerPosition = null; if (pending) applyPointerMove(pending.clientX, pending.clientY); }); } }; const pointerUp = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId === pointerId) { flushPointerMove(); finishQueueDrag(); } }; const pointerCancel = (pointerEvent: PointerEvent) => { if (pointerEvent.pointerId === pointerId) { pendingPointerPosition = null; if (pointerMoveFrame !== null) { window.cancelAnimationFrame(pointerMoveFrame); pointerMoveFrame = null; } finishQueueDrag(true); } }; const lostPointerCapture = () => finishQueueDrag(true); const cancel = () => finishQueueDrag(true); window.addEventListener('pointermove', pointerMove); window.addEventListener('pointerup', pointerUp); window.addEventListener('pointercancel', pointerCancel); window.addEventListener('blur', cancel); document.addEventListener('visibilitychange', cancel); captureTarget.addEventListener('lostpointercapture', lostPointerCapture); queueDragCleanupRef.current = () => { pendingPointerPosition = null; if (pointerMoveFrame !== null) { window.cancelAnimationFrame(pointerMoveFrame); pointerMoveFrame = null; } window.removeEventListener('pointermove', pointerMove); window.removeEventListener('pointerup', pointerUp); window.removeEventListener('pointercancel', pointerCancel); window.removeEventListener('blur', cancel); document.removeEventListener('visibilitychange', cancel); captureTarget.removeEventListener('lostpointercapture', lostPointerCapture); }; }; // The implementation closes over the current drag helpers, while rows need // a stable prop so React.memo can keep the entire queue from rerendering on // every pointer frame. const queueDragStartHandlerRef = useRef(handleQueueDragStart); queueDragStartHandlerRef.current = handleQueueDragStart; const stableHandleQueueDragStart = useCallback((id: string, event: React.PointerEvent) => { queueDragStartHandlerRef.current(id, event); }, []); 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]); const queueReorderingEnabled = isQueueFilter && !queueSortConfig; const queueReorderableDownloads = useMemo( () => queueReorderingEnabled ? sortedDownloads.filter(download => download.status !== 'completed' && !(isActiveDownloadStatus(download.status) && download.status !== 'queued') ) : [], [queueReorderingEnabled, sortedDownloads] ); const queueReorderableIds = useMemo( () => new Set(queueReorderableDownloads.map(download => download.id)), [queueReorderableDownloads] ); queueReorderableDownloadsRef.current = queueReorderableDownloads; const renderedDownloads = useMemo(() => { if (!queueDragPreviewOrder || !queueReorderingEnabled) return sortedDownloads; const previewRank = new Map(queueDragPreviewOrder.map((id, index) => [id, index])); const previewItems = [...queueReorderableDownloads].sort((left, right) => (previewRank.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (previewRank.get(right.id) ?? Number.MAX_SAFE_INTEGER) ); let previewIndex = 0; return sortedDownloads.map(download => queueReorderableIds.has(download.id) ? previewItems[previewIndex++] ?? download : download ); }, [queueDragPreviewOrder, queueReorderableDownloads, queueReorderableIds, queueReorderingEnabled, sortedDownloads]); queueDragItemsRef.current = renderedDownloads.filter(download => queueReorderableIds.has(download.id)); const queueRowOrderKey = useMemo( () => renderedDownloads.map(download => download.id).join('\u0001'), [renderedDownloads] ); useLayoutEffect(() => { const container = queueRowsContainerElementRef.current; const scrollContainer = queueListElementRef.current; if (!container || !scrollContainer) return; const measureRows = () => { const rows = new Map(); const scrollContainerTop = scrollContainer.getBoundingClientRect().top; const scrollTop = scrollContainer.scrollTop; for (const child of Array.from(container.children)) { if (!(child instanceof HTMLElement)) continue; const id = child.dataset.downloadId; const motionElement = Array.from(child.children).find(element => element instanceof HTMLElement && element.classList.contains('download-row-motion') ); if (!id || !(motionElement instanceof HTMLElement)) continue; const rowRect = child.getBoundingClientRect(); const motionRect = motionElement.getBoundingClientRect(); rows.set(id, { element: child, motionElement, top: motionRect.top - scrollContainerTop + scrollTop, height: rowRect.height, }); } return rows; }; const currentRows = measureRows(); const previousTops = queueRowTopsRef.current; const previousHeights = queueRowHeightsRef.current; const wasAnimating = queueRowAnimationActiveRef.current; const layoutChanged = Array.from(currentRows).some(([id, row]) => { const previousHeight = previousHeights.get(id); return previousHeight !== undefined && Math.abs(previousHeight - row.height) > 0.5; }); const generation = ++queueRowAnimationGenerationRef.current; if (queueRowAnimationFrameRef.current !== null) { window.cancelAnimationFrame(queueRowAnimationFrameRef.current); queueRowAnimationFrameRef.current = null; } if (queueRowAnimationTimerRef.current !== null) { window.clearTimeout(queueRowAnimationTimerRef.current); queueRowAnimationTimerRef.current = null; } const startTops = new Map(); for (const [id, row] of currentRows) { startTops.set( id, wasAnimating || layoutChanged ? row.top : previousTops.get(id) ?? row.top ); row.motionElement.style.transition = 'none'; row.motionElement.style.transform = ''; } const finalRows = measureRows(); queueRowTopsRef.current = new Map( Array.from(finalRows, ([id, row]) => [id, row.top]) ); queueRowHeightsRef.current = new Map( Array.from(finalRows, ([id, row]) => [id, row.height]) ); queueRowAnimationActiveRef.current = false; const movedRows = Array.from(finalRows).flatMap(([id, row]) => { const deltaY = (startTops.get(id) ?? row.top) - row.top; return Math.abs(deltaY) > 0.5 ? [{ element: row.motionElement, deltaY }] : []; }); if (movedRows.length === 0) return; const prefersReducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) return; for (const { element, deltaY } of movedRows) { element.style.willChange = 'transform'; element.style.transform = `translate3d(0, ${deltaY}px, 0)`; } // Force the inverse transforms to be committed before releasing them on // the next frame. This keeps the FLIP animation independent of React's // render timing and avoids a layout read during pointer movement. void container.offsetHeight; queueRowAnimationActiveRef.current = true; queueRowAnimationFrameRef.current = window.requestAnimationFrame(() => { queueRowAnimationFrameRef.current = null; if (queueRowAnimationGenerationRef.current !== generation) return; for (const { element } of movedRows) { element.style.transition = `transform ${QUEUE_ROW_REORDER_DURATION}ms ${QUEUE_ROW_REORDER_EASING}`; element.style.transform = 'translate3d(0, 0, 0)'; } }); queueRowAnimationTimerRef.current = window.setTimeout(() => { queueRowAnimationTimerRef.current = null; if (queueRowAnimationGenerationRef.current !== generation) return; queueRowAnimationActiveRef.current = false; for (const { element } of movedRows) { element.style.removeProperty('transition'); element.style.removeProperty('transform'); element.style.removeProperty('will-change'); } }, QUEUE_ROW_REORDER_DURATION + 60); }, [queueRowOrderKey]); useLayoutEffect(() => { if (!queueDragState?.active) return; // The preview follows the pointer imperatively so high-frequency pointer // movement does not rerender every row. Keep React renders caused by // progress or other download updates from overwriting that live position // with an older pointerY value. updateQueueDragPreviewPosition(queueDragState.pointerY); }, [queueDragState?.active]); useEffect(() => () => { queueRowAnimationGenerationRef.current += 1; if (queueRowAnimationFrameRef.current !== null) { window.cancelAnimationFrame(queueRowAnimationFrameRef.current); } if (queueRowAnimationTimerRef.current !== null) { window.clearTimeout(queueRowAnimationTimerRef.current); } const container = queueRowsContainerElementRef.current; if (container) { for (const child of Array.from(container.children)) { if (!(child instanceof HTMLElement) || !child.dataset.downloadId) continue; const motionElement = Array.from(child.children).find(element => element instanceof HTMLElement && element.classList.contains('download-row-motion') ); if (!(motionElement instanceof HTMLElement)) continue; motionElement.style.removeProperty('transition'); motionElement.style.removeProperty('transform'); motionElement.style.removeProperty('will-change'); } } queueRowAnimationActiveRef.current = false; queueRowTopsRef.current.clear(); queueRowHeightsRef.current.clear(); }, []); useEffect(() => { const current = queueDragStateRef.current; if (!current) return; const currentQueueIds = new Set( queueReorderableDownloads .filter(download => (download.queueId || MAIN_QUEUE_ID) === current.queueId) .map(download => download.id) ); if ( !queueReorderingEnabled || current.ids.some(id => !currentQueueIds.has(id)) ) { finishQueueDrag(true); } }, [queueReorderableDownloads, queueReorderingEnabled]); const selectedDownloads = useMemo( () => filteredDownloads.filter(download => selectedIds.has(download.id)), [filteredDownloads, selectedIds] ); const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads; const downloadSummary = useMemo( () => summarizeDownloads(summaryDownloads, progressMap), [summaryDownloads, progressMap] ); const selectedQueueItems = useMemo( () => queueReorderableDownloads.filter(download => selectedIds.has(download.id)), [queueReorderableDownloads, selectedIds] ); const selectedQueueIndices = selectedQueueItems .map(item => queueReorderableDownloads.findIndex(candidate => candidate.id === item.id)); const canMoveSelectedUp = selectedQueueIndices.length > 0 && Math.min(...selectedQueueIndices) > 0; const canMoveSelectedDown = selectedQueueIndices.length > 0 && Math.max(...selectedQueueIndices) < queueReorderableDownloads.length - 1; const queueReorderPending = queueDragState !== null || queueDragPreviewOrder !== null; useEffect(() => { onSummaryChange?.({ summary: downloadSummary, }); }, [downloadSummary, onSummaryChange]); useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); // 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 }); }); } if (queueDragPreviewOrder && queueReorderingEnabled && !queueDragState?.active) { queueDragPreviewOrder.forEach((id, index) => { positions.set(id, { index, length: queueDragPreviewOrder.length }); }); } return positions; }, [downloads, queueDragPreviewOrder, queueDragState?.active, queueReorderingEnabled]); 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 (suppressQueueClickRef.current) { suppressQueueClickRef.current = false; return; } 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') => { if ( queueDragStateRef.current || queueDragPreviewOrderRef.current ) return; const currentQueueItems = queueReorderableDownloadsRef.current; const source = currentQueueItems.find(item => item.id === id); if (!source) return; const sourceQueueId = source.queueId || MAIN_QUEUE_ID; const currentQueueIds = new Set( currentQueueItems .filter(item => (item.queueId || MAIN_QUEUE_ID) === sourceQueueId) .map(item => item.id) ); const ids = selectedIdsRef.current.has(id) ? Array.from(selectedIdsRef.current).filter(selectedId => currentQueueIds.has(selectedId)) : [id]; if (ids.length === 0) return; void trackQueueReorderOperation(() => moveInQueue(ids, direction)) .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)); }, [moveInQueue, showInteractionError, t]); const moveSelectedQueueItems = useCallback((ids: string[], direction: 'up' | 'down') => { if ( queueDragStateRef.current || queueDragPreviewOrderRef.current ) return; void trackQueueReorderOperation(() => moveInQueue(ids, direction)) .catch(error => showInteractionError(t($ => $.downloadTable.queueReorderFailed), error)); }, [moveInQueue, showInteractionError, t]); 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; const queueDragPreviewItem = queueDragState?.active ? queueDragState.ids .map(id => queueReorderableDownloads.find(download => download.id === id)) .find((download): download is DownloadItem => Boolean(download)) : null; return (
Firelink
{getFilterTitle()} {sortedDownloads.length} {queueReorderingEnabled && queueReorderableDownloads.length > 0 ? ( $.downloadTable.queueReorderShortcut, { key: isMac ? 'Option' : 'Alt' })}> {t($ => $.downloadTable.queueReorderHint)} $.downloadTable.queueReorderShortcut, { key: isMac ? 'Option' : 'Alt' })} > {isMac ? 'Option' : 'Alt'} ) : null}
{selectedDownloads.length > 0 ? ( {t($ => $.downloadTable.summary.selected, { count: selectedDownloads.length })} ) : null} {queueReorderingEnabled && queueReorderableDownloads.length > 0 ? (
$.downloadTable.queuePriorityControls)} >
) : null}
{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 && ( <>