mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-06 17:38:06 +00:00
fix(downloads): harden duplicate resolution and row controls
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user