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
+3 -1
View File
@@ -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,
+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>
+4
View File
@@ -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',
+4
View File
@@ -50,6 +50,10 @@ const fa = {
rename: 'تغییر نام',
replace: 'جایگزینی',
skip: 'رد کردن',
applyToAll: 'اعمال برای همه',
renameAll: 'تغییر نام همه',
replaceAll: 'جایگزینی همه',
skipAll: 'رد کردن همه',
},
removeDownload: {
title: 'حذف دانلود',
+4
View File
@@ -50,6 +50,10 @@ const he = {
rename: 'שינוי שם',
replace: 'החלפה',
skip: 'דילוג',
applyToAll: 'החל על הכול',
renameAll: 'שינוי שם לכולם',
replaceAll: 'החלפה לכולם',
skipAll: 'דילוג על כולם',
},
removeDownload: {
title: 'הסרת הורדה',
+4
View File
@@ -50,6 +50,10 @@ const ru = {
rename: 'Переименовать',
replace: 'Заменить',
skip: 'Пропустить',
applyToAll: 'Применить ко всем',
renameAll: 'Переименовать все',
replaceAll: 'Заменить все',
skipAll: 'Пропустить все',
},
removeDownload: {
title: 'Удалить загрузку',
+4
View File
@@ -50,6 +50,10 @@ const uk = {
rename: 'Перейменувати',
replace: 'Замінити',
skip: 'Пропустити',
applyToAll: 'Застосувати до всіх',
renameAll: 'Перейменувати всі',
replaceAll: 'Замінити всі',
skipAll: 'Пропустити всі',
},
removeDownload: {
title: 'Видалити завантаження',
+4
View File
@@ -50,6 +50,10 @@ const zhCN = {
rename: '重命名',
replace: '替换',
skip: '跳过',
applyToAll: '应用于全部',
renameAll: '全部重命名',
replaceAll: '全部替换',
skipAll: '全部跳过',
},
removeDownload: {
title: '移除下载',
+51 -22
View File
@@ -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 {
+6 -2
View File
@@ -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;
}
+9
View File
@@ -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;
+7
View File
@@ -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 = {
+12 -1
View File
@@ -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',
});
+8 -5
View File
@@ -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',