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.
This commit is contained in:
Sucukdeluxe
2026-08-12 02:21:17 +02:00
parent 51b422fa10
commit 7af3587dfd
8 changed files with 391 additions and 100 deletions
@@ -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) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
{renderItems ? <PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} /> : null}
</article>
);
}
@@ -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) {
+4 -26
View File
@@ -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 type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
import {
DownloadsTableHeader,
ItemRow,
PackageCard,
type DownloadSortColumn,
type DownloadsTableActions
} from "./DownloadsTable";
import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody";
import "./downloads.css";
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
@@ -139,34 +138,13 @@ function tableState(model: DownloadsViewModel): ReactElement | null {
return null;
}
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
return model.packageRows.map((row: DownloadPackageRow) => (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.package.id}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
key={row.package.id}
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
row={row}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
));
}
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const state = tableState(model);
return (
<main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads">
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
{tableState(model)}
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
</div>
<VirtualizedDownloadsBody actions={actions} model={model} state={state} />
</div>
</main>
);
@@ -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<HTMLDivElement>): DownloadViewportState {
const [viewport, setViewport] = useState<DownloadViewportState>({ scrollTop: 0, viewportHeight: DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT });
useEffect(() => {
const body = bodyRef.current;
const scrollport = body?.closest<HTMLElement>(".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 (
<ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={row.item} selected={model.selectedIds.has(row.item.id)} sessionRunning={model.running} />
);
}
return (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.packageId}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
packageSpeedBps={model.packageSpeedBps[row.packageId] ?? 0}
renderItems={false}
row={row.packageRow}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
);
}
export function VirtualizedDownloadsBody({ actions, model, state }: { actions: DownloadsViewActions; model: DownloadsViewModel; state: ReactElement | null }): ReactElement {
const bodyRef = useRef<HTMLDivElement>(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 (
<div className="downloads-table-body" data-visual-region="downloads-table-body" ref={bodyRef} role="rowgroup">
{state}
{!state ? (
<div className="downloads-virtual-spacer" style={spacerStyle}>
{virtualWindow.rows.map((entry) => (
<div className="downloads-virtual-row" data-download-virtual-index={entry.index} key={`${entry.id}:${entry.index}`} style={rowStyle(entry.top, entry.height)}>
{renderVirtualRow(entry.source, model, actions)}
</div>
))}
</div>
) : null}
</div>
);
}
@@ -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<T extends DownloadVirtualRowInput> {
id: string;
index: number;
top: number;
height: number;
pinned: boolean;
source: T;
}
export interface DownloadVirtualWindow<T extends DownloadVirtualRowInput> {
rows: DownloadVirtualPositionedRow<T>[];
totalHeight: number;
startIndex: number;
endIndex: number;
}
export interface DownloadVirtualWindowOptions {
scrollTop: number;
viewportHeight: number;
overscan?: number;
pinnedIds?: Iterable<string | null | undefined>;
}
function finiteDimension(value: number, fallback = 0): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
export function calculateDownloadVirtualWindow<T extends DownloadVirtualRowInput>(rows: readonly T[], options: DownloadVirtualWindowOptions): DownloadVirtualWindow<T> {
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<number>();
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 };
}
+25 -18
View File
@@ -1,4 +1,5 @@
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";
@@ -56,6 +57,10 @@ export interface DownloadsViewModelCore {
filteredEmpty: boolean;
}
export type DownloadLogicalRow =
| (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow })
| (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem });
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
@@ -116,15 +121,22 @@ 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 buildDownloadLogicalRows(model: Pick<DownloadsViewModelCore, "displayMode" | "packageRows" | "fileRows">): 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])
@@ -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));
@@ -165,20 +177,15 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
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 logicalRows = buildDownloadLogicalRows({
displayMode: input.displayMode,
packageRows: displayedPackages,
fileRows
});
const visibleItemIds = logicalRows.filter((row): row is Extract<DownloadLogicalRow, { type: "item" }> => 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));
@@ -205,7 +212,7 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: mainRowCount < totalMainRowCount,
limited: false,
empty: allItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0
};
+23 -4
View File
@@ -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);
}
+97 -9
View File
@@ -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<DownloadsModelInput> = {}): DownloadsMod
};
}
function createLargeInput(packageCount: number, itemCount: number, overrides: Partial<DownloadsModelInput> = {}): 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> = {}): 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("1260 von 264");
expect(model.paginationLabel).toBe("1264 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(<DownloadsView actions={createActions()} model={withRuntime(createLargeInput(320, 2, { renderLimit: 20 }))} />);
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(<DownloadsContent actions={createActions()} model={model} />);
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", () => {
+9
View File
@@ -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", () => {