mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-30 14:12:18 +00:00
fix(downloads): harden duplicate resolution and row controls
This commit is contained in:
@@ -270,6 +270,8 @@ fn retry_metadata_with_cookies(
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct MetadataResponse {
|
||||
// Keep the caller's durable source URL here. Resolved redirect targets
|
||||
// can be short-lived signed URLs and must not become download identity.
|
||||
url: String,
|
||||
filename: String,
|
||||
size: String,
|
||||
@@ -2048,7 +2050,7 @@ async fn fetch_metadata(
|
||||
}
|
||||
|
||||
Ok(MetadataResponse {
|
||||
url: current_url,
|
||||
url,
|
||||
filename,
|
||||
size: size_str,
|
||||
size_bytes,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -50,6 +50,10 @@ const common = {
|
||||
rename: 'Rename',
|
||||
replace: 'Replace',
|
||||
skip: 'Skip',
|
||||
applyToAll: 'Apply to all',
|
||||
renameAll: 'Rename all',
|
||||
replaceAll: 'Replace all',
|
||||
skipAll: 'Skip all',
|
||||
},
|
||||
removeDownload: {
|
||||
title: 'Remove Download',
|
||||
|
||||
@@ -50,6 +50,10 @@ const fa = {
|
||||
rename: 'تغییر نام',
|
||||
replace: 'جایگزینی',
|
||||
skip: 'رد کردن',
|
||||
applyToAll: 'اعمال برای همه',
|
||||
renameAll: 'تغییر نام همه',
|
||||
replaceAll: 'جایگزینی همه',
|
||||
skipAll: 'رد کردن همه',
|
||||
},
|
||||
removeDownload: {
|
||||
title: 'حذف دانلود',
|
||||
|
||||
@@ -50,6 +50,10 @@ const he = {
|
||||
rename: 'שינוי שם',
|
||||
replace: 'החלפה',
|
||||
skip: 'דילוג',
|
||||
applyToAll: 'החל על הכול',
|
||||
renameAll: 'שינוי שם לכולם',
|
||||
replaceAll: 'החלפה לכולם',
|
||||
skipAll: 'דילוג על כולם',
|
||||
},
|
||||
removeDownload: {
|
||||
title: 'הסרת הורדה',
|
||||
|
||||
@@ -50,6 +50,10 @@ const ru = {
|
||||
rename: 'Переименовать',
|
||||
replace: 'Заменить',
|
||||
skip: 'Пропустить',
|
||||
applyToAll: 'Применить ко всем',
|
||||
renameAll: 'Переименовать все',
|
||||
replaceAll: 'Заменить все',
|
||||
skipAll: 'Пропустить все',
|
||||
},
|
||||
removeDownload: {
|
||||
title: 'Удалить загрузку',
|
||||
|
||||
@@ -50,6 +50,10 @@ const uk = {
|
||||
rename: 'Перейменувати',
|
||||
replace: 'Замінити',
|
||||
skip: 'Пропустити',
|
||||
applyToAll: 'Застосувати до всіх',
|
||||
renameAll: 'Перейменувати всі',
|
||||
replaceAll: 'Замінити всі',
|
||||
skipAll: 'Пропустити всі',
|
||||
},
|
||||
removeDownload: {
|
||||
title: 'Видалити завантаження',
|
||||
|
||||
@@ -50,6 +50,10 @@ const zhCN = {
|
||||
rename: '重命名',
|
||||
replace: '替换',
|
||||
skip: '跳过',
|
||||
applyToAll: '应用于全部',
|
||||
renameAll: '全部重命名',
|
||||
replaceAll: '全部替换',
|
||||
skipAll: '全部跳过',
|
||||
},
|
||||
removeDownload: {
|
||||
title: '移除下载',
|
||||
|
||||
+51
-22
@@ -2944,46 +2944,74 @@ body.is-queue-dragging * {
|
||||
.download-row-actions {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
direction: ltr;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
border: 1px solid hsl(var(--text-primary) / 0.32);
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--surface-overlay));
|
||||
box-shadow:
|
||||
0 0 0 1px hsl(var(--main-bg) / 0.72),
|
||||
0 2px 8px hsl(var(--shadow-color));
|
||||
width: max-content;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
/* Rails are mutually exclusive during pointer handoff. Animating the
|
||||
* container would leave the previous fixed rail visible while the next
|
||||
* row's rail appears, producing a brief duplicate-control flash. */
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.download-row.has-visible-actions .download-row-actions {
|
||||
.download-row-actions.main-control-group {
|
||||
overflow: visible;
|
||||
z-index: 12;
|
||||
isolation: isolate;
|
||||
background: hsl(var(--surface-overlay));
|
||||
}
|
||||
|
||||
.download-row:hover .download-row-actions.is-positioned,
|
||||
.download-row.has-keyboard-action-focus .download-row-actions.is-positioned,
|
||||
.download-row:has(> .download-row-actions:hover) .download-row-actions.is-positioned {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The hovered row owns the rail during pointer interaction. Keep any stale
|
||||
* keyboard-focused fixed rail hidden while the pointer is over another row.
|
||||
* The second :has check keeps a rail clickable when its fixed child is
|
||||
* hovered. */
|
||||
.download-table-list-content:has(.download-row:hover, .download-row-actions:hover)
|
||||
.download-row:not(:hover):not(:has(> .download-row-actions:hover))
|
||||
.download-row-actions.is-positioned {
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button,
|
||||
.download-row-actions .app-icon-button:hover:not(:disabled),
|
||||
.download-row-actions .app-icon-button:active:not(:disabled) {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 26px;
|
||||
border-radius: 0;
|
||||
border-inline-end: 1px solid hsl(var(--border-color));
|
||||
background: transparent;
|
||||
color: hsl(var(--text-primary));
|
||||
color: hsl(var(--text-secondary));
|
||||
transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease;
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button:last-child {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
.download-row-action-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
inset-inline-end: -5px;
|
||||
top: 1px;
|
||||
inset-inline-end: 1px;
|
||||
display: inline-flex;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 3px;
|
||||
@@ -2995,6 +3023,7 @@ body.is-queue-dragging * {
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -3008,15 +3037,15 @@ body.is-queue-dragging * {
|
||||
|
||||
.download-row-actions .app-icon-button:hover:not(:disabled),
|
||||
.download-row-actions .app-icon-button:focus-visible {
|
||||
background: hsl(var(--accent-color) / 0.14);
|
||||
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.34);
|
||||
color: hsl(var(--accent-color));
|
||||
background: hsl(var(--item-hover));
|
||||
box-shadow: none;
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.download-row-actions .app-icon-button:active:not(:disabled) {
|
||||
background: hsl(var(--accent-color) / 0.22);
|
||||
box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.46);
|
||||
color: hsl(var(--accent-color));
|
||||
background: hsl(var(--item-hover));
|
||||
box-shadow: none;
|
||||
color: hsl(var(--text-primary));
|
||||
}
|
||||
|
||||
.download-column-menu {
|
||||
|
||||
@@ -941,8 +941,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
get().updateDownload(id, { hasBeenDispatched: true });
|
||||
return true;
|
||||
} else {
|
||||
console.error("Failed to re-enqueue for resume");
|
||||
get().updateDownload(id, { status: prevStatus });
|
||||
const dispatchError = get().downloads.find(download => download.id === id)?.lastError;
|
||||
console.error("Failed to re-enqueue for resume:", dispatchError || "backend rejected the enqueue request");
|
||||
get().updateDownload(id, {
|
||||
status: prevStatus,
|
||||
...(dispatchError ? { lastError: dispatchError } : {})
|
||||
});
|
||||
clearDownloadControlIntent(id, 'resume');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
commonMediaFormatsForRows,
|
||||
canSubmitMetadataRows,
|
||||
commonMediaQualitiesForRows,
|
||||
durableDownloadUrl,
|
||||
mediaFormatSelectorForRow,
|
||||
mediaFileNameForSelectedFormat,
|
||||
mediaFormatForFormat,
|
||||
@@ -35,6 +36,14 @@ const row = (
|
||||
});
|
||||
|
||||
describe('add download metadata workflow', () => {
|
||||
it('keeps the stable source URL instead of persisting a resolved redirect', () => {
|
||||
const sourceUrl = 'https://github.com/example/project/releases/download/v1/file.zip';
|
||||
const signedRedirect = 'https://release-assets.githubusercontent.com/github-production-release-asset/asset?se=2099-01-01T00%3A00%3A00Z&sig=redacted';
|
||||
|
||||
expect(durableDownloadUrl(sourceUrl)).toBe(sourceUrl);
|
||||
expect(durableDownloadUrl(sourceUrl)).not.toBe(signedRedirect);
|
||||
});
|
||||
|
||||
it('preserves rows by normalized source URL and creates only new rows', () => {
|
||||
const existing = row({ file: 'server-name.zip' });
|
||||
let nextId = 0;
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface AddDownloadDraftRow {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect targets are request-time details, not durable download identity.
|
||||
* Providers such as GitHub sign those targets with short-lived credentials,
|
||||
* so retries must start from the source URL and resolve a fresh target.
|
||||
*/
|
||||
export const durableDownloadUrl = (sourceUrl: string): string => sourceUrl.trim();
|
||||
|
||||
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'sftp:']);
|
||||
|
||||
type ParsedInput = {
|
||||
|
||||
@@ -73,7 +73,18 @@ describe('download table column preferences', () => {
|
||||
800
|
||||
)).toMatchObject({
|
||||
right: 8,
|
||||
height: 30,
|
||||
height: 28,
|
||||
visibility: 'visible',
|
||||
});
|
||||
|
||||
expect(getDownloadActionPosition(
|
||||
{ top: 40, right: 400, bottom: 72, left: 100 },
|
||||
viewport,
|
||||
viewport,
|
||||
800,
|
||||
16
|
||||
)).toMatchObject({
|
||||
right: 416,
|
||||
visibility: 'visible',
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ export const DEFAULT_COLUMN_ORDER = [
|
||||
|
||||
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
|
||||
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
|
||||
// Width of the compact action rail shown while hovering or focusing a row.
|
||||
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 64;
|
||||
// Keep the viewport-anchored rail clear of the window edge and scrollbar.
|
||||
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
|
||||
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 30;
|
||||
// Match the 28px master-control group height.
|
||||
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 28;
|
||||
export const DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE = 1;
|
||||
|
||||
export interface DownloadActionViewportRect {
|
||||
@@ -40,7 +39,8 @@ export const getDownloadActionPosition = (
|
||||
rowRect: DownloadActionViewportRect,
|
||||
horizontalViewportRect: DownloadActionViewportRect,
|
||||
verticalViewportRect: DownloadActionViewportRect,
|
||||
windowWidth: number
|
||||
windowWidth: number,
|
||||
actionInset = DOWNLOAD_ACTIONS_VIEWPORT_INSET
|
||||
): DownloadActionPosition => {
|
||||
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
|
||||
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
|
||||
@@ -52,10 +52,13 @@ export const getDownloadActionPosition = (
|
||||
visibleWidth > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE;
|
||||
const actionHeight = Math.min(DOWNLOAD_ACTIONS_MAX_HEIGHT, rowRect.bottom - rowRect.top, visibleHeight);
|
||||
const actionRightEdge = Math.min(rowRect.right, horizontalViewportRect.right);
|
||||
const safeActionInset = Number.isFinite(actionInset)
|
||||
? Math.max(0, actionInset)
|
||||
: DOWNLOAD_ACTIONS_VIEWPORT_INSET;
|
||||
|
||||
return {
|
||||
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
|
||||
right: Math.max(0, windowWidth - actionRightEdge) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
|
||||
right: Math.max(0, windowWidth - actionRightEdge) + safeActionInset,
|
||||
height: actionHeight,
|
||||
overflow: actionHeight < rowRect.bottom - rowRect.top ? 'hidden' : 'visible',
|
||||
visibility: isVisible ? 'visible' : 'hidden',
|
||||
|
||||
Reference in New Issue
Block a user