fix: repair download interaction flows

This commit is contained in:
NimBold
2026-06-19 17:25:40 +03:30
parent 85df857678
commit 3ca126e5db
12 changed files with 264 additions and 75 deletions
+2 -14
View File
@@ -1,4 +1,4 @@
import { initMediaDomains, isActiveDownloadStatus } from './utils/downloads';
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
import { useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable";
@@ -102,19 +102,7 @@ function App() {
if (previousSpeedLimit.current === globalSpeedLimit) return;
previousSpeedLimit.current = globalSpeedLimit;
// Convert to aria2 format (e.g. "1M", "500K")
let formattedLimit = null;
if (globalSpeedLimit) {
const match = globalSpeedLimit.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?(?:\/s)?$/i);
if (match) {
const amount = Number(match[1]);
if (Number.isFinite(amount) && amount > 0) {
const multipliers: Record<string, number> = { '': 1, k: 1024, m: 1048576, g: 1073741824, t: 1099511627776 };
const bytes = Math.round(amount * multipliers[match[2].toLowerCase()]);
formattedLimit = `${bytes}`;
}
}
}
const formattedLimit = normalizeSpeedLimitForBackend(globalSpeedLimit);
invoke('set_global_speed_limit', { limit: formattedLimit }).catch(error => {
console.error('Failed to apply global speed limit:', error);
+16 -6
View File
@@ -269,6 +269,7 @@ export const AddDownloadsModal = () => {
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
const [urls, setUrls] = useState('');
const [metadataRefreshNonce, setMetadataRefreshNonce] = useState(0);
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
@@ -464,7 +465,7 @@ export const AddDownloadsModal = () => {
active = false;
clearTimeout(timer);
};
}, [urls, pendingAddFilename, isSaveLocationManual]);
}, [urls, pendingAddFilename, isSaveLocationManual, metadataRefreshNonce]);
if (!isAddModalOpen) return null;
@@ -569,6 +570,7 @@ export const AddDownloadsModal = () => {
const idx = parseInt(res.id);
const item = itemsToAdd[idx];
if (!item) continue;
const conflict = conflicts.find(c => c.id === res.id);
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
@@ -601,8 +603,12 @@ export const AddDownloadsModal = () => {
}
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
let finalFile = item.file;
} else if (res.resolution === 'replace') {
if (conflict?.reason.type !== 'file') {
itemsToAdd[idx] = null;
continue;
}
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -734,9 +740,13 @@ export const AddDownloadsModal = () => {
/>
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span>
<button className="flex items-center gap-1.5 text-[11px] text-blue-500 hover:text-blue-400 font-medium">
<RefreshCw size={12} /> Refresh Metadata
</button>
<button
type="button"
onClick={() => setMetadataRefreshNonce(value => value + 1)}
className="flex items-center gap-1.5 text-[11px] text-blue-500 hover:text-blue-400 font-medium"
>
<RefreshCw size={12} /> Refresh Metadata
</button>
</div>
</div>
+30 -15
View File
@@ -1,9 +1,11 @@
import React from 'react';
import React, { useState } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { AlertTriangle } from 'lucide-react';
export const DeleteConfirmationModal: React.FC = () => {
const { deleteModalState, closeDeleteModal, removeDownload } = useDownloadStore();
const [errorMessage, setErrorMessage] = useState('');
const [isRemoving, setIsRemoving] = useState(false);
if (!deleteModalState.isOpen) return null;
@@ -11,58 +13,71 @@ export const DeleteConfirmationModal: React.FC = () => {
closeDeleteModal();
};
const handleRemoveFromList = () => {
const handleRemoveFromList = async () => {
if (deleteModalState.downloadId) {
removeDownload(deleteModalState.downloadId, false);
setIsRemoving(true);
try {
await removeDownload(deleteModalState.downloadId, false);
} catch (error) {
setErrorMessage(`Remove failed: ${String(error)}`);
setIsRemoving(false);
return;
}
}
closeDeleteModal();
};
const handleDeleteFile = () => {
const handleDeleteFile = async () => {
if (deleteModalState.downloadId) {
removeDownload(deleteModalState.downloadId, true);
setIsRemoving(true);
try {
await removeDownload(deleteModalState.downloadId, true);
} catch (error) {
setErrorMessage(`Delete failed: ${String(error)}`);
setIsRemoving(false);
return;
}
}
closeDeleteModal();
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
<div
<div
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
<div className="p-2 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-400" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">
Remove Download
</h2>
<h2 className="text-lg font-semibold text-text-primary m-0">Remove Download</h2>
</div>
{/* Body */}
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed">
Are you sure you want to remove this item from the list? You can also choose to delete the underlying file from your hard drive.
{errorMessage && <div className="mt-3 text-xs text-red-400">{errorMessage}</div>}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary"
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleRemoveFromList}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary"
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-border-modal hover:bg-border-modal/80 text-text-primary disabled:opacity-50"
>
Remove
</button>
<button
onClick={handleDeleteFile}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30"
disabled={isRemoving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
>
Delete file
</button>
+2 -2
View File
@@ -178,7 +178,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{download.status === 'queued' && queueIndex !== -1 && (
<>
<button
onClick={() => moveInQueue(download.id, 'Up')}
onClick={() => moveInQueue(download.id, 'up')}
disabled={queueIndex === 0}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Up"
@@ -186,7 +186,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<ArrowUp size={14} />
</button>
<button
onClick={() => moveInQueue(download.id, 'Down')}
onClick={() => moveInQueue(download.id, 'down')}
disabled={queueIndex === pendingOrder.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
+19 -9
View File
@@ -390,13 +390,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url).catch(error => {
showInteractionError('Could not copy address', error);
});
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy Address
</button>
@@ -404,8 +406,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<button
onClick={async () => {
setContextMenu(null);
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
navigator.clipboard.writeText(fullPath);
const fullPath = await getDownloadPath(contextItem);
if (!fullPath) {
showInteractionError('Could not copy file path', 'File name is missing');
return;
}
try {
await navigator.clipboard.writeText(fullPath);
} catch (error) {
showInteractionError('Could not copy file path', error);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
+1 -1
View File
@@ -44,7 +44,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">Rename</option>
<option value="replace">Replace</option>
{conflict.reason.type === 'file' && <option value="replace">Replace</option>}
<option value="skip">Skip</option>
</select>
</div>
+3 -3
View File
@@ -190,7 +190,7 @@ export const PropertiesModal = () => {
<span>
{item.status === 'completed'
? "File identity is read-only. Transfer settings are saved for redownload."
: "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."}
: "Transfer settings can be changed after stopping or pausing. Current transfers keep their existing backend options."}
</span>
</div>
)}
@@ -222,12 +222,12 @@ export const PropertiesModal = () => {
<label className="text-xs text-text-muted text-right">Speed</label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input disabled:opacity-50" />
Limit
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent" />
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} disabled={isTransferLocked} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
+1 -1
View File
@@ -109,7 +109,7 @@ type CommandMap = {
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string };
enqueue_many: { args: { items: any[] }; result: void };
move_in_queue: { args: { id: string; direction: 'Up' | 'Down' | 'Top' | 'Bottom' }; result: string[] };
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
+16 -18
View File
@@ -11,7 +11,7 @@ import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import type { MediaMetadata } from '../bindings/MediaMetadata';
import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, redactDownloadForPersistence } from '../utils/downloads';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
export type { DownloadCategory } from '../utils/downloads';
@@ -98,7 +98,7 @@ interface DownloadState {
queues: Queue[];
pendingOrder: string[];
setPendingOrder: (order: string[]) => void;
moveInQueue: (id: string, direction: 'Up' | 'Down') => Promise<void>;
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
pendingAddUrls: string;
@@ -235,7 +235,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -283,24 +283,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id);
if (item && deleteFile) {
const filepath = await resolveDownloadPath(item.destination || '~/Downloads', item.fileName);
const partialPaths = [`${filepath}.aria2`, `${filepath}.part`];
await invoke('trash_download_assets', { path: filepath, partialPaths });
}
if (item) {
try {
await invoke('remove_download', { id, filepath: null });
} catch (e) {
console.error("Failed to terminate download on deletion:", e);
return;
}
}
if (item && deleteFile) {
try {
const filepath = await resolveDownloadPath(item.destination || '~/Downloads', item.fileName);
const partialPaths = [`${filepath}.aria2`, `${filepath}.part`];
await invoke('trash_download_assets', { path: filepath, partialPaths });
} catch (e) {
console.error("Failed to trash file from disk:", e);
throw e;
}
}
set((state) => ({
downloads: state.downloads.filter(d => d.id !== id),
pendingOrder: state.pendingOrder.filter(x => x !== id)
@@ -385,7 +383,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || settings.globalSpeedLimit || null,
speed_limit: targetItem.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
@@ -448,7 +446,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: targetItem.fileName,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || settings.globalSpeedLimit || null,
speed_limit: targetItem.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
@@ -512,7 +510,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -631,7 +629,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || settings.globalSpeedLimit || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
+14
View File
@@ -31,6 +31,20 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
ACTIVE_DOWNLOAD_STATUSES.has(status);
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?(?:\/s)?$/i);
if (!match) return null;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return null;
const unit = match[2].toUpperCase();
return unit ? `${amount}${unit}` : `${amount}K`;
};
export const initMediaDomains = async () => {
try {
const domains = await invoke<string[]>('get_supported_media_domains');