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)})} { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮ - + {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", () => {