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:
@@ -502,10 +502,11 @@ export interface PackageCardProps {
|
|||||||
sessionRunning?: boolean;
|
sessionRunning?: boolean;
|
||||||
columnOrder: readonly string[];
|
columnOrder: readonly string[];
|
||||||
gridTemplate: string;
|
gridTemplate: string;
|
||||||
|
renderItems?: boolean;
|
||||||
actions: DownloadsTableActions;
|
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;
|
const entry = row.package;
|
||||||
let renameFinished = false;
|
let renameFinished = false;
|
||||||
const finishRename = (value: string): void => {
|
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>)}
|
{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>
|
<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>
|
</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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -555,7 +556,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa
|
|||||||
const a = previous.row.package;
|
const a = previous.row.package;
|
||||||
const b = next.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 (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.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
||||||
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
||||||
for (const itemId of b.itemIds) {
|
for (const itemId of b.itemIds) {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
||||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||||
import {
|
import {
|
||||||
DownloadsTableHeader,
|
DownloadsTableHeader,
|
||||||
ItemRow,
|
type DownloadSortColumn,
|
||||||
PackageCard,
|
type DownloadsTableActions
|
||||||
type DownloadSortColumn,
|
} from "./DownloadsTable";
|
||||||
type DownloadsTableActions
|
import { VirtualizedDownloadsBody } from "./VirtualizedDownloadsBody";
|
||||||
} from "./DownloadsTable";
|
|
||||||
import "./downloads.css";
|
import "./downloads.css";
|
||||||
|
|
||||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||||
@@ -139,38 +138,17 @@ function tableState(model: DownloadsViewModel): ReactElement | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
|
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
return model.packageRows.map((row: DownloadPackageRow) => (
|
const state = tableState(model);
|
||||||
<PackageCard
|
return (
|
||||||
actions={actions}
|
<main className="downloads-content">
|
||||||
columnOrder={model.columnOrder}
|
<div className="downloads-table" role="table" aria-label="Downloads">
|
||||||
editing={model.editingPackageId === row.package.id}
|
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
|
||||||
editingName={model.editingName}
|
<VirtualizedDownloadsBody actions={actions} model={model} state={state} />
|
||||||
gridTemplate={model.gridTemplate}
|
</div>
|
||||||
key={row.package.id}
|
</main>
|
||||||
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 {
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 DownloadDisplayMode = "packages" | "files";
|
||||||
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
|
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
|
||||||
@@ -34,7 +35,7 @@ export interface DownloadPackageRow {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadsViewModelCore {
|
export interface DownloadsViewModelCore {
|
||||||
displayMode: DownloadDisplayMode;
|
displayMode: DownloadDisplayMode;
|
||||||
filter: DownloadSidebarFilter;
|
filter: DownloadSidebarFilter;
|
||||||
providerFilter: string;
|
providerFilter: string;
|
||||||
@@ -53,11 +54,15 @@ export interface DownloadsViewModelCore {
|
|||||||
paginationLabel: string;
|
paginationLabel: string;
|
||||||
limited: boolean;
|
limited: boolean;
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
filteredEmpty: boolean;
|
filteredEmpty: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
|
export type DownloadLogicalRow =
|
||||||
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
|
| (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"]);
|
||||||
|
|
||||||
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
|
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
|
||||||
if (activeStatuses.has(status)) return "active";
|
if (activeStatuses.has(status)) return "active";
|
||||||
@@ -116,16 +121,23 @@ function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
|||||||
return providerFilter === "all" || item.provider === providerFilter;
|
return providerFilter === "all" || item.provider === providerFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActivePackage(row: DownloadPackageRow): boolean {
|
function paginationLabel(visible: number, total: number): string {
|
||||||
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
|
if (visible === 0 || total === 0) return "0 von 0";
|
||||||
}
|
return `1–${visible} von ${total}`;
|
||||||
|
}
|
||||||
function paginationLabel(visible: number, total: number): string {
|
|
||||||
if (visible === 0 || total === 0) return "0 von 0";
|
export function buildDownloadLogicalRows(model: Pick<DownloadsViewModelCore, "displayMode" | "packageRows" | "fileRows">): DownloadLogicalRow[] {
|
||||||
return `1–${visible} von ${total}`;
|
if (model.displayMode === "files") {
|
||||||
}
|
return model.fileRows.map((item) => ({ type: "item", id: item.id, packageId: item.packageId, item, height: DOWNLOAD_FILE_ROW_HEIGHT }));
|
||||||
|
}
|
||||||
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
|
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
|
const allPackages = input.packageOrder
|
||||||
.map((id) => input.packages[id])
|
.map((id) => input.packages[id])
|
||||||
.filter((entry): entry is PackageEntry => Boolean(entry));
|
.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 query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||||
const collapsed = new Set(input.collapsedPackageIds);
|
const collapsed = new Set(input.collapsedPackageIds);
|
||||||
const selectedIds = new Set(input.selectedIds);
|
const selectedIds = new Set(input.selectedIds);
|
||||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
const packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||||
const allPackageItems = entry.itemIds
|
const allPackageItems = entry.itemIds
|
||||||
.map((id) => input.items[id])
|
.map((id) => input.items[id])
|
||||||
.filter((item): item is DownloadItem => Boolean(item));
|
.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) }];
|
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPackageRows = packageRows.length;
|
const totalPackageRows = packageRows.length;
|
||||||
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
||||||
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
|
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
|
||||||
const activeRows = packageRows.filter(isActivePackage);
|
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
|
||||||
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
|
const logicalRows = buildDownloadLogicalRows({
|
||||||
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
|
displayMode: input.displayMode,
|
||||||
}
|
packageRows: displayedPackages,
|
||||||
|
fileRows
|
||||||
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
|
});
|
||||||
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
|
const visibleItemIds = logicalRows.filter((row): row is Extract<DownloadLogicalRow, { type: "item" }> => row.type === "item").map((row) => row.item.id);
|
||||||
const visibleItemIds = (input.displayMode === "files"
|
const visibleRowIds = logicalRows.map((row) => row.id);
|
||||||
? fileRows
|
const visibleRowSet = new Set(visibleRowIds);
|
||||||
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
|
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
|
||||||
const visibleRowIds = input.displayMode === "files"
|
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
|
||||||
? 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 actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
|
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
|
||||||
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
|
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
|
||||||
const totalMainRowCount = input.displayMode === "files"
|
const totalMainRowCount = input.displayMode === "files"
|
||||||
@@ -203,10 +210,10 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
|||||||
actionableSelectedPackageIds,
|
actionableSelectedPackageIds,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
mainRowCount,
|
mainRowCount,
|
||||||
totalMainRowCount,
|
totalMainRowCount,
|
||||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||||
limited: mainRowCount < totalMainRowCount,
|
limited: false,
|
||||||
empty: allItems.length === 0,
|
empty: allItems.length === 0,
|
||||||
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
filteredEmpty: allItems.length > 0 && mainRowCount === 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-table {
|
.downloads-table {
|
||||||
|
--downloads-package-row-height: 40px;
|
||||||
|
--downloads-file-row-height: 38px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -284,6 +286,23 @@
|
|||||||
contain: layout style;
|
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 {
|
.downloads-package-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -330,7 +349,7 @@
|
|||||||
.downloads-package-row {
|
.downloads-package-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 40px;
|
height: var(--downloads-package-row-height);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -340,12 +359,12 @@
|
|||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
contain: layout paint style;
|
contain: layout paint style;
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
contain-intrinsic-size: 40px;
|
contain-intrinsic-size: var(--downloads-package-row-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-item-row {
|
.downloads-item-row {
|
||||||
height: 38px;
|
height: var(--downloads-file-row-height);
|
||||||
contain-intrinsic-size: 38px;
|
contain-intrinsic-size: var(--downloads-file-row-height);
|
||||||
border-top: 1px solid var(--ui-border);
|
border-top: 1px solid var(--ui-border);
|
||||||
color: var(--ui-text-muted);
|
color: var(--ui-text-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getDownloadQueueTotalBytes,
|
getDownloadQueueTotalBytes,
|
||||||
getPendingDownloadItemCount,
|
getPendingDownloadItemCount,
|
||||||
getDownloadSpeedBps,
|
getDownloadSpeedBps,
|
||||||
|
buildDownloadLogicalRows,
|
||||||
type DownloadSidebarFilter,
|
type DownloadSidebarFilter,
|
||||||
type DownloadsModelInput
|
type DownloadsModelInput
|
||||||
} from "../src/renderer/views/downloads/downloads-model";
|
} from "../src/renderer/views/downloads/downloads-model";
|
||||||
@@ -36,6 +37,11 @@ import {
|
|||||||
getPackageProgress,
|
getPackageProgress,
|
||||||
getPackageSizeProgress
|
getPackageSizeProgress
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_FILE_ROW_HEIGHT,
|
||||||
|
DOWNLOAD_PACKAGE_ROW_HEIGHT,
|
||||||
|
calculateDownloadVirtualWindow
|
||||||
|
} from "../src/renderer/views/downloads/download-virtualizer";
|
||||||
import {
|
import {
|
||||||
compactDownloadServiceLabel,
|
compactDownloadServiceLabel,
|
||||||
extractHoster,
|
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 {
|
function createActions(overrides: Partial<DownloadsViewActions> = {}): DownloadsViewActions {
|
||||||
return {
|
return {
|
||||||
onDisplayModeChange: () => {},
|
onDisplayModeChange: () => {},
|
||||||
@@ -468,7 +491,7 @@ describe("downloads model", () => {
|
|||||||
expect(model.actionableSelectedIds).toEqual(["package-a"]);
|
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 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 itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued"));
|
||||||
const model = buildDownloadsViewModel(createInput({
|
const model = buildDownloadsViewModel(createInput({
|
||||||
@@ -479,11 +502,60 @@ describe("downloads model", () => {
|
|||||||
renderLimit: 260
|
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.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"]);
|
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", () => {
|
describe("downloads view", () => {
|
||||||
@@ -692,14 +764,18 @@ describe("downloads view", () => {
|
|||||||
expect(html).toContain('data-download-column="name"');
|
expect(html).toContain('data-download-column="name"');
|
||||||
expect(html).toContain('data-download-column="hoster"');
|
expect(html).toContain('data-download-column="hoster"');
|
||||||
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
|
expect(html).toMatch(/grid-template-columns:[^"]+ 60px/);
|
||||||
expect(html).toContain('class="downloads-package-items is-expanded"');
|
expect(html).toContain('class="downloads-virtual-spacer"');
|
||||||
expect(html).toContain('class="downloads-package-items-inner"');
|
|
||||||
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*scrollbar-gutter:\s*stable;/s);
|
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).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).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-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-package-row-height:\s*40px;/s);
|
||||||
expect(css).toMatch(/\.downloads-item-row\s*\{[^}]*height:\s*38px;/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-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
|
||||||
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/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);
|
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-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(/\.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(/\[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\.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\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);
|
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 ? (");
|
||||||
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", () => {
|
describe("download table row contracts", () => {
|
||||||
|
|||||||
@@ -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(/\.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);
|
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", () => {
|
describe("focus restoration", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user