fix(downloads): harden duplicate resolution and row controls

This commit is contained in:
NimBold
2026-07-29 18:28:46 +03:30
parent 546be23c91
commit 29bbb009b5
17 changed files with 248 additions and 89 deletions
+26 -7
View File
@@ -35,6 +35,7 @@ import {
appendRequestUrlsAfterVersion,
commonMediaFormatsForRows,
commonMediaQualitiesForRows,
durableDownloadUrl,
mediaFileNameForSelectedFormat,
mediaFormatForFormat,
mediaQualityForFormat,
@@ -625,7 +626,12 @@ export const AddDownloadsModal = () => {
proxy,
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
});
const nextDownloadUrl = meta.url || row.sourceUrl;
// Persist the stable source URL, not the resolved redirect. A
// redirect target may be a short-lived signed URL (for example,
// GitHub release assets) and would make later resumes fail after
// its expiry. The metadata response remains useful for filename,
// size, and resumability.
const nextDownloadUrl = durableDownloadUrl(row.sourceUrl);
setParsedItems(current => updateRowIfCurrent(
current,
row.id,
@@ -889,7 +895,11 @@ export const AddDownloadsModal = () => {
destinationOverrides[i]
);
const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed');
const urlMatch = store.downloads.find(d =>
normalizeComparableUrl(d.url) === normalizeComparableUrl(item.downloadUrl)
&& d.status !== 'failed'
&& d.status !== 'completed'
);
const hasBatchConflict = plannedTargets.some(target =>
downloadLocationEquals(
target.location,
@@ -899,8 +909,15 @@ export const AddDownloadsModal = () => {
platform.os
)
);
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) }, resolution: 'rename' });
if (urlMatch) {
newConflicts.push({
id: i.toString(),
fileName: finalFile,
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
resolution: 'rename',
replaceAllowed: !isTransferLocked(urlMatch.status),
existingDownloadId: urlMatch.id
});
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
@@ -1123,9 +1140,11 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) {
itemsToAdd[idx] = null;
continue;
if (!conflict?.replaceAllowed) {
const finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
: canonicalizeDownloadFileName(item.file);
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
const finalFile = item.isMedia
? mediaFileNameForSelectedFormat(item.file, item)
+59 -48
View File
@@ -18,7 +18,6 @@ import {
} from '../utils/downloadProgress';
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
getDownloadActionPosition,
getColumnGridColumn,
type DownloadColumnAlignment,
@@ -39,6 +38,7 @@ interface DownloadItemProps {
handleResumeSelected: () => void;
getCategoryIcon: (category: string) => React.ReactNode;
isSelected: boolean;
selectedDownloadCount: number;
selectedActionCounts: DownloadActionCounts;
isQueueReorderable: boolean;
isQueueDragSource: boolean;
@@ -61,6 +61,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
handleResumeSelected,
getCategoryIcon,
isSelected,
selectedDownloadCount,
selectedActionCounts,
isQueueReorderable,
isQueueDragSource,
@@ -78,12 +79,19 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const [isActionFocused, setIsActionFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const hasRowActions = download.status !== 'completed';
const pauseSelectionCount = isSelected && selectedActionCounts.pause > 1
const isBulkSelection = isSelected && selectedDownloadCount > 1;
const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0
? selectedActionCounts.pause
: null;
const resumeSelectionCount = isSelected && selectedActionCounts.resume > 1
const resumeSelectionCount = isBulkSelection && selectedActionCounts.resume > 0
? selectedActionCounts.resume
: null;
const canResumeAction = isBulkSelection
? selectedActionCounts.resume > 0
: canStartDownload(download.status);
const canPauseAction = isBulkSelection
? selectedActionCounts.pause > 0
: canPauseDownload(download.status);
const selectedCountLabel = (count: number | null) => count === null
? null
: t($ => $.downloadTable.summary.selected, { count });
@@ -115,11 +123,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const rowRect = row.getBoundingClientRect();
const horizontalViewportRect = horizontalViewport.getBoundingClientRect();
const verticalViewportRect = verticalViewport.getBoundingClientRect();
const rowPadding = Number.parseFloat(getComputedStyle(row).getPropertyValue('--download-row-padding-x'));
const nextPosition = getDownloadActionPosition(
rowRect,
horizontalViewportRect,
verticalViewportRect,
window.innerWidth
window.innerWidth,
Number.isFinite(rowPadding) ? rowPadding : undefined
);
setActionPosition(previous => (
@@ -133,7 +143,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
));
}, []);
React.useEffect(() => {
React.useLayoutEffect(() => {
if (!isActionVisible) return;
let frame: number | null = null;
@@ -351,11 +361,14 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const rowActions = hasRowActions ? (
<div
className="download-row-actions items-center gap-0.5"
className={`download-row-actions main-control-group ${actionPosition?.visibility === 'visible' ? 'is-positioned' : ''}`}
style={{
...actionPosition,
visibility: isActionVisible && actionPosition?.visibility === 'visible' ? 'visible' : 'hidden',
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
// Preserve the geometry helper's hidden result when the row is
// outside the scroll viewport. A fixed rail is not clipped by the
// list, while visible state remains CSS-owned so pointer handoff
// cannot leave two rails visible.
visibility: actionPosition?.visibility === 'hidden' ? 'hidden' : undefined,
}}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
@@ -377,50 +390,48 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
}}
>
{canPauseDownload(download.status) && (
<button
onClick={() => pauseSelectionCount !== null ? handlePauseSelected() : handlePause(download.id)}
className="app-icon-button h-7 w-7"
title={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
aria-label={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
>
<Pause size={14} fill="currentColor" />
{pauseSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(pauseSelectionCount)}
</span>
) : null}
</button>
)}
{canStartDownload(download.status) && (
<button
onClick={() => resumeSelectionCount !== null ? handleResumeSelected() : handleResume(download)}
className="app-icon-button h-7 w-7"
title={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
aria-label={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
>
<Play size={14} fill="currentColor" />
{resumeSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(resumeSelectionCount)}
</span>
) : null}
</button>
)}
<button
disabled={!canResumeAction}
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
className="app-icon-button main-control-button"
title={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
aria-label={resumeSelectionCount === null
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
>
<Play size={14} fill="currentColor" />
{resumeSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(resumeSelectionCount)}
</span>
) : null}
</button>
<button
disabled={!canPauseAction}
onClick={() => isBulkSelection ? handlePauseSelected() : handlePause(download.id)}
className="app-icon-button main-control-button"
title={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
aria-label={pauseSelectionCount === null
? t($ => $.downloads.actions.pause)
: `${t($ => $.downloads.actions.pause)} (${selectedCountLabel(pauseSelectionCount)})`}
>
<Pause size={14} fill="currentColor" />
{pauseSelectionCount !== null ? (
<span className="download-row-action-badge" aria-hidden="true">
{formatDownloadActionCount(pauseSelectionCount)}
</span>
) : null}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
}}
className="app-icon-button h-7 w-7"
className="app-icon-button main-control-button"
title={t($ => $.downloads.actions.options)}
>
<MoreVertical size={14} />
@@ -432,7 +443,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<div
ref={rowRef}
data-download-id={download.id}
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${isRowKeyboardFocused || isActionFocused ? 'has-keyboard-action-focus' : ''} ${isSelected ? 'is-selected' : ''} ${isQueueReorderable ? 'is-queue-reorderable' : ''} ${isQueueDragSource ? 'is-queue-drag-source' : ''}`}
style={{ minWidth: tableMinWidth }}
tabIndex={0}
onMouseEnter={() => {
+1
View File
@@ -2261,6 +2261,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
handleResumeSelected={handleResumeSelected}
getCategoryIcon={getCategoryIcon}
isSelected={selectedIds.has(d.id)}
selectedDownloadCount={selectedDownloads.length}
selectedActionCounts={selectedActionCounts}
isQueueReorderable={queueReorderableIds.has(d.id)}
isQueueDragSource={Boolean(queueDragState?.active && queueDragState.ids.includes(d.id))}
+42 -3
View File
@@ -37,7 +37,16 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
}, [onCancel]);
const updateResolution = (id: string, resolution: DuplicateResolution) => {
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
setConflicts(current => current.map(c => c.id === id ? { ...c, resolution } : c));
};
const canReplaceAll = conflicts.length > 0 && conflicts.every(conflict =>
conflict.replaceAllowed === true
);
const applyResolutionToAll = (resolution: DuplicateResolution) => {
if (resolution === 'replace' && !canReplaceAll) return;
setConflicts(current => current.map(conflict => ({ ...conflict, resolution })));
};
return (
@@ -60,7 +69,37 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
<h2 id="duplicate-downloads-title" className="text-lg font-semibold text-text-primary">{t($ => $.dialogs.duplicateDownloads.title)}</h2>
<p className="text-xs text-text-muted">{t($ => $.dialogs.duplicateDownloads.description)}</p>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border-modal/60 bg-sidebar-bg/30 px-4 py-2.5">
<span className="shrink-0 text-xs font-medium text-text-secondary">
{t($ => $.dialogs.duplicateDownloads.applyToAll)}
</span>
<div className="flex min-w-0 items-center justify-end gap-1.5" role="group" aria-label={t($ => $.dialogs.duplicateDownloads.applyToAll)}>
<button
type="button"
onClick={() => applyResolutionToAll('rename')}
className="app-button px-2.5 py-1 text-[11px]"
>
{t($ => $.dialogs.duplicateDownloads.renameAll)}
</button>
<button
type="button"
onClick={() => applyResolutionToAll('replace')}
disabled={!canReplaceAll}
className="app-button px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-40"
>
{t($ => $.dialogs.duplicateDownloads.replaceAll)}
</button>
<button
type="button"
onClick={() => applyResolutionToAll('skip')}
className="app-button px-2.5 py-1 text-[11px]"
>
{t($ => $.dialogs.duplicateDownloads.skipAll)}
</button>
</div>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
{conflicts.map(conflict => (
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
@@ -74,7 +113,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">{t($ => $.dialogs.duplicateDownloads.rename)}</option>
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
{conflict.replaceAllowed && <option value="replace">{t($ => $.dialogs.duplicateDownloads.replace)}</option>}
<option value="skip">{t($ => $.dialogs.duplicateDownloads.skip)}</option>
</select>
</div>