mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-28 21:09:37 +00:00
fix(ui): harden queue controls and floating menus
This commit is contained in:
+31
-1
@@ -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<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<()>>>>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -2293,7 +2323,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.map(|index| pending[*index].clone())
|
||||
.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() {
|
||||
pending[*pending_index] = reordered[queue_index].clone();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+160
-122
@@ -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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter, onSummaryC
|
||||
const [columnDragState, setColumnDragState] = useState<ColumnDragState | null>(null);
|
||||
const [columnDragOrder, setColumnDragOrder] = useState<DownloadTableColumnKey[] | 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 [, 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ 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<DownloadTableProps> = ({ filter, onSummaryC
|
||||
|
||||
<button
|
||||
className="main-control-button"
|
||||
disabled={sortedDownloads.length === 0}
|
||||
onClick={() => {
|
||||
void resumeItemsSequentially(sortedDownloads.filter(d => canStartDownload(d.status)));
|
||||
}}
|
||||
disabled={!hasStartableDownloads}
|
||||
onClick={handleStartAll}
|
||||
title={t($ => $.downloadTable.resumeAll)}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
@@ -1967,23 +2040,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
|
||||
<button
|
||||
className="main-control-button"
|
||||
disabled={sortedDownloads.length === 0}
|
||||
onClick={() => {
|
||||
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));
|
||||
}}
|
||||
disabled={!hasPausableDownloads}
|
||||
onClick={() => void handlePauseAll()}
|
||||
title={t($ => $.downloadTable.pauseAll)}
|
||||
>
|
||||
<Pause size={15} fill="currentColor" />
|
||||
@@ -2246,10 +2304,11 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{columnMenu && (
|
||||
{columnMenu && createPortal(
|
||||
<div
|
||||
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 }}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
@@ -2285,14 +2344,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
<GripVertical size={14} aria-hidden="true" />
|
||||
<span>{t($ => $.downloadTable.columnOptions.resetLayout)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Floating Context Menu */}
|
||||
{contextMenu && contextItem && (
|
||||
{contextMenu && contextItem && createPortal(
|
||||
<div
|
||||
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={{
|
||||
top: contextMenuPosition?.y,
|
||||
left: contextMenuPosition?.x,
|
||||
@@ -2340,28 +2401,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
)}
|
||||
|
||||
{itemsToQueue.length > 0 && (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
|
||||
>
|
||||
{t($ => $.downloadTable.addToQueue)}
|
||||
<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>
|
||||
<FloatingQueueSubmenu
|
||||
label={t($ => $.downloadTable.addToQueue)}
|
||||
queues={queues}
|
||||
onSelect={q => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue(itemsToQueue.map(item => item.id), q.id).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.moveManyFailed), error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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' && (
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors flex justify-between items-center"
|
||||
>
|
||||
{t($ => $.downloadTable.addToQueue)}
|
||||
<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>
|
||||
<FloatingQueueSubmenu
|
||||
label={t($ => $.downloadTable.addToQueue)}
|
||||
queues={queues}
|
||||
onSelect={q => {
|
||||
setContextMenu(null);
|
||||
void assignToQueue([contextItem.id], q.id).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.moveOneFailed), error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<LogEntry[]>([]);
|
||||
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('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<HTMLDivElement>(null);
|
||||
const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden');
|
||||
const homeDirectoryRef = useRef('');
|
||||
const scrollRef = useRef<HTMLDivElement>(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() {
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu && (
|
||||
{contextMenu && createPortal(
|
||||
<div
|
||||
role="menu"
|
||||
className="app-modal fixed z-50 min-w-[150px] overflow-visible py-1.5 text-[12px] font-medium text-text-primary"
|
||||
style={{ left: Math.min(contextMenu.x, window.innerWidth - 150), top: Math.min(contextMenu.y, window.innerHeight - 50) }}
|
||||
ref={contextMenuRef}
|
||||
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()}
|
||||
>
|
||||
<button
|
||||
@@ -368,7 +409,8 @@ export default function LogsView() {
|
||||
<Copy size={13} className="me-2 text-text-secondary" />
|
||||
{t($ => $.logs.copy)}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-9
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+177
-22
@@ -35,11 +35,17 @@ type DownloadLifecycleOperation = {
|
||||
};
|
||||
const downloadLifecycleOperations = new Map<string, DownloadLifecycleOperation>();
|
||||
const preemptDispatch = ['dispatch'] as const;
|
||||
const preemptStartSelected = ['dispatch', 'start-selected'] as const;
|
||||
let pendingStartupResume: Promise<void> | null = null;
|
||||
|
||||
type DownloadControlIntent = 'pause' | 'resume';
|
||||
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
|
||||
// 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<void>;
|
||||
removeFromQueue: (id: string) => Promise<void>;
|
||||
isAddModalOpen: boolean;
|
||||
@@ -749,7 +756,8 @@ interface DownloadState {
|
||||
removeDownload: (id: string, deleteFile?: boolean, preserveResumable?: boolean) => Promise<void>;
|
||||
pauseDownload: (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[]>;
|
||||
pauseQueue: (queueId: string) => 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();
|
||||
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<DownloadState>((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<DownloadState>((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<DownloadState>((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<DownloadState>((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<DownloadState>((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<DownloadState>((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<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) => {
|
||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user