From 7af3587dfd61935362e49e138b88ad464d8de0f8 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 12 Aug 2026 02:21:17 +0200 Subject: [PATCH] Virtualize downloads table rows Add a custom download virtualizer with fixed 40px package rows, 38px file rows, overscan, and pinned rename rows. Keep the downloads model logically complete so header selection, shift ranges, and actionable selections operate on every filtered expanded row instead of the rendered DOM window. Render the virtualized body inside the existing downloads table scrollport without adding a second scroll context, while preserving column drag data attributes, row click behavior, context menus, and disabled native package dragging. Cover bounded rendering, full logical row selection, scroll window geometry, pinned inline rename rendering, and responsive scrollport CSS with focused tests. --- .../views/downloads/DownloadsTable.tsx | 7 +- .../views/downloads/DownloadsView.tsx | 58 +++------- .../downloads/VirtualizedDownloadsBody.tsx | 101 +++++++++++++++++ .../views/downloads/download-virtualizer.ts | 88 +++++++++++++++ .../views/downloads/downloads-model.ts | 95 ++++++++-------- src/renderer/views/downloads/downloads.css | 27 ++++- tests/downloads-view.test.tsx | 106 ++++++++++++++++-- tests/responsive-ui.test.tsx | 9 ++ 8 files changed, 391 insertions(+), 100 deletions(-) create mode 100644 src/renderer/views/downloads/VirtualizedDownloadsBody.tsx create mode 100644 src/renderer/views/downloads/download-virtualizer.ts diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index ff92873..8c6853d 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -502,10 +502,11 @@ export interface PackageCardProps { sessionRunning?: boolean; columnOrder: readonly string[]; gridTemplate: string; + renderItems?: boolean; actions: DownloadsTableActions; } -export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement { +export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, renderItems = true, actions }: PackageCardProps): ReactElement { const entry = row.package; let renameFinished = false; const finishRename = (value: string): void => { @@ -546,7 +547,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac {columnOrder.map((column) => {packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)})} - + {renderItems ? : null} ); } @@ -555,7 +556,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa const a = previous.row.package; const b = next.row.package; if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false; - if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions) return false; + if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.renderItems !== next.renderItems || previous.actions !== next.actions) return false; if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) { if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false; for (const itemId of b.itemIds) { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 6d4b153..4c7068e 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -1,14 +1,13 @@ import type { ReactElement } from "react"; import { RollingMetricValue } from "../../ui/RollingMetricValue"; import { SlidingSelection } from "../../ui/SlidingSelection"; -import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model"; -import { - DownloadsTableHeader, - ItemRow, - PackageCard, - type DownloadSortColumn, - type DownloadsTableActions -} from "./DownloadsTable"; +import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model"; +import { + DownloadsTableHeader, + type DownloadSortColumn, + type DownloadsTableActions +} from "./DownloadsTable"; +import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody"; import "./downloads.css"; const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }); @@ -139,38 +138,17 @@ function tableState(model: DownloadsViewModel): ReactElement | null { return null; } -function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] { - return model.packageRows.map((row: DownloadPackageRow) => ( - - )); -} - -export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { - return ( -
-
- -
- {tableState(model)} - {!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null} - {!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => ) : null} -
-
-
- ); -} +export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { + const state = tableState(model); + return ( +
+
+ + +
+
+ ); +} export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { return ( diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx new file mode 100644 index 0000000..7048d40 --- /dev/null +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from "react"; +import { buildDownloadLogicalRows, type DownloadLogicalRow } from "./downloads-model"; +import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView"; +import { ItemRow, PackageCard } from "./DownloadsTable"; +import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer"; + +interface DownloadViewportState { + scrollTop: number; + viewportHeight: number; +} + +function useDownloadViewport(bodyRef: React.RefObject): DownloadViewportState { + const [viewport, setViewport] = useState({ scrollTop: 0, viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT }); + + useEffect(() => { + const body = bodyRef.current; + const scrollport = body?.closest(".downloads-table"); + if (!body || !scrollport) return; + let frame = 0; + const measure = (): void => { + frame = 0; + const next = { + scrollTop: scrollport.scrollTop, + viewportHeight: scrollport.clientHeight || DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT + }; + setViewport((current) => current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight ? current : next); + }; + const schedule = (): void => { + if (frame !== 0) return; + frame = window.requestAnimationFrame(measure); + }; + measure(); + scrollport.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule); + return () => { + scrollport.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + if (frame !== 0) window.cancelAnimationFrame(frame); + }; + }, [bodyRef]); + + return viewport; +} + +function rowStyle(top: number, height: number): CSSProperties { + return { + "--downloads-virtual-row-top": `${top}px`, + "--downloads-virtual-row-height": `${height}px` + } as CSSProperties; +} + +function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement { + if (row.type === "item") { + return ( + + ); + } + return ( + + ); +} + +export function VirtualizedDownloadsBody({ actions, model, state }: { actions: DownloadsViewActions; model: DownloadsViewModel; state: ReactElement | null }): ReactElement { + const bodyRef = useRef(null); + const viewport = useDownloadViewport(bodyRef); + const logicalRows = useMemo(() => buildDownloadLogicalRows(model), [model]); + const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(logicalRows, { + scrollTop: viewport.scrollTop, + viewportHeight: viewport.viewportHeight, + overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS, + pinnedIds: [model.editingPackageId] + }), [logicalRows, model.editingPackageId, viewport.scrollTop, viewport.viewportHeight]); + const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties; + + return ( +
+ {state} + {!state ? ( +
+ {virtualWindow.rows.map((entry) => ( +
+ {renderVirtualRow(entry.source, model, actions)} +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/src/renderer/views/downloads/download-virtualizer.ts b/src/renderer/views/downloads/download-virtualizer.ts new file mode 100644 index 0000000..73c9cae --- /dev/null +++ b/src/renderer/views/downloads/download-virtualizer.ts @@ -0,0 +1,88 @@ +export const DOWNLOAD_PACKAGE_ROW_HEIGHT = 40; +export const DOWNLOAD_FILE_ROW_HEIGHT = 38; +export const DOWNLOAD_VIRTUAL_OVERSCAN_ROWS = 8; +export const DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720; + +export interface DownloadVirtualRowInput { + id: string; + height: number; +} + +export interface DownloadVirtualPositionedRow { + id: string; + index: number; + top: number; + height: number; + pinned: boolean; + source: T; +} + +export interface DownloadVirtualWindow { + rows: DownloadVirtualPositionedRow[]; + totalHeight: number; + startIndex: number; + endIndex: number; +} + +export interface DownloadVirtualWindowOptions { + scrollTop: number; + viewportHeight: number; + overscan?: number; + pinnedIds?: Iterable; +} + +function finiteDimension(value: number, fallback = 0): number { + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +export function calculateDownloadVirtualWindow(rows: readonly T[], options: DownloadVirtualWindowOptions): DownloadVirtualWindow { + if (rows.length === 0) return { rows: [], totalHeight: 0, startIndex: 0, endIndex: -1 }; + + const overscan = Math.max(0, Math.floor(finiteDimension(options.overscan ?? DOWNLOAD_VIRTUAL_OVERSCAN_ROWS))); + const scrollTop = finiteDimension(options.scrollTop); + const viewportHeight = finiteDimension(options.viewportHeight, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT); + const viewportBottom = scrollTop + viewportHeight; + const pinnedIds = new Set(Array.from(options.pinnedIds ?? []).filter((id): id is string => Boolean(id))); + const tops: number[] = []; + let totalHeight = 0; + let firstVisible = -1; + let lastVisible = -1; + + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + const height = finiteDimension(row.height); + const top = totalHeight; + const bottom = top + height; + tops.push(top); + totalHeight = bottom; + if (firstVisible === -1 && bottom > scrollTop) firstVisible = index; + if (top < viewportBottom) lastVisible = index; + } + + if (firstVisible === -1) firstVisible = rows.length - 1; + if (lastVisible < firstVisible) lastVisible = firstVisible; + + const startIndex = Math.max(0, firstVisible - overscan); + const endIndex = Math.min(rows.length - 1, lastVisible + overscan); + const selected = new Set(); + for (let index = startIndex; index <= endIndex; index += 1) { + selected.add(index); + } + for (let index = 0; index < rows.length; index += 1) { + if (pinnedIds.has(rows[index].id)) selected.add(index); + } + + const positioned = [...selected].sort((left, right) => left - right).map((index) => { + const source = rows[index]; + return { + id: source.id, + index, + top: tops[index], + height: finiteDimension(source.height), + pinned: pinnedIds.has(source.id), + source + }; + }); + + return { rows: positioned, totalHeight, startIndex, endIndex }; +} diff --git a/src/renderer/views/downloads/downloads-model.ts b/src/renderer/views/downloads/downloads-model.ts index 210922a..e4fe264 100644 --- a/src/renderer/views/downloads/downloads-model.ts +++ b/src/renderer/views/downloads/downloads-model.ts @@ -1,4 +1,5 @@ -import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types"; +import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types"; +import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer"; export type DownloadDisplayMode = "packages" | "files"; export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed"; @@ -34,7 +35,7 @@ export interface DownloadPackageRow { collapsed: boolean; } -export interface DownloadsViewModelCore { +export interface DownloadsViewModelCore { displayMode: DownloadDisplayMode; filter: DownloadSidebarFilter; providerFilter: string; @@ -53,11 +54,15 @@ export interface DownloadsViewModelCore { paginationLabel: string; limited: boolean; empty: boolean; - filteredEmpty: boolean; -} - -const activeStatuses = new Set(["downloading", "validating", "extracting", "integrity_check"]); -const queuedStatuses = new Set(["queued", "reconnect_wait"]); + filteredEmpty: boolean; +} + +export type DownloadLogicalRow = + | (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow }) + | (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem }); + +const activeStatuses = new Set(["downloading", "validating", "extracting", "integrity_check"]); +const queuedStatuses = new Set(["queued", "reconnect_wait"]); export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter { if (activeStatuses.has(status)) return "active"; @@ -116,16 +121,23 @@ function matchesProvider(item: DownloadItem, providerFilter: string): boolean { return providerFilter === "all" || item.provider === providerFilter; } -function isActivePackage(row: DownloadPackageRow): boolean { - return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active"); -} - -function paginationLabel(visible: number, total: number): string { - if (visible === 0 || total === 0) return "0 von 0"; - return `1–${visible} von ${total}`; -} - -export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore { +function paginationLabel(visible: number, total: number): string { + if (visible === 0 || total === 0) return "0 von 0"; + return `1–${visible} von ${total}`; +} + +export function buildDownloadLogicalRows(model: Pick): DownloadLogicalRow[] { + if (model.displayMode === "files") { + return model.fileRows.map((item) => ({ type: "item", id: item.id, packageId: item.packageId, item, height: DOWNLOAD_FILE_ROW_HEIGHT })); + } + return model.packageRows.flatMap((row): DownloadLogicalRow[] => { + const packageRow: DownloadLogicalRow = { type: "package", id: row.package.id, packageId: row.package.id, packageRow: row, height: DOWNLOAD_PACKAGE_ROW_HEIGHT }; + if (row.collapsed) return [packageRow]; + return [packageRow, ...row.items.map((item): DownloadLogicalRow => ({ type: "item", id: item.id, packageId: item.packageId, item, height: DOWNLOAD_FILE_ROW_HEIGHT }))]; + }); +} + +export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore { const allPackages = input.packageOrder .map((id) => input.packages[id]) .filter((entry): entry is PackageEntry => Boolean(entry)); @@ -139,7 +151,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi const query = input.query.trim().toLocaleLowerCase("de-DE"); const collapsed = new Set(input.collapsedPackageIds); const selectedIds = new Set(input.selectedIds); - let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => { + const packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => { const allPackageItems = entry.itemIds .map((id) => input.items[id]) .filter((item): item is DownloadItem => Boolean(item)); @@ -163,25 +175,20 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }]; }); - const totalPackageRows = packageRows.length; - const allMatchingFileRows = packageRows.flatMap((row) => row.items); - if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) { - const activeRows = packageRows.filter(isActivePackage); - const inactiveRows = packageRows.filter((row) => !isActivePackage(row)); - packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit); - } - - const fileRows = input.displayMode === "files" ? allMatchingFileRows : []; - const displayedPackages = input.displayMode === "packages" ? packageRows : []; - const visibleItemIds = (input.displayMode === "files" - ? fileRows - : displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id); - const visibleRowIds = input.displayMode === "files" - ? visibleItemIds - : displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]); - const visibleRowSet = new Set(visibleRowIds); - const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id)); - const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id)); + const totalPackageRows = packageRows.length; + const allMatchingFileRows = packageRows.flatMap((row) => row.items); + const fileRows = input.displayMode === "files" ? allMatchingFileRows : []; + const displayedPackages = input.displayMode === "packages" ? packageRows : []; + const logicalRows = buildDownloadLogicalRows({ + displayMode: input.displayMode, + packageRows: displayedPackages, + fileRows + }); + const visibleItemIds = logicalRows.filter((row): row is Extract => row.type === "item").map((row) => row.item.id); + const visibleRowIds = logicalRows.map((row) => row.id); + const visibleRowSet = new Set(visibleRowIds); + const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id)); + const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id)); const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id)); const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length; const totalMainRowCount = input.displayMode === "files" @@ -203,10 +210,10 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi actionableSelectedPackageIds, selectedIds, mainRowCount, - totalMainRowCount, - paginationLabel: paginationLabel(mainRowCount, totalMainRowCount), - limited: mainRowCount < totalMainRowCount, - empty: allItems.length === 0, - filteredEmpty: allItems.length > 0 && mainRowCount === 0 - }; -} + totalMainRowCount, + paginationLabel: paginationLabel(mainRowCount, totalMainRowCount), + limited: false, + empty: allItems.length === 0, + filteredEmpty: allItems.length > 0 && mainRowCount === 0 + }; +} diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 17b7f25..690dd8e 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -249,6 +249,8 @@ } .downloads-table { + --downloads-package-row-height: 40px; + --downloads-file-row-height: 38px; width: 100%; height: 100%; min-width: 0; @@ -284,6 +286,23 @@ contain: layout style; } +.downloads-virtual-spacer { + position: relative; + height: var(--downloads-virtual-total-height); + min-height: 0; +} + +.downloads-virtual-row { + position: absolute; + inset: 0 0 auto 0; + height: var(--downloads-virtual-row-height); + transform: translateY(var(--downloads-virtual-row-top)); +} + +.downloads-virtual-row > .downloads-package-card { + height: var(--downloads-virtual-row-height); +} + .downloads-package-card { box-sizing: border-box; width: 100%; @@ -330,7 +349,7 @@ .downloads-package-row { display: grid; align-items: center; - height: 40px; + height: var(--downloads-package-row-height); box-sizing: border-box; width: 100%; min-width: 0; @@ -340,12 +359,12 @@ border-left: 3px solid transparent; contain: layout paint style; content-visibility: auto; - contain-intrinsic-size: 40px; + contain-intrinsic-size: var(--downloads-package-row-height); } .downloads-item-row { - height: 38px; - contain-intrinsic-size: 38px; + height: var(--downloads-file-row-height); + contain-intrinsic-size: var(--downloads-file-row-height); border-top: 1px solid var(--ui-border); color: var(--ui-text-muted); } diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 8bcf9f6..240e797 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -12,6 +12,7 @@ import { getDownloadQueueTotalBytes, getPendingDownloadItemCount, getDownloadSpeedBps, + buildDownloadLogicalRows, type DownloadSidebarFilter, type DownloadsModelInput } from "../src/renderer/views/downloads/downloads-model"; @@ -36,6 +37,11 @@ import { getPackageProgress, getPackageSizeProgress } from "../src/renderer/views/downloads/DownloadsTable"; +import { + DOWNLOAD_FILE_ROW_HEIGHT, + DOWNLOAD_PACKAGE_ROW_HEIGHT, + calculateDownloadVirtualWindow +} from "../src/renderer/views/downloads/download-virtualizer"; import { compactDownloadServiceLabel, extractHoster, @@ -245,6 +251,23 @@ function createInput(overrides: Partial = {}): DownloadsMod }; } +function createLargeInput(packageCount: number, itemCount: number, overrides: Partial = {}): DownloadsModelInput { + const packageEntries = Array.from({ length: packageCount }, (_, packageIndex) => { + const ids = Array.from({ length: itemCount }, (_, itemIndex) => `i-${packageIndex}-${itemIndex}`); + return pkg(`p-${packageIndex}`, `Paket ${packageIndex}`, ids); + }); + const itemEntries = packageEntries.flatMap((entry, packageIndex) => ( + entry.itemIds.map((id, itemIndex) => item(id, entry.id, packageIndex === packageCount - 1 && itemIndex === 0 ? "downloading" : "queued")) + )); + return { + ...createInput(overrides), + packageOrder: packageEntries.map((entry) => entry.id), + packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])), + items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])), + ...overrides + }; +} + function createActions(overrides: Partial = {}): DownloadsViewActions { return { onDisplayModeChange: () => {}, @@ -468,7 +491,7 @@ describe("downloads model", () => { expect(model.actionableSelectedIds).toEqual(["package-a"]); }); - it("limits occupied package rows honestly while preserving active packages and an actionable visible selection", () => { + it("keeps occupied package rows logical while preserving active packages and an actionable visible selection", () => { const packageEntries = Array.from({ length: 264 }, (_, index) => pkg(`p-${index}`, `Paket ${index}`, [`i-${index}`])); const itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued")); const model = buildDownloadsViewModel(createInput({ @@ -479,11 +502,60 @@ describe("downloads model", () => { renderLimit: 260 })); - expect(model.packageRows).toHaveLength(260); + expect(model.packageRows).toHaveLength(264); expect(model.packageRows.some((row) => row.package.id === "p-263")).toBe(true); - expect(model.paginationLabel).toBe("1–260 von 264"); + expect(model.paginationLabel).toBe("1–264 von 264"); expect(model.actionableSelectedIds).toEqual(["p-0", "i-0", "p-263", "i-263"]); }); + + it("keeps every filtered expanded row logical while the DOM window stays bounded", () => { + const model = buildDownloadsViewModel(createLargeInput(320, 2, { + selectedIds: ["p-0", "i-0-0", "p-319", "i-319-0"], + renderLimit: 20 + })); + const logicalRows = buildDownloadLogicalRows(model); + const html = renderToStaticMarkup(); + const renderedRows = html.match(/data-download-row-id=/g) ?? []; + + expect(model.packageRows).toHaveLength(320); + expect(model.visibleRowIds).toHaveLength(960); + expect(model.visibleRowIds.slice(0, 3)).toEqual(["p-0", "i-0-0", "i-0-1"]); + expect(model.visibleRowIds.slice(-3)).toEqual(["p-319", "i-319-0", "i-319-1"]); + expect(model.actionableSelectedIds).toEqual(["p-0", "i-0-0", "p-319", "i-319-0"]); + expect(logicalRows).toHaveLength(960); + expect(renderedRows.length).toBeGreaterThan(0); + expect(renderedRows.length).toBeLessThan(80); + }); + + it("uses fixed download row geometry, overscan and pinned rows for the virtual window", () => { + const rows = [ + { id: "p-0", height: DOWNLOAD_PACKAGE_ROW_HEIGHT }, + { id: "i-0", height: DOWNLOAD_FILE_ROW_HEIGHT }, + { id: "p-1", height: DOWNLOAD_PACKAGE_ROW_HEIGHT }, + { id: "i-1", height: DOWNLOAD_FILE_ROW_HEIGHT }, + { id: "p-2", height: DOWNLOAD_PACKAGE_ROW_HEIGHT }, + { id: "i-2", height: DOWNLOAD_FILE_ROW_HEIGHT } + ]; + + const window = calculateDownloadVirtualWindow(rows, { + scrollTop: 79, + viewportHeight: 40, + overscan: 1, + pinnedIds: ["p-0", "i-2"] + }); + + expect(DOWNLOAD_PACKAGE_ROW_HEIGHT).toBe(40); + expect(DOWNLOAD_FILE_ROW_HEIGHT).toBe(38); + expect(window.totalHeight).toBe(234); + expect(window.rows.map((row) => [row.id, row.index, row.top, row.height, row.pinned])).toEqual([ + ["p-0", 0, 0, 40, true], + ["i-0", 1, 40, 38, false], + ["p-1", 2, 78, 40, false], + ["i-1", 3, 118, 38, false], + ["p-2", 4, 156, 40, false], + ["i-2", 5, 196, 38, true] + ]); + }); }); describe("downloads view", () => { @@ -692,14 +764,18 @@ describe("downloads view", () => { expect(html).toContain('data-download-column="name"'); expect(html).toContain('data-download-column="hoster"'); expect(html).toMatch(/grid-template-columns:[^"]+ 60px/); - expect(html).toContain('class="downloads-package-items is-expanded"'); - expect(html).toContain('class="downloads-package-items-inner"'); + expect(html).toContain('class="downloads-virtual-spacer"'); expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s); + expect(css).not.toMatch(/\.downloads-table-body\s*\{[^}]*overflow(?:-y)?:\s*auto;/s); expect(css).toMatch(/\.downloads-table-header,\s*\.downloads-table-body\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*var\(--downloads-table-min-width, 1191px\);/s); expect(css).not.toMatch(/min-width:\s*max-content;/); expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s); - expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*40px;/s); - expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/s); + expect(css).toMatch(/--downloads-package-row-height:\s*40px;/s); + expect(css).toMatch(/--downloads-file-row-height:\s*38px;/s); + expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*var\(--downloads-package-row-height\);/s); + expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*var\(--downloads-file-row-height\);/s); + expect(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*position:\s*relative;[^}]*height:\s*var\(--downloads-virtual-total-height\);/s); + expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*position:\s*absolute;[^}]*transform:\s*translateY\(var\(--downloads-virtual-row-top\)\);/s); expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s); expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s); expect(css).toMatch(/\.downloads-collapse-button\s*\{[^}]*box-sizing:\s*border-box;[^}]*flex:\s*0 0 30px;[^}]*width:\s*30px;[^}]*min-width:\s*30px;[^}]*max-width:\s*30px;/s); @@ -707,8 +783,6 @@ describe("downloads view", () => { expect(css).toMatch(/\.downloads-cell-slot\s*>\s*\.downloads-cell\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s); expect(css).toMatch(/\.downloads-column-header\s*\{[^}]*justify-content:\s*center;[^}]*text-align:\s*center;/s); expect(css).toMatch(/\[data-download-column="name"\][^{]*\{[^}]*justify-content:\s*flex-start;[^}]*text-align:\s*left;/s); - expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s); - expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s); expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s); expect(css).toMatch(/\.downloads-meter-label\s*\{[^}]*font-weight:\s*700;/s); expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*clip-path:\s*inset\(0 0 0 var\(--downloads-progress\)\);/s); @@ -789,6 +863,20 @@ describe("downloads App integration", () => { expect(source).not.toContain(") : false ? ("); expect(source).not.toContain("{false && ("); }); + + it("pins the inline rename row outside the initial viewport without rendering the whole queue", () => { + const model = withRuntime(createLargeInput(340, 1), { + editingPackageId: "p-339", + editingName: "Pinned Rename" + }); + const html = renderToStaticMarkup(); + const renderedRows = html.match(/data-download-row-id=/g) ?? []; + + expect(html).toContain('data-download-row-id="p-339"'); + expect(html).toContain('class="downloads-rename-input"'); + expect(renderedRows.length).toBeGreaterThan(0); + expect(renderedRows.length).toBeLessThan(80); + }); }); describe("download table row contracts", () => { diff --git a/tests/responsive-ui.test.tsx b/tests/responsive-ui.test.tsx index 6253d50..25f66fd 100644 --- a/tests/responsive-ui.test.tsx +++ b/tests/responsive-ui.test.tsx @@ -67,6 +67,15 @@ describe("responsive shell mode", () => { expect(css).toMatch(/\.md-shell\.has-collapsed-sidebar:is\(\.is-compact, \.is-minimum\) \.md-shell-navigation\s*\{[^}]*padding-left:\s*44px;/s); expect(css).toMatch(/@media \(max-width: 1120px\)[\s\S]*\.md-shell\.is-minimum \.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*top:\s*-46px;/s); }); + + it("keeps the virtualized downloads body inside the existing table scrollport", () => { + const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8"); + + expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s); + expect(css).not.toMatch(/\.downloads-table-body\s*\{[^}]*overflow(?:-y)?:\s*auto;/s); + expect(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*height:\s*var\(--downloads-virtual-total-height\);/s); + expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*position:\s*absolute;/s); + }); }); describe("focus restoration", () => {