diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2bcc45..43cd004 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index a167bdf..570033e 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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) diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 246db3a..e054da8 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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(({ handleResumeSelected, getCategoryIcon, isSelected, + selectedDownloadCount, selectedActionCounts, isQueueReorderable, isQueueDragSource, @@ -78,12 +79,19 @@ export const DownloadItem = React.memo(({ const [isActionFocused, setIsActionFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); 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(({ 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(({ )); }, []); - React.useEffect(() => { + React.useLayoutEffect(() => { if (!isActionVisible) return; let frame: number | null = null; @@ -351,11 +361,14 @@ export const DownloadItem = React.memo(({ const rowActions = hasRowActions ? ( + +
{conflicts.map(conflict => (
@@ -74,7 +113,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir className="app-control w-24 shrink-0 px-2 py-1 text-xs" > - {conflict.reason.type === 'file' && conflict.replaceAllowed && } + {conflict.replaceAllowed && }
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 6d58ace..d0270f7 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 3bc66b4..205dee5 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -50,6 +50,10 @@ const fa = { rename: 'تغییر نام', replace: 'جایگزینی', skip: 'رد کردن', + applyToAll: 'اعمال برای همه', + renameAll: 'تغییر نام همه', + replaceAll: 'جایگزینی همه', + skipAll: 'رد کردن همه', }, removeDownload: { title: 'حذف دانلود', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index c233058..b22e94b 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -50,6 +50,10 @@ const he = { rename: 'שינוי שם', replace: 'החלפה', skip: 'דילוג', + applyToAll: 'החל על הכול', + renameAll: 'שינוי שם לכולם', + replaceAll: 'החלפה לכולם', + skipAll: 'דילוג על כולם', }, removeDownload: { title: 'הסרת הורדה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 9dd5b5c..5f3f2cc 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -50,6 +50,10 @@ const ru = { rename: 'Переименовать', replace: 'Заменить', skip: 'Пропустить', + applyToAll: 'Применить ко всем', + renameAll: 'Переименовать все', + replaceAll: 'Заменить все', + skipAll: 'Пропустить все', }, removeDownload: { title: 'Удалить загрузку', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index ba0bba3..6b9bccc 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -50,6 +50,10 @@ const uk = { rename: 'Перейменувати', replace: 'Замінити', skip: 'Пропустити', + applyToAll: 'Застосувати до всіх', + renameAll: 'Перейменувати всі', + replaceAll: 'Замінити всі', + skipAll: 'Пропустити всі', }, removeDownload: { title: 'Видалити завантаження', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index ebe04d5..db06465 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -50,6 +50,10 @@ const zhCN = { rename: '重命名', replace: '替换', skip: '跳过', + applyToAll: '应用于全部', + renameAll: '全部重命名', + replaceAll: '全部替换', + skipAll: '全部跳过', }, removeDownload: { title: '移除下载', diff --git a/src/index.css b/src/index.css index 6d420bc..fb8d2f0 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 932f82b..833a005 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -941,8 +941,12 @@ export const useDownloadStore = create((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; } diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index b9ac0a2..1c53536 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -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; diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index fa34980..0fdb05d 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -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 = { diff --git a/src/utils/downloadTableColumns.test.ts b/src/utils/downloadTableColumns.test.ts index 4a09510..7e1d3d1 100644 --- a/src/utils/downloadTableColumns.test.ts +++ b/src/utils/downloadTableColumns.test.ts @@ -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', }); diff --git a/src/utils/downloadTableColumns.ts b/src/utils/downloadTableColumns.ts index 750e263..db4aa05 100644 --- a/src/utils/downloadTableColumns.ts +++ b/src/utils/downloadTableColumns.ts @@ -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',