fix(ui): harden queue controls and floating menus

This commit is contained in:
NimBold
2026-07-28 23:49:46 +03:30
parent 807663cf03
commit 23288ca4e8
10 changed files with 828 additions and 160 deletions
+31 -1
View File
@@ -53,6 +53,36 @@ fn reorder_selected_queue_tasks(
Some(reordered) Some(reordered)
} }
fn reorder_selected_queue_tasks_in_order(
queue_tasks: &[QueuedTask],
ids: &[String],
target_index: usize,
) -> Option<Vec<QueuedTask>> {
let selected_ids = ids.iter().collect::<HashSet<_>>();
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::<Vec<_>>();
if selected_tasks.is_empty() {
return None;
}
let unselected_tasks = queue_tasks
.iter()
.filter(|task| !selected_ids.contains(&task.id))
.cloned()
.collect::<Vec<_>>();
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<StdMutex<HashMap<String, Arc<Mutex<()>>>>>; type Aria2ControlLocks = Arc<StdMutex<HashMap<String, Arc<Mutex<()>>>>>;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -2293,7 +2323,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
.map(|index| pending[*index].clone()) .map(|index| pending[*index].clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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() { for (queue_index, pending_index) in queue_positions.iter().enumerate() {
pending[*pending_index] = reordered[queue_index].clone(); pending[*pending_index] = reordered[queue_index].clone();
} }
+46
View File
@@ -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] #[tokio::test]
async fn target_move_reorders_a_selected_block_and_clamps_the_target() { async fn target_move_reorders_a_selected_block_and_clamps_the_target() {
use firelink_lib::ipc::QueueDirection; 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] #[tokio::test]
async fn moving_one_queue_does_not_reorder_another_queue() { async fn moving_one_queue_does_not_reorder_another_queue() {
use firelink_lib::ipc::QueueDirection; use firelink_lib::ipc::QueueDirection;
+160 -122
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; 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 { useDownloadStore, DownloadItem, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { useDownloadProgressStore } from '../store/downloadProgressStore'; import { useDownloadProgressStore } from '../store/downloadProgressStore';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
@@ -6,7 +7,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar'; import { SidebarFilter } from './Sidebar';
import { import {
Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, 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 AlignLeft, AlignCenter, AlignRight, GripVertical
} from 'lucide-react'; } from 'lucide-react';
import { DownloadItem as DownloadItemComponent } from './DownloadItem'; import { DownloadItem as DownloadItemComponent } from './DownloadItem';
@@ -52,6 +53,8 @@ import {
moveSelectedBlockToIndex moveSelectedBlockToIndex
} from '../utils/queueOrdering'; } from '../utils/queueOrdering';
import { updateDownloadSelection } from '../utils/downloadSelection'; import { updateDownloadSelection } from '../utils/downloadSelection';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { FloatingQueueSubmenu } from './FloatingQueueSubmenu';
export interface DownloadTableStatusSummary { export interface DownloadTableStatusSummary {
summary: DownloadSummary; summary: DownloadSummary;
@@ -153,7 +156,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
openDeleteModal, openDeleteModal,
redownload, redownload,
moveInQueue, moveInQueue,
moveManyInQueueToPosition moveManyInQueueToPosition,
startAll,
pauseAll,
startSelected
} = useDownloadStore(); } = useDownloadStore();
const progressMap = useDownloadProgressStore(state => state.progressMap); const progressMap = useDownloadProgressStore(state => state.progressMap);
const { addToast } = useToast(); const { addToast } = useToast();
@@ -273,8 +279,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const [columnDragState, setColumnDragState] = useState<ColumnDragState | null>(null); const [columnDragState, setColumnDragState] = useState<ColumnDragState | null>(null);
const [columnDragOrder, setColumnDragOrder] = useState<DownloadTableColumnKey[] | null>(null); const [columnDragOrder, setColumnDragOrder] = useState<DownloadTableColumnKey[] | null>(null);
const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null); const [columnMenu, setColumnMenu] = useState<ColumnMenuState | null>(null);
const [columnMenuPosition, setColumnMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const columnMenuRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [columnDropFlashKey, setColumnDropFlashKey] = useState<DownloadTableColumnKey | null>(null); const [columnDropFlashKey, setColumnDropFlashKey] = useState<DownloadTableColumnKey | null>(null);
const [, setMenuViewportVersion] = useState(0);
const columnWidthsRef = useRef(columnWidths); const columnWidthsRef = useRef(columnWidths);
const columnOrderRef = useRef(columnOrder); const columnOrderRef = useRef(columnOrder);
const normalizedColumnWidths = columnWidthsRef.current.map((width, index) => const normalizedColumnWidths = columnWidthsRef.current.map((width, index) =>
@@ -689,30 +698,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}; };
const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => { const clampMenuPosition = useCallback((x: number, y: number, menuWidth: number, menuHeight: number) => {
const workspaceRect = downloadsViewRef.current?.getBoundingClientRect(); return clampFloatingPosition(
const minX = Math.max(8, (workspaceRect?.left ?? 0) + 8); x,
const minY = Math.max(8, (workspaceRect?.top ?? 0) + 8); y,
const maxX = Math.max(minX, Math.min( menuWidth,
window.innerWidth - menuWidth - 8, menuHeight,
(workspaceRect?.right ?? window.innerWidth) - menuWidth - 8 window.innerWidth,
)); window.innerHeight
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 openColumnMenu = (key: DownloadTableColumnKey, x: number, y: number) => {
const position = clampMenuPosition(x, y, 188, 220); const position = clampMenuPosition(x, y, 188, 220);
setContextMenu(null); setContextMenu(null);
setColumnMenu({ setColumnMenuPosition(position);
key, setColumnMenu({ key, x, y });
...position,
});
}; };
const setColumnAlignment = (alignment: DownloadColumnAlignment) => { const setColumnAlignment = (alignment: DownloadColumnAlignment) => {
@@ -837,12 +837,69 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}; };
}, []); }, []);
useEffect(() => { useLayoutEffect(() => {
if (!columnMenu && !contextMenu) return; if (!columnMenu || !columnMenuRef.current) return;
const updateMenuViewport = () => setMenuViewportVersion(version => version + 1);
window.addEventListener('resize', updateMenuViewport); const updateColumnMenuPosition = () => {
return () => window.removeEventListener('resize', updateMenuViewport); const menu = columnMenuRef.current;
}, [columnMenu, contextMenu]); 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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -1599,6 +1656,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
() => countDownloadActions(selectedDownloads), () => countDownloadActions(selectedDownloads),
[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 summaryDownloads = selectedDownloads.length > 0 ? selectedDownloads : filteredDownloads;
const downloadSummary = useMemo( const downloadSummary = useMemo(
() => summarizeDownloads(summaryDownloads, progressMap), () => summarizeDownloads(summaryDownloads, progressMap),
@@ -1702,10 +1763,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setLastSelectedId(menu.id); setLastSelectedId(menu.id);
} }
setColumnMenu(null); setColumnMenu(null);
setContextMenu({ const position = clampMenuPosition(menu.x, menu.y, 200, 300);
...menu, setContextMenuPosition(position);
...clampMenuPosition(menu.x, menu.y, 200, 300), setContextMenu(menu);
});
}, [clampMenuPosition]); }, [clampMenuPosition]);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => { const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
@@ -1804,15 +1864,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
} }
}, [showInteractionError]); }, [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 getCurrentSelectedDownloads = useCallback(() => {
const selected = selectedIdsRef.current; const selected = selectedIdsRef.current;
return useDownloadStore.getState().downloads.filter(download => selected.has(download.id)); return useDownloadStore.getState().downloads.filter(download => selected.has(download.id));
@@ -1841,10 +1892,40 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [getCurrentSelectedDownloads, handlePause, t]); }, [getCurrentSelectedDownloads, handlePause, t]);
const handleResumeSelected = useCallback(() => { const handleResumeSelected = useCallback(() => {
const items = getCurrentSelectedDownloads().filter(download => canStartDownload(download.status)); const ids = Array.from(selectedIdsRef.current);
if (items.length === 0) return; if (ids.length === 0) return;
void resumeItemsSequentially(items); void startSelected(ids).catch(error => {
}, [getCurrentSelectedDownloads, resumeItemsSequentially]); 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[]) => { const handleDelete = (ids: string | string[]) => {
openDeleteModal(ids); openDeleteModal(ids);
@@ -1924,12 +2005,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({ const columnAlignmentStyle = (key: DownloadTableColumnKey): React.CSSProperties => ({
'--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]], '--column-justify': COLUMN_ALIGNMENT_JUSTIFY[columnAlignments[key]],
} as React.CSSProperties); } 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 const queueDragPreviewItem = queueDragState?.active
? queueDragState.ids ? queueDragState.ids
.map(id => queueReorderableDownloads.find(download => download.id === id)) .map(id => queueReorderableDownloads.find(download => download.id === id))
@@ -1956,10 +2031,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button <button
className="main-control-button" className="main-control-button"
disabled={sortedDownloads.length === 0} disabled={!hasStartableDownloads}
onClick={() => { onClick={handleStartAll}
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
}}
title={t($ => $.downloadTable.resumeAll)} title={t($ => $.downloadTable.resumeAll)}
> >
<Play size={15} fill="currentColor" /> <Play size={15} fill="currentColor" />
@@ -1967,23 +2040,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button <button
className="main-control-button" className="main-control-button"
disabled={sortedDownloads.length === 0} disabled={!hasPausableDownloads}
onClick={() => { onClick={() => void handlePauseAll()}
const toPause = sortedDownloads.filter(d => canPauseDownload(d.status));
const nonResumableCount = toPause.filter(d => d.resumable === false).length;
if (nonResumableCount > 0) {
const confirmPause = window.confirm(
nonResumableCount === 1
? t($ => $.downloadTable.nonResumableOne)
: t($ => $.downloadTable.nonResumableMany, { count: nonResumableCount })
);
if (!confirmPause) {
return;
}
}
// Skip the individual check by passing a flag to handlePause, or just invoking directly.
toPause.forEach(d => handlePause(d.id, true));
}}
title={t($ => $.downloadTable.pauseAll)} title={t($ => $.downloadTable.pauseAll)}
> >
<Pause size={15} fill="currentColor" /> <Pause size={15} fill="currentColor" />
@@ -2246,10 +2304,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
</div> </div>
</div> </div>
{columnMenu && ( {columnMenu && createPortal(
<div <div
role="menu" role="menu"
className="download-column-menu app-modal fixed z-50 min-w-[188px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary" ref={columnMenuRef}
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }} style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
onClick={event => event.stopPropagation()} onClick={event => event.stopPropagation()}
> >
@@ -2285,14 +2344,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<GripVertical size={14} aria-hidden="true" /> <GripVertical size={14} aria-hidden="true" />
<span>{t($ => $.downloadTable.columnOptions.resetLayout)}</span> <span>{t($ => $.downloadTable.columnOptions.resetLayout)}</span>
</button> </button>
</div> </div>,
document.body
)} )}
{/* Floating Context Menu */} {/* Floating Context Menu */}
{contextMenu && contextItem && ( {contextMenu && contextItem && createPortal(
<div <div
role="menu" role="menu"
className="app-modal fixed z-50 min-w-[180px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary" ref={contextMenuRef}
className="app-modal fixed z-[70] min-w-[180px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ style={{
top: contextMenuPosition?.y, top: contextMenuPosition?.y,
left: contextMenuPosition?.x, left: contextMenuPosition?.x,
@@ -2340,28 +2401,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
)} )}
{itemsToQueue.length > 0 && ( {itemsToQueue.length > 0 && (
<div className="group relative"> <FloatingQueueSubmenu
<button label={t($ => $.downloadTable.addToQueue)}
type="button" queues={queues}
aria-haspopup="menu" onSelect={q => {
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center" setContextMenu(null);
> void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
{t($ => $.downloadTable.addToQueue)} showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
<ChevronRight size={14} className="download-context-menu-chevron" /> });
</button> }}
<div className="download-context-submenu absolute top-0 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50" role="menu"> />
{queues.map(q => (
<button key={q.id} role="menuitem" onClick={() => {
setContextMenu(null);
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
))}
</div>
</div>
)} )}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div> <div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -2463,28 +2512,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
)} )}
{contextItem.status !== 'completed' && ( {contextItem.status !== 'completed' && (
<div className="group relative"> <FloatingQueueSubmenu
<button label={t($ => $.downloadTable.addToQueue)}
type="button" queues={queues}
aria-haspopup="menu" onSelect={q => {
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center" setContextMenu(null);
> void assignToQueue([contextItem.id], q.id).catch(error => {
{t($ => $.downloadTable.addToQueue)} showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
<ChevronRight size={14} className="download-context-menu-chevron" /> });
</button> }}
<div className="download-context-submenu absolute top-0 min-w-[150px] bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-50" role="menu"> />
{queues.map(q => (
<button key={q.id} role="menuitem" onClick={() => {
setContextMenu(null);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
))}
</div>
</div>
)} )}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div> <div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
@@ -2547,7 +2584,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
</button> </button>
</> </>
)} )}
</div> </div>,
document.body
)} )}
</div> </div>
+195
View File
@@ -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<FloatingQueueSubmenuProps> = ({ label, queues, onSelect }) => {
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const closeTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<FloatingSubmenuPosition | null>(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 (
<div
ref={triggerRef}
className="relative"
onMouseEnter={openMenu}
onMouseLeave={scheduleClose}
onFocus={openMenu}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
scheduleClose();
}
}}
>
<button
type="button"
aria-haspopup="menu"
aria-expanded={isOpen}
className="w-full text-start px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
onClick={event => {
event.stopPropagation();
if (isOpen) closeMenu();
else openMenu();
}}
onKeyDown={event => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
if (event.key === openKey) {
event.preventDefault();
openMenu();
window.requestAnimationFrame(() => menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus());
}
if (event.key === closeKey && isOpen) {
event.preventDefault();
event.stopPropagation();
closeMenu();
event.currentTarget.focus();
}
}}
>
{label}
<ChevronRight size={14} aria-hidden="true" className="download-context-menu-chevron" />
</button>
{isOpen && createPortal(
<div
ref={menuRef}
role="menu"
data-side={position?.side}
className={`download-context-submenu fixed min-w-[150px] max-w-[min(280px,calc(100vw-16px))] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden bg-bg-modal border border-border-modal rounded-lg shadow-lg py-1.5 z-[80] ${position ? 'is-open' : ''}`}
style={{
top: position?.y ?? 8,
left: position?.x ?? 8,
}}
onMouseEnter={clearCloseTimer}
onMouseLeave={scheduleClose}
onFocus={clearCloseTimer}
onBlur={event => {
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<HTMLElement>('button')?.focus();
}}
onClick={event => event.stopPropagation()}
>
{queues.map(queue => (
<button
key={queue.id}
type="button"
role="menuitem"
title={queue.name}
className="w-full min-w-0 text-start px-3 py-2 hover:bg-item-hover transition-colors text-[12px] truncate"
onClick={() => onSelect(queue)}
>
{queue.name}
</button>
))}
</div>,
document.body
)}
</div>
);
};
+47 -5
View File
@@ -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 { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog'; import { save } from '@tauri-apps/plugin-dialog';
import { homeDir } from '@tauri-apps/api/path'; import { homeDir } from '@tauri-apps/api/path';
@@ -8,6 +9,7 @@ import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore'; import { useSettingsStore } from '../store/useSettingsStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { import {
MAX_LOG_LINES, MAX_LOG_LINES,
appendBoundedLogEntries, appendBoundedLogEntries,
@@ -26,6 +28,8 @@ export default function LogsView() {
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All'); const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); 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<HTMLDivElement>(null);
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden'); const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
const homeDirectoryRef = useRef(''); const homeDirectoryRef = useRef('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -161,12 +165,48 @@ export default function LogsView() {
e.preventDefault(); e.preventDefault();
const selection = window.getSelection()?.toString(); const selection = window.getSelection()?.toString();
if (selection && selection.trim().length > 0) { 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 }); setContextMenu({ x: e.clientX, y: e.clientY, text: selection });
} else { } else {
setContextMenu(null); 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 () => { const handleCopy = async () => {
if (contextMenu?.text) { if (contextMenu?.text) {
try { try {
@@ -354,11 +394,12 @@ export default function LogsView() {
</div> </div>
{/* Context Menu */} {/* Context Menu */}
{contextMenu && ( {contextMenu && createPortal(
<div <div
role="menu" role="menu"
className="app-modal fixed z-50 min-w-[150px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary" ref={contextMenuRef}
style={{ left: Math.min(contextMenu.x, window.innerWidth - 150), top: Math.min(contextMenu.y, window.innerHeight - 50) }} className="app-modal fixed z-[70] min-w-[150px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ left: contextMenuPosition?.x, top: contextMenuPosition?.y }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button <button
@@ -368,7 +409,8 @@ export default function LogsView() {
<Copy size={13} className="me-2 text-text-secondary" /> <Copy size={13} className="me-2 text-text-secondary" />
{t($ => $.logs.copy)} {t($ => $.logs.copy)}
</button> </button>
</div> </div>,
document.body
)} )}
</div> </div>
); );
+2 -9
View File
@@ -3074,8 +3074,6 @@ body.is-queue-dragging * {
} }
.download-context-submenu { .download-context-submenu {
inset-inline-start: 100%;
margin-inline-start: 0.25rem;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
pointer-events: none; pointer-events: none;
@@ -3087,8 +3085,7 @@ body.is-queue-dragging * {
visibility 0s linear 120ms; visibility 0s linear 120ms;
} }
.group:hover > .download-context-submenu, .download-context-submenu.is-open {
.group:focus-within > .download-context-submenu {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
@@ -3096,11 +3093,7 @@ body.is-queue-dragging * {
transition-delay: 0s; transition-delay: 0s;
} }
.app-workspace--sidebar-right .download-context-submenu { .download-context-submenu[data-side="left"] {
inset-inline-start: auto;
inset-inline-end: 100%;
margin-inline-start: 0;
margin-inline-end: 0.25rem;
transform-origin: top right; transform-origin: top right;
} }
+97
View File
@@ -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 () => { it('cleans an accepted backend enqueue when queue reconciliation fails', async () => {
useDownloadStore.setState({ useDownloadStore.setState({
downloads: [ downloads: [
+177 -22
View File
@@ -35,11 +35,17 @@ type DownloadLifecycleOperation = {
}; };
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>(); const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
const preemptDispatch = ['dispatch'] as const; const preemptDispatch = ['dispatch'] as const;
const preemptStartSelected = ['dispatch', 'start-selected'] as const;
let pendingStartupResume: Promise<void> | null = null; let pendingStartupResume: Promise<void> | null = null;
type DownloadControlIntent = 'pause' | 'resume'; type DownloadControlIntent = 'pause' | 'resume';
const downloadControlIntents = new Map<string, DownloadControlIntent>(); const downloadControlIntents = new Map<string, DownloadControlIntent>();
export interface ResumeDownloadOptions {
preserveQueuePosition?: boolean;
forceRequeue?: boolean;
}
// State events do not carry a lifecycle generation. Keep the intent that // State events do not carry a lifecycle generation. Keep the intent that
// initiated a control transition long enough for the listener to discard an // initiated a control transition long enough for the listener to discard an
// already-emitted event from the previous transition. // already-emitted event from the previous transition.
@@ -711,7 +717,8 @@ interface DownloadState {
ids: string | string[], ids: string | string[],
queueId: string, queueId: string,
targetIndex: number, targetIndex: number,
beforeId?: string | null beforeId?: string | null,
selectedOrder?: readonly string[]
) => Promise<void>; ) => Promise<void>;
removeFromQueue: (id: string) => Promise<void>; removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean; isAddModalOpen: boolean;
@@ -749,7 +756,8 @@ interface DownloadState {
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>; removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
pauseDownload: (id: string) => Promise<void>; pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>; redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<boolean>; resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
startSelected: (ids: string[]) => Promise<number>;
startQueue: (queueId: string) => Promise<string[]>; startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>; pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>; startAll: () => Promise<number>;
@@ -814,41 +822,81 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} }
}; };
const resumeDownloadInternal = async (id: string): Promise<boolean> => { const resumeDownloadInternal = async (
id: string,
options: ResumeDownloadOptions = {}
): Promise<boolean> => {
await waitForPendingStartupResume(); await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id); let targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return false; if (!targetItem) return false;
setDownloadControlIntent(id, 'resume'); setDownloadControlIntent(id, 'resume');
let previousStatus = targetItem.status;
try { 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 }); get().updateDownload(id, { status: 'queued', hasBeenDispatched: true });
if (await dispatchItemInternal(id)) { if (await dispatchItemInternal(id)) {
return true; return true;
} }
get().updateDownload(id, { status: targetItem.status }); get().updateDownload(id, { status: currentTargetItem.status });
clearDownloadControlIntent(id, 'resume'); clearDownloadControlIntent(id, 'resume');
return false; return false;
} }
const prevStatus = targetItem.status; const prevStatus = currentTargetItem.status;
const queueItems = get().downloads.filter(d => 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 maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = options.preserveQueuePosition
? currentTargetItem.queuePosition
: maxPos + 1;
get().updateDownload(id, { get().updateDownload(id, {
status: 'queued', status: 'queued',
speed: '-', speed: '-',
eta: '-', eta: '-',
queuePosition: maxPos + 1, ...(queuePosition === undefined ? {} : { queuePosition }),
lastTry: new Date().toISOString() lastTry: new Date().toISOString()
}); });
const resumedExisting = await invoke('resume_download', { const resumedExisting = options.forceRequeue
id, ? false
queueId: targetItem.queueId || MAIN_QUEUE_ID : await invoke('resume_download', {
}); id,
queueId: currentTargetItem.queueId || MAIN_QUEUE_ID
});
let dispatchSucceeded = resumedExisting; let dispatchSucceeded = resumedExisting;
if (!dispatchSucceeded) { if (!dispatchSucceeded) {
@@ -870,7 +918,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} }
} catch (e) { } catch (e) {
console.error("Failed to resume download:", 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'); clearDownloadControlIntent(id, 'resume');
return false; return false;
} }
@@ -964,8 +1015,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
queueReorderPromises.set(queueId, trackedOperation); queueReorderPromises.set(queueId, trackedOperation);
return trackedOperation; return trackedOperation;
}, },
moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId) => { moveManyInQueueToPosition: (idOrIds, queueId, targetIndex, beforeId, selectedOrder) => {
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; 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(); if (ids.length === 0) return Promise.resolve();
// Dragging must be one serialized, atomic queue operation. This prevents // Dragging must be one serialized, atomic queue operation. This prevents
@@ -975,7 +1030,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const operation = previousOperation.catch(() => undefined).then(async () => { const operation = previousOperation.catch(() => undefined).then(async () => {
const allDownloads = get().downloads; const allDownloads = get().downloads;
const queueItems = queueItemsForReordering(allDownloads, queueId); 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; if (selectedItems.length === 0) return;
const selectedIds = new Set(selectedItems.map(item => item.id)); const selectedIds = new Set(selectedItems.map(item => item.id));
@@ -990,7 +1049,13 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const resolvedTargetIndex = anchoredTargetIndex >= 0 const resolvedTargetIndex = anchoredTargetIndex >= 0
? anchoredTargetIndex ? anchoredTargetIndex
: Math.max(0, Math.min(targetIndex, unselectedItems.length)); : 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) })); set(state => ({ downloads: applyQueueOrder(state.downloads, queueId, reordered) }));
const registeredIdsToMove = selectedItems const registeredIdsToMove = selectedItems
@@ -1345,7 +1410,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} finally { } finally {
clearDownloadControlIntent(id, 'pause'); clearDownloadControlIntent(id, 'pause');
} }
}, true, preemptDispatch), }, true, preemptStartSelected),
redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => { redownload: (id) => runDownloadLifecycleOperation(id, 'redownload', async () => {
await waitForPendingStartupResume(); await waitForPendingStartupResume();
const targetItem = get().downloads.find(d => d.id === id); const targetItem = get().downloads.find(d => d.id === id);
@@ -1396,13 +1461,103 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
info(`Download ${id} redownloaded (queued)`); info(`Download ${id} redownloaded (queued)`);
} }
}, true, preemptDispatch), }, true, preemptDispatch),
resumeDownload: (id) => runDownloadLifecycleOperation( resumeDownload: (id, options) => runDownloadLifecycleOperation(
id, id,
'resume', 'resume',
() => resumeDownloadInternal(id), () => resumeDownloadInternal(id, options),
true, true,
preemptDispatch 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<string, string[]>();
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<string, string[]>();
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) => { startQueue: (queueId) => {
const requestedGeneration = currentQueueControlGeneration(queueId); const requestedGeneration = currentQueueControlGeneration(queueId);
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]); const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
@@ -1457,7 +1612,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const backendPending = get().pendingOrder.includes(item.id); const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'queued' && backendRegistered && !backendPending) { if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
if (await get().resumeDownload(item.id)) { if (await get().resumeDownload(item.id, { preserveQueuePosition: true })) {
acceptedIds.push(item.id); acceptedIds.push(item.id);
} }
continue; continue;
+33 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { clampFloatingPosition } from './floatingPosition'; import { clampFloatingPosition, positionFloatingSubmenu } from './floatingPosition';
describe('floating surface positioning', () => { describe('floating surface positioning', () => {
it('keeps a variable-height menu inside the viewport', () => { it('keeps a variable-height menu inside the viewport', () => {
@@ -15,4 +15,36 @@ describe('floating surface positioning', () => {
y: 8, 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',
});
});
}); });
+40
View File
@@ -3,6 +3,12 @@ export interface FloatingPosition {
y: number; 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. */ /** Keep a fixed-position surface inside the viewport with a small safe gutter. */
export const clampFloatingPosition = ( export const clampFloatingPosition = (
x: number, x: number,
@@ -21,3 +27,37 @@ export const clampFloatingPosition = (
y: Math.min(Math.max(gutter, y), maxY), 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<DOMRect, 'left' | 'right' | 'top'>,
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 };
};