diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 87be990..9858f82 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -53,6 +53,36 @@ fn reorder_selected_queue_tasks( Some(reordered) } +fn reorder_selected_queue_tasks_in_order( + queue_tasks: &[QueuedTask], + ids: &[String], + target_index: usize, +) -> Option> { + let selected_ids = ids.iter().collect::>(); + let mut seen_ids = HashSet::new(); + let selected_tasks = ids + .iter() + .filter(|id| seen_ids.insert(*id)) + .filter_map(|id| queue_tasks.iter().find(|task| task.id == *id)) + .cloned() + .collect::>(); + if selected_tasks.is_empty() { + return None; + } + + let unselected_tasks = queue_tasks + .iter() + .filter(|task| !selected_ids.contains(&task.id)) + .cloned() + .collect::>(); + let insert_index = target_index.min(unselected_tasks.len()); + let mut reordered = Vec::with_capacity(queue_tasks.len()); + reordered.extend_from_slice(&unselected_tasks[..insert_index]); + reordered.extend(selected_tasks); + reordered.extend_from_slice(&unselected_tasks[insert_index..]); + Some(reordered) +} + type Aria2ControlLocks = Arc>>>>; #[derive(Debug, Clone, PartialEq, Eq)] @@ -2293,7 +2323,7 @@ impl QueueManager { .map(|index| pending[*index].clone()) .collect::>(); - if let Some(reordered) = reorder_selected_queue_tasks(&queue_tasks, ids, target_index) { + if let Some(reordered) = reorder_selected_queue_tasks_in_order(&queue_tasks, ids, target_index) { for (queue_index, pending_index) in queue_positions.iter().enumerate() { pending[*pending_index] = reordered[queue_index].clone(); } diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index eeb11ff..a03ebd9 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -2032,6 +2032,23 @@ async fn multi_move_reorders_selected_items_as_one_atomic_block() { ); } +#[tokio::test] +async fn direction_move_keeps_the_queue_order_for_an_unsorted_selection() { + use firelink_lib::ipc::QueueDirection; + + let (mgr, _spawner) = make_manager(3); + for id in ["a", "b", "c", "d", "e"] { + mgr.push(sample_task(id)).await.unwrap(); + } + + let selected = vec!["d".to_string(), "b".to_string()]; + assert_eq!( + mgr.move_many_in_queue(&selected, "main", QueueDirection::Up) + .await, + vec!["b", "d", "a", "c", "e"] + ); +} + #[tokio::test] async fn target_move_reorders_a_selected_block_and_clamps_the_target() { use firelink_lib::ipc::QueueDirection; @@ -2059,6 +2076,35 @@ async fn target_move_reorders_a_selected_block_and_clamps_the_target() { ); } +#[tokio::test] +async fn target_move_preserves_the_explicit_selection_order() { + let (mgr, _spawner) = make_manager(3); + for id in ["a", "b", "c", "d", "e"] { + mgr.push(sample_task(id)).await.unwrap(); + } + + let selected = vec!["d".to_string(), "b".to_string()]; + assert_eq!( + mgr.move_many_in_queue_to(&selected, "main", 0).await, + vec!["d", "b", "a", "c", "e"] + ); +} + +#[tokio::test] +async fn target_move_deduplicates_ids_without_dropping_pending_tasks() { + let (mgr, _spawner) = make_manager(3); + for id in ["a", "b", "c", "d"] { + mgr.push(sample_task(id)).await.unwrap(); + } + + let selected = vec!["c".to_string(), "c".to_string(), "b".to_string()]; + assert_eq!( + mgr.move_many_in_queue_to(&selected, "main", 0).await, + vec!["c", "b", "a", "d"] + ); + assert_eq!(mgr.pending_order(None).await.len(), 4); +} + #[tokio::test] async fn moving_one_queue_does_not_reorder_another_queue() { use firelink_lib::ipc::QueueDirection; diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 6e85233..fc9e76e 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; import { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useToast } from '../contexts/ToastContext'; @@ -6,7 +7,7 @@ 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, + ArrowDownCircle, ArrowUp, ArrowDown, Command, ChevronUp, ChevronDown, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, GripVertical } from 'lucide-react'; import { DownloadItem as DownloadItemComponent } from './DownloadItem'; @@ -52,6 +53,8 @@ import { moveSelectedBlockToIndex } from '../utils/queueOrdering'; import { updateDownloadSelection } from '../utils/downloadSelection'; +import { clampFloatingPosition } from '../utils/floatingPosition'; +import { FloatingQueueSubmenu } from './FloatingQueueSubmenu'; export interface DownloadTableStatusSummary { summary: DownloadSummary; @@ -153,7 +156,10 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC openDeleteModal, redownload, moveInQueue, - moveManyInQueueToPosition + moveManyInQueueToPosition, + startAll, + pauseAll, + startSelected } = useDownloadStore(); const progressMap = useDownloadProgressStore(state => state.progressMap); const { addToast } = useToast(); @@ -273,8 +279,11 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC const [columnDragState, setColumnDragState] = useState(null); const [columnDragOrder, setColumnDragOrder] = useState(null); const [columnMenu, setColumnMenu] = useState(null); + const [columnMenuPosition, setColumnMenuPosition] = useState<{ x: number; y: number } | null>(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); + const columnMenuRef = useRef(null); + const contextMenuRef = useRef(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) => @@ -689,30 +698,21 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }; 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), - }; + return clampFloatingPosition( + x, + y, + menuWidth, + menuHeight, + window.innerWidth, + window.innerHeight + ); }, []); const openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => { const position = clampMenuPosition(x, y, 188, 220); setContextMenu(null); - setColumnMenu({ - key, - ...position, - }); + setColumnMenuPosition(position); + setColumnMenu({ key, x, y }); }; const setColumnAlignment = (alignment: DownloadColumnAlignment) => { @@ -837,12 +837,69 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }; }, []); - useEffect(() => { - if (!columnMenu && !contextMenu) return; - const updateMenuViewport = () => setMenuViewportVersion(version => version + 1); - window.addEventListener('resize', updateMenuViewport); - return () => window.removeEventListener('resize', updateMenuViewport); - }, [columnMenu, contextMenu]); + useLayoutEffect(() => { + if (!columnMenu || !columnMenuRef.current) return; + + const updateColumnMenuPosition = () => { + const menu = columnMenuRef.current; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + const nextPosition = clampMenuPosition( + columnMenu.x, + columnMenu.y, + menu.offsetWidth || rect.width, + menu.offsetHeight || rect.height + ); + setColumnMenuPosition(current => ( + current?.x === nextPosition.x && current.y === nextPosition.y + ? current + : nextPosition + )); + }; + + updateColumnMenuPosition(); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateColumnMenuPosition); + resizeObserver?.observe(columnMenuRef.current); + window.addEventListener('resize', updateColumnMenuPosition); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateColumnMenuPosition); + }; + }, [columnMenu, clampMenuPosition]); + + useLayoutEffect(() => { + if (!contextMenu || !contextMenuRef.current) return; + + const updateContextMenuPosition = () => { + const menu = contextMenuRef.current; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + const nextPosition = clampMenuPosition( + contextMenu.x, + contextMenu.y, + menu.offsetWidth || rect.width, + menu.offsetHeight || rect.height + ); + setContextMenuPosition(current => ( + current?.x === nextPosition.x && current.y === nextPosition.y + ? current + : nextPosition + )); + }; + + updateContextMenuPosition(); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateContextMenuPosition); + resizeObserver?.observe(contextMenuRef.current); + window.addEventListener('resize', updateContextMenuPosition); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateContextMenuPosition); + }; + }, [contextMenu, clampMenuPosition]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1599,6 +1656,10 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC () => countDownloadActions(selectedDownloads), [selectedDownloads] ); + const hasStartableDownloads = downloads.some(download => + download.status === 'queued' || canStartDownload(download.status) + ); + const hasPausableDownloads = downloads.some(download => canPauseDownload(download.status)); const summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads; const downloadSummary = useMemo( () => summarizeDownloads(summaryDownloads, progressMap), @@ -1702,10 +1763,9 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC setLastSelectedId(menu.id); } setColumnMenu(null); - setContextMenu({ - ...menu, - ...clampMenuPosition(menu.x, menu.y, 200, 300), - }); + const position = clampMenuPosition(menu.x, menu.y, 200, 300); + setContextMenuPosition(position); + setContextMenu(menu); }, [clampMenuPosition]); const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => { @@ -1804,15 +1864,6 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC } }, [showInteractionError]); - const resumeItemsSequentially = useCallback(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); - } - } - }, [handleResume]); - const getCurrentSelectedDownloads = useCallback(() => { const selected = selectedIdsRef.current; return useDownloadStore.getState().downloads.filter(download => selected.has(download.id)); @@ -1841,10 +1892,40 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC }, [getCurrentSelectedDownloads, handlePause, t]); const handleResumeSelected = useCallback(() => { - const items = getCurrentSelectedDownloads().filter(download => canStartDownload(download.status)); - if (items.length === 0) return; - void resumeItemsSequentially(items); - }, [getCurrentSelectedDownloads, resumeItemsSequentially]); + const ids = Array.from(selectedIdsRef.current); + if (ids.length === 0) return; + void startSelected(ids).catch(error => { + showInteractionError(t($ => $.downloadTable.resumeFailed), error); + }); + }, [showInteractionError, startSelected, t]); + + const handleStartAll = useCallback(() => { + void startAll().catch(error => { + showInteractionError(t($ => $.downloadTable.resumeFailed), error); + }); + }, [showInteractionError, startAll, t]); + + const handlePauseAll = useCallback(async () => { + const currentDownloads = useDownloadStore.getState().downloads; + const pausableDownloads = currentDownloads.filter(download => canPauseDownload(download.status)); + if (pausableDownloads.length === 0) return; + + const nonResumableCount = pausableDownloads.filter(download => download.resumable === false).length; + if (nonResumableCount > 0) { + const confirmPause = window.confirm( + nonResumableCount === 1 + ? t($ => $.downloadTable.nonResumableOne) + : t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount }) + ); + if (!confirmPause) return; + } + + try { + await pauseAll(); + } catch (error) { + showInteractionError(t($ => $.downloadTable.pauseFailed), error); + } + }, [pauseAll, showInteractionError, t]); const handleDelete = (ids: string | string[]) => { openDeleteModal(ids); @@ -1924,12 +2005,6 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC 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)) @@ -1956,10 +2031,8 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC - + , + document.body )} {/* Floating Context Menu */} - {contextMenu && contextItem && ( + {contextMenu && contextItem && createPortal(
= ({ filter, onSummaryC )} {itemsToQueue.length > 0 && ( -
- -
- {queues.map(q => ( - - ))} -
-
+ $.downloadTable.addToQueue)} + queues={queues} + onSelect={q => { + setContextMenu(null); + void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => { + showInteractionError(t($ => $.downloadTable.moveManyFailed), error); + }); + }} + /> )}
@@ -2463,28 +2512,16 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC )} {contextItem.status !== 'completed' && ( -
- -
- {queues.map(q => ( - - ))} -
-
+ $.downloadTable.addToQueue)} + queues={queues} + onSelect={q => { + setContextMenu(null); + void assignToQueue([contextItem.id], q.id).catch(error => { + showInteractionError(t($ => $.downloadTable.moveOneFailed), error); + }); + }} + /> )}
@@ -2547,7 +2584,8 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC )} -
+ , + document.body )} diff --git a/src/components/FloatingQueueSubmenu.tsx b/src/components/FloatingQueueSubmenu.tsx new file mode 100644 index 0000000..87a35c6 --- /dev/null +++ b/src/components/FloatingQueueSubmenu.tsx @@ -0,0 +1,195 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronRight } from 'lucide-react'; +import type { Queue } from '../store/useDownloadStore'; +import { positionFloatingSubmenu, type FloatingSubmenuPosition } from '../utils/floatingPosition'; + +interface FloatingQueueSubmenuProps { + label: React.ReactNode; + queues: Queue[]; + onSelect: (queue: Queue) => void; +} + +const CLOSE_DELAY = 140; + +export const FloatingQueueSubmenu: React.FC = ({ label, queues, onSelect }) => { + const triggerRef = useRef(null); + const menuRef = useRef(null); + const closeTimerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState(null); + const isRtl = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; + const openKey = isRtl ? 'ArrowLeft' : 'ArrowRight'; + const closeKey = isRtl ? 'ArrowRight' : 'ArrowLeft'; + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const closeMenu = useCallback(() => { + clearCloseTimer(); + setIsOpen(false); + setPosition(null); + }, [clearCloseTimer]); + + const scheduleClose = useCallback(() => { + clearCloseTimer(); + closeTimerRef.current = window.setTimeout(() => { + const activeElement = document.activeElement; + if ( + triggerRef.current?.contains(activeElement) || + menuRef.current?.contains(activeElement) + ) { + closeTimerRef.current = null; + return; + } + closeTimerRef.current = null; + setIsOpen(false); + setPosition(null); + }, CLOSE_DELAY); + }, [clearCloseTimer]); + + const openMenu = useCallback(() => { + clearCloseTimer(); + setIsOpen(true); + }, [clearCloseTimer]); + + useLayoutEffect(() => { + if (!isOpen) return; + + const updatePosition = () => { + const trigger = triggerRef.current; + const menu = menuRef.current; + if (!trigger || !menu) return; + const triggerRect = trigger.getBoundingClientRect(); + const menuRect = menu.getBoundingClientRect(); + const nextPosition = positionFloatingSubmenu( + triggerRect, + menu.offsetWidth || menuRect.width, + menu.offsetHeight || menuRect.height, + window.innerWidth, + window.innerHeight, + 8, + 4, + isRtl ? 'left' : 'right' + ); + setPosition(current => ( + current?.x === nextPosition.x && + current.y === nextPosition.y && + current.side === nextPosition.side + ? current + : nextPosition + )); + }; + + updatePosition(); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updatePosition); + if (menuRef.current) resizeObserver?.observe(menuRef.current); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen, isRtl]); + + useLayoutEffect(() => () => clearCloseTimer(), [clearCloseTimer]); + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + scheduleClose(); + } + }} + > + + + {isOpen && createPortal( +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + scheduleClose(); + } + }} + onKeyDown={event => { + if (event.key !== 'Escape' && event.key !== 'ArrowLeft') return; + event.preventDefault(); + event.stopPropagation(); + closeMenu(); + triggerRef.current?.querySelector('button')?.focus(); + }} + onClick={event => event.stopPropagation()} + > + {queues.map(queue => ( + + ))} +
, + document.body + )} +
+ ); +}; diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index 29eb0bf..79126e2 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { invokeCommand as invoke } from '../ipc'; import { save } from '@tauri-apps/plugin-dialog'; import { homeDir } from '@tauri-apps/api/path'; @@ -8,6 +9,7 @@ import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { useSettingsStore } from '../store/useSettingsStore'; import { useTranslation } from 'react-i18next'; +import { clampFloatingPosition } from '../utils/floatingPosition'; import { MAX_LOG_LINES, appendBoundedLogEntries, @@ -26,6 +28,8 @@ export default function LogsView() { const [logs, setLogs] = useState([]); const [levelFilter, setLevelFilter] = useState('All'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); + const contextMenuRef = useRef(null); const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden'); const homeDirectoryRef = useRef(''); const scrollRef = useRef(null); @@ -161,12 +165,48 @@ export default function LogsView() { e.preventDefault(); const selection = window.getSelection()?.toString(); if (selection && selection.trim().length > 0) { + const position = clampFloatingPosition(e.clientX, e.clientY, 150, 50, window.innerWidth, window.innerHeight); + setContextMenuPosition(position); setContextMenu({ x: e.clientX, y: e.clientY, text: selection }); } else { setContextMenu(null); } }; + useLayoutEffect(() => { + if (!contextMenu || !contextMenuRef.current) return; + + const updateContextMenuPosition = () => { + const menu = contextMenuRef.current; + if (!menu) return; + const rect = menu.getBoundingClientRect(); + const nextPosition = clampFloatingPosition( + contextMenu.x, + contextMenu.y, + menu.offsetWidth || rect.width, + menu.offsetHeight || rect.height, + window.innerWidth, + window.innerHeight + ); + setContextMenuPosition(current => ( + current?.x === nextPosition.x && current.y === nextPosition.y + ? current + : nextPosition + )); + }; + + updateContextMenuPosition(); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateContextMenuPosition); + resizeObserver?.observe(contextMenuRef.current); + window.addEventListener('resize', updateContextMenuPosition); + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateContextMenuPosition); + }; + }, [contextMenu]); + const handleCopy = async () => { if (contextMenu?.text) { try { @@ -354,11 +394,12 @@ export default function LogsView() { {/* Context Menu */} - {contextMenu && ( + {contextMenu && createPortal(
e.stopPropagation()} > -
+ , + document.body )} ); diff --git a/src/index.css b/src/index.css index 067269b..8cdd6a9 100644 --- a/src/index.css +++ b/src/index.css @@ -3074,8 +3074,6 @@ body.is-queue-dragging * { } .download-context-submenu { - inset-inline-start: 100%; - margin-inline-start: 0.25rem; opacity: 0; visibility: hidden; pointer-events: none; @@ -3087,8 +3085,7 @@ body.is-queue-dragging * { visibility 0s linear 120ms; } -.group:hover > .download-context-submenu, -.group:focus-within > .download-context-submenu { +.download-context-submenu.is-open { opacity: 1; visibility: visible; pointer-events: auto; @@ -3096,11 +3093,7 @@ body.is-queue-dragging * { transition-delay: 0s; } -.app-workspace--sidebar-right .download-context-submenu { - inset-inline-start: auto; - inset-inline-end: 100%; - margin-inline-start: 0; - margin-inline-end: 0.25rem; +.download-context-submenu[data-side="left"] { transform-origin: top right; } diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index fcb1ede..adc8a9e 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -1006,6 +1006,103 @@ describe('useDownloadStore', () => { }); }); + it('starts a selected queue block in selection order and moves pending rows to the front', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'other', url: 'http://other', fileName: 'other', destination: '/tmp', status: 'staged', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 0 }, + { id: 'selected-a', url: 'http://a', fileName: 'a', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 1, hasBeenDispatched: true }, + { id: 'selected-b', url: 'http://b', fileName: 'b', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'selection-queue', queuePosition: 2, hasBeenDispatched: true }, + ] as any[], + backendRegisteredIds: new Set(['selected-a', 'selected-b']) + }); + + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => { + if (command === 'enqueue_download') { + const id = (args as { item: { id: string } }).item.id; + return { id, filename: id }; + } + if (command === 'get_pending_order') return ['selected-b', 'selected-a', 'other']; + if (command === 'move_many_in_queue') return ['selected-b', 'selected-a', 'other']; + return undefined; + }); + + await expect(useDownloadStore.getState().startSelected(['selected-b', 'selected-a'])).resolves.toBe(2); + + const enqueueIds = vi.mocked(ipc.invokeCommand).mock.calls + .filter(([command]) => command === 'enqueue_download') + .map(([, args]) => (args as { item: { id: string } }).item.id); + expect(enqueueIds).toEqual(['selected-b', 'selected-a']); + expect(ipc.invokeCommand).toHaveBeenCalledWith('move_many_in_queue', { + ids: ['selected-b', 'selected-a'], + queueId: 'selection-queue', + direction: 'up', + targetIndex: 0, + }); + expect(useDownloadStore.getState().downloads.map(item => item.id)).toEqual([ + 'other', + 'selected-a', + 'selected-b', + ]); + expect(useDownloadStore.getState().downloads.find(item => item.id === 'selected-b')?.queuePosition).toBe(0); + expect(useDownloadStore.getState().downloads.find(item => item.id === 'selected-a')?.queuePosition).toBe(1); + }); + + it('pauses queued items through the global pause action', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'queued-one', url: 'http://one', fileName: 'one', status: 'queued', category: 'Other', dateAdded: '', queueId: 'pause-queue' }, + { id: 'queued-two', url: 'http://two', fileName: 'two', status: 'queued', category: 'Other', dateAdded: '', queueId: 'pause-queue' }, + ] as any[], + }); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never); + + await expect(useDownloadStore.getState().pauseAll()).resolves.toBe(2); + expect( + vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download') + ).toHaveLength(2); + expect(useDownloadStore.getState().downloads.every(item => item.status === 'paused')).toBe(true); + }); + + it('does not let a queue pause lose a selected start in flight', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'race-first', url: 'http://first', fileName: 'first', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'race-selected', queuePosition: 0 }, + { id: 'race-second', url: 'http://second', fileName: 'second', destination: '/tmp', status: 'paused', category: 'Other', dateAdded: '', queueId: 'race-selected', queuePosition: 1 }, + ] as any[], + }); + + let releaseEnqueue!: (value: { id: string; filename: string }) => void; + const enqueue = new Promise<{ id: string; filename: string }>(resolve => { + releaseEnqueue = resolve; + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => { + if (command === 'enqueue_download') return enqueue; + if (command === 'get_pending_order') return []; + return undefined; + }); + + const start = useDownloadStore.getState().startSelected(['race-first', 'race-second']); + await vi.waitFor(() => { + expect(vi.mocked(ipc.invokeCommand)).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ item: expect.objectContaining({ id: 'race-first' }) }) + ); + }); + + const pause = useDownloadStore.getState().pauseQueue('race-selected'); + await vi.waitFor(() => { + expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'cancel_enqueue_generation')) + .toHaveLength(2); + }); + releaseEnqueue({ id: 'race-first', filename: 'first' }); + + await expect(start).resolves.toBe(0); + await expect(pause).resolves.toBe(1); + expect(useDownloadStore.getState().downloads.map(item => item.status)).toEqual(['paused', 'paused']); + expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(([command]) => command === 'pause_download')) + .toHaveLength(1); + }); + it('cleans an accepted backend enqueue when queue reconciliation fails', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index e4b38d2..02068bb 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -35,11 +35,17 @@ type DownloadLifecycleOperation = { }; const downloadLifecycleOperations = new Map(); const preemptDispatch = ['dispatch'] as const; +const preemptStartSelected = ['dispatch', 'start-selected'] as const; let pendingStartupResume: Promise | null = null; type DownloadControlIntent = 'pause' | 'resume'; const downloadControlIntents = new Map(); +export interface ResumeDownloadOptions { + preserveQueuePosition?: boolean; + forceRequeue?: boolean; +} + // State events do not carry a lifecycle generation. Keep the intent that // initiated a control transition long enough for the listener to discard an // already-emitted event from the previous transition. @@ -711,7 +717,8 @@ interface DownloadState { ids: string | string[], queueId: string, targetIndex: number, - beforeId?: string | null + beforeId?: string | null, + selectedOrder?: readonly string[] ) => Promise; removeFromQueue: (id: string) => Promise; isAddModalOpen: boolean; @@ -749,7 +756,8 @@ interface DownloadState { removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise; pauseDownload: (id: string) => Promise; redownload: (id: string) => Promise; - resumeDownload: (id: string) => Promise; + resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise; + startSelected: (ids: string[]) => Promise; startQueue: (queueId: string) => Promise; pauseQueue: (queueId: string) => Promise; startAll: () => Promise; @@ -814,41 +822,81 @@ export const useDownloadStore = create((set, get) => { } }; - const resumeDownloadInternal = async (id: string): Promise => { + const resumeDownloadInternal = async ( + id: string, + options: ResumeDownloadOptions = {} + ): Promise => { await waitForPendingStartupResume(); - const targetItem = get().downloads.find(d => d.id === id); + let targetItem = get().downloads.find(d => d.id === id); if (!targetItem) return false; setDownloadControlIntent(id, 'resume'); + let previousStatus = targetItem.status; try { - if (targetItem.status === 'ready' || targetItem.status === 'staged') { + if (options.forceRequeue) { + // Fence any older enqueue before replacing a paused backend lifecycle. + // Otherwise a late addUri result can win the race and make this + // selection start outside the requested order. + const { pendingDispatch } = await invalidateDispatch(id); + if (pendingDispatch) await pendingDispatch; + targetItem = get().downloads.find(download => download.id === id); + if (!targetItem || !canStartDownload(targetItem.status)) { + clearDownloadControlIntent(id, 'resume'); + return false; + } + previousStatus = targetItem.status; + } + + const currentTargetItem = targetItem; + + if ( + options.forceRequeue && + currentTargetItem.status === 'paused' && + get().backendRegisteredIds.has(id) + ) { + await invoke('detach_download_for_reconfigure', { id }); + get().unregisterBackendIds([id]); + } + + if (options.forceRequeue) { + set(state => ({ + pendingOrder: state.pendingOrder.filter(value => value !== id) + })); + } + + if (currentTargetItem.status === 'ready' || currentTargetItem.status === 'staged') { get().updateDownload(id, { status: 'queued', hasBeenDispatched: true }); if (await dispatchItemInternal(id)) { return true; } - get().updateDownload(id, { status: targetItem.status }); + get().updateDownload(id, { status: currentTargetItem.status }); clearDownloadControlIntent(id, 'resume'); return false; } - const prevStatus = targetItem.status; + const prevStatus = currentTargetItem.status; const queueItems = get().downloads.filter(d => - (d.queueId || MAIN_QUEUE_ID) === (targetItem.queueId || MAIN_QUEUE_ID) + (d.queueId || MAIN_QUEUE_ID) === (currentTargetItem.queueId || MAIN_QUEUE_ID) ); const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1); + const queuePosition = options.preserveQueuePosition + ? currentTargetItem.queuePosition + : maxPos + 1; get().updateDownload(id, { status: 'queued', speed: '-', eta: '-', - queuePosition: maxPos + 1, + ...(queuePosition === undefined ? {} : { queuePosition }), lastTry: new Date().toISOString() }); - const resumedExisting = await invoke('resume_download', { - id, - queueId: targetItem.queueId || MAIN_QUEUE_ID - }); + const resumedExisting = options.forceRequeue + ? false + : await invoke('resume_download', { + id, + queueId: currentTargetItem.queueId || MAIN_QUEUE_ID + }); let dispatchSucceeded = resumedExisting; if (!dispatchSucceeded) { @@ -870,7 +918,10 @@ export const useDownloadStore = create((set, get) => { } } catch (e) { console.error("Failed to resume download:", e); - get().updateDownload(id, { status: targetItem.status }); + const current = get().downloads.find(download => download.id === id); + if (current?.status === 'queued') { + get().updateDownload(id, { status: previousStatus }); + } clearDownloadControlIntent(id, 'resume'); return false; } @@ -964,8 +1015,12 @@ export const useDownloadStore = create((set, get) => { queueReorderPromises.set(queueId, trackedOperation); return trackedOperation; }, - moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => { - const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId, selectedOrder) => { + const requestedIds = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + const ids = [...new Set(requestedIds)]; + const normalizedSelectedOrder = selectedOrder + ? [...new Set(selectedOrder)].filter(id => ids.includes(id)) + : undefined; if (ids.length === 0) return Promise.resolve(); // Dragging must be one serialized, atomic queue operation. This prevents @@ -975,7 +1030,11 @@ export const useDownloadStore = create((set, get) => { const operation = previousOperation.catch(() => undefined).then(async () => { const allDownloads = get().downloads; const queueItems = queueItemsForReordering(allDownloads, queueId); - const selectedItems = queueItems.filter(item => ids.includes(item.id)); + const selectedItems = normalizedSelectedOrder + ? normalizedSelectedOrder + .map(id => queueItems.find(item => item.id === id)) + .filter((item): item is DownloadItem => Boolean(item)) + : queueItems.filter(item => ids.includes(item.id)); if (selectedItems.length === 0) return; const selectedIds = new Set(selectedItems.map(item => item.id)); @@ -990,7 +1049,13 @@ export const useDownloadStore = create((set, get) => { const resolvedTargetIndex = anchoredTargetIndex >= 0 ? anchoredTargetIndex : Math.max(0, Math.min(targetIndex, unselectedItems.length)); - const reordered = moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex); + const reordered = normalizedSelectedOrder + ? [ + ...unselectedItems.slice(0, resolvedTargetIndex), + ...selectedItems, + ...unselectedItems.slice(resolvedTargetIndex) + ] + : moveSelectedBlockToIndex(queueItems, selectedIds, resolvedTargetIndex); set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) })); const registeredIdsToMove = selectedItems @@ -1345,7 +1410,7 @@ export const useDownloadStore = create((set, get) => { } finally { clearDownloadControlIntent(id, 'pause'); } - }, true, preemptDispatch), + }, true, preemptStartSelected), redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => { await waitForPendingStartupResume(); const targetItem = get().downloads.find(d => d.id === id); @@ -1396,13 +1461,103 @@ export const useDownloadStore = create((set, get) => { info(`Download ${id} redownloaded (queued)`); } }, true, preemptDispatch), - resumeDownload: (id) => runDownloadLifecycleOperation( + resumeDownload: (id, options) => runDownloadLifecycleOperation( id, 'resume', - () => resumeDownloadInternal(id), + () => resumeDownloadInternal(id, options), true, preemptDispatch ), + startSelected: (ids) => { + const orderedIds = [...new Set(ids)]; + if (orderedIds.length === 0) return Promise.resolve(0); + + return runDownloadLifecycleOperations(orderedIds, 'start-selected', async () => { + await waitForPendingStartupResume(); + const selectedByQueue = new Map(); + for (const id of orderedIds) { + const item = get().downloads.find(download => download.id === id); + if (!item || !canStartDownload(item.status)) continue; + const queueId = item.queueId || MAIN_QUEUE_ID; + const queueIds = selectedByQueue.get(queueId) || []; + queueIds.push(id); + selectedByQueue.set(queueId, queueIds); + } + + const selectedQueueGenerations = new Map( + Array.from(selectedByQueue.keys(), queueId => [ + queueId, + advanceQueueControlGeneration(queueId) + ] as const) + ); + + // Make the selection's order explicit before any capacity becomes + // available. This keeps the frontend projection and backend pending + // queue aligned even when the selected rows were not adjacent. + for (const [queueId, queueIds] of selectedByQueue) { + const generation = selectedQueueGenerations.get(queueId); + if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { + continue; + } + await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds); + } + + let startedCount = 0; + const startedByQueue = new Map(); + for (const id of orderedIds) { + const current = get().downloads.find(download => download.id === id); + if (!current || !canStartDownload(current.status)) continue; + const queueId = current.queueId || MAIN_QUEUE_ID; + const generation = selectedQueueGenerations.get(queueId); + if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { + continue; + } + const resumed = await resumeDownloadInternal(id, { + preserveQueuePosition: true, + forceRequeue: true + }); + if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) { + // A queue pause can win while this item's requeue is in flight. The + // pause action is allowed to preempt this bulk-start operation so + // this item cannot remain running after the user's pause request. + await get().pauseDownload(id); + continue; + } + if (resumed) { + startedCount += 1; + const startedIds = startedByQueue.get(queueId) || []; + startedIds.push(id); + startedByQueue.set(queueId, startedIds); + + // A requeued paused item is appended by the backend. Move the + // started prefix immediately so a newly available slot cannot let + // older pending work overtake the remaining selection while it is + // still being requeued. + if (isCurrentQueueControlGeneration(queueId, generation)) { + await get().moveManyInQueueToPosition( + startedIds, + queueId, + 0, + null, + startedIds + ); + } + } + } + + // Newly dispatched rows are now visible to the backend pending list. + // Reapply the same ordered block so existing pending work cannot remain + // ahead of a user-selected start request. + for (const [queueId, queueIds] of selectedByQueue) { + const generation = selectedQueueGenerations.get(queueId); + if (generation === undefined || !isCurrentQueueControlGeneration(queueId, generation)) { + continue; + } + await get().moveManyInQueueToPosition(queueIds, queueId, 0, null, queueIds); + } + return startedCount; + }); + }, startQueue: (queueId) => { const requestedGeneration = currentQueueControlGeneration(queueId); const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]); @@ -1457,7 +1612,7 @@ export const useDownloadStore = create((set, get) => { const backendPending = get().pendingOrder.includes(item.id); if (currentItem.status === 'queued' && backendRegistered && !backendPending) { - if (await get().resumeDownload(item.id)) { + if (await get().resumeDownload(item.id, { preserveQueuePosition: true })) { acceptedIds.push(item.id); } continue; diff --git a/src/utils/floatingPosition.test.ts b/src/utils/floatingPosition.test.ts index e1622a2..d281f46 100644 --- a/src/utils/floatingPosition.test.ts +++ b/src/utils/floatingPosition.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clampFloatingPosition } from './floatingPosition'; +import { clampFloatingPosition, positionFloatingSubmenu } from './floatingPosition'; describe('floating surface positioning', () => { it('keeps a variable-height menu inside the viewport', () => { @@ -15,4 +15,36 @@ describe('floating surface positioning', () => { y: 8, }); }); + + it('opens a submenu to the right when there is room', () => { + expect(positionFloatingSubmenu({ left: 200, right: 280, top: 120 }, 160, 180, 800, 600)).toEqual({ + x: 284, + y: 120, + side: 'right', + }); + }); + + it('flips a submenu to the left at the viewport edge', () => { + expect(positionFloatingSubmenu({ left: 700, right: 780, top: 120 }, 160, 180, 800, 600)).toEqual({ + x: 536, + y: 120, + side: 'left', + }); + }); + + it('clamps a submenu vertically when its trigger is near the bottom', () => { + expect(positionFloatingSubmenu({ left: 200, right: 280, top: 580 }, 160, 180, 800, 600)).toEqual({ + x: 284, + y: 412, + side: 'right', + }); + }); + + it('prefers the inline-start side in RTL when both sides fit', () => { + expect(positionFloatingSubmenu({ left: 400, right: 480, top: 120 }, 160, 180, 800, 600, 8, 4, 'left')).toEqual({ + x: 236, + y: 120, + side: 'left', + }); + }); }); diff --git a/src/utils/floatingPosition.ts b/src/utils/floatingPosition.ts index 8fcc99d..62d97a3 100644 --- a/src/utils/floatingPosition.ts +++ b/src/utils/floatingPosition.ts @@ -3,6 +3,12 @@ export interface FloatingPosition { y: number; } +export type FloatingSubmenuSide = 'left' | 'right'; + +export interface FloatingSubmenuPosition extends FloatingPosition { + side: FloatingSubmenuSide; +} + /** Keep a fixed-position surface inside the viewport with a small safe gutter. */ export const clampFloatingPosition = ( x: number, @@ -21,3 +27,37 @@ export const clampFloatingPosition = ( y: Math.min(Math.max(gutter, y), maxY), }; }; + +/** Place a submenu beside its trigger, flipping sides before clamping to the viewport. */ +export const positionFloatingSubmenu = ( + trigger: Pick, + width: number, + height: number, + viewportWidth: number, + viewportHeight: number, + gutter = 8, + gap = 4, + preferredSide: FloatingSubmenuSide = 'right' +): FloatingSubmenuPosition => { + const rightX = trigger.right + gap; + const leftX = trigger.left - width - gap; + const rightFits = rightX + width <= viewportWidth - gutter; + const leftFits = leftX >= gutter; + const preferredFits = preferredSide === 'right' ? rightFits : leftFits; + const alternateFits = preferredSide === 'right' ? leftFits : rightFits; + const side = preferredFits || !alternateFits + ? preferredSide + : preferredSide === 'right' ? 'left' : 'right'; + const preferredX = side === 'right' ? rightX : leftX; + const position = clampFloatingPosition( + preferredX, + trigger.top, + width, + height, + viewportWidth, + viewportHeight, + gutter + ); + + return { ...position, side }; +};