diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx
index 6e1ea7a..47ae459 100644
--- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx
+++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx
@@ -7,6 +7,7 @@ import {
mergeDownloadDisclosureRows,
prepareDownloadDisclosureTransition,
scheduleDownloadDisclosureActivation,
+ stableDownloadDisclosureRows,
type DownloadDisclosureRow
} from "./download-disclosure-transition";
import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
@@ -69,7 +70,16 @@ function disclosureOpacity(row: DownloadLogicalRow | DownloadDisclosureRow): num
return "disclosureOpacity" in row && typeof row.disclosureOpacity === "number" ? row.disclosureOpacity : 1;
}
-function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
+function renderVirtualRow(row: DownloadLogicalRow | DownloadDisclosureRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
+ if (row.type === "item-group") {
+ return (
+
+ {row.items.map((entry) => (
+
+ ))}
+
+ );
+ }
if (row.type === "item") {
return (
@@ -140,7 +150,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
};
}, [model.disclosureRevision, model.displayMode]);
- const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : desiredRows, [desiredRows, transitionRows]);
+ const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
scrollTop: viewport.scrollTop,
viewportHeight: viewport.viewportHeight,
@@ -155,7 +165,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
{!state ? (
{virtualWindow.rows.map((entry) => (
-
+
{renderVirtualRow(entry.source, model, actions)}
))}
diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts
index e4210e0..ab5b27f 100644
--- a/src/renderer/views/downloads/download-disclosure-transition.ts
+++ b/src/renderer/views/downloads/download-disclosure-transition.ts
@@ -2,6 +2,7 @@ import type { DownloadLogicalRow } from "./downloads-model";
import {
DOWNLOAD_FILE_ROW_HEIGHT,
calculateDownloadVirtualWindow,
+ type DownloadVirtualRowInput,
type DownloadVirtualWindowOptions
} from "./download-virtualizer";
@@ -26,28 +27,57 @@ export function scheduleDownloadDisclosureActivation(
export type DownloadDisclosurePhase = "stable" | "entering" | "leaving";
-export type DownloadDisclosureRow = DownloadLogicalRow & {
+type DownloadItemLogicalRow = Extract
;
+
+type DownloadDisclosureMeta = {
disclosureOpacity: number;
disclosurePhase: DownloadDisclosurePhase;
};
-function stableRow(row: DownloadLogicalRow): DownloadDisclosureRow {
- return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" };
-}
+export type DownloadDisclosureLogicalRow = DownloadLogicalRow & DownloadDisclosureMeta;
+
+export type DownloadDisclosureGroupRow = DownloadVirtualRowInput & DownloadDisclosureMeta & {
+ type: "item-group";
+ packageId: string;
+ items: DownloadItemLogicalRow[];
+};
+
+export type DownloadDisclosureRow = DownloadDisclosureLogicalRow | DownloadDisclosureGroupRow;
type DownloadDisclosureSourceRow = DownloadLogicalRow | DownloadDisclosureRow;
-function disclosureOpacity(row: DownloadDisclosureSourceRow): number {
- return "disclosureOpacity" in row ? row.disclosureOpacity : 1;
+function stableRow(row: DownloadLogicalRow): DownloadDisclosureLogicalRow {
+ return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" };
+}
+
+function itemGroup(
+ packageId: string,
+ items: readonly DownloadItemLogicalRow[],
+ height: number,
+ phase: Exclude
+): DownloadDisclosureGroupRow {
+ return {
+ type: "item-group",
+ id: `${packageId}:items`,
+ packageId,
+ items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
+ height,
+ disclosureOpacity: 1,
+ disclosurePhase: phase
+ };
}
export function stableDownloadDisclosureRows(rows: readonly DownloadLogicalRow[]): DownloadDisclosureRow[] {
return rows.map(stableRow);
}
-function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map {
- const grouped = new Map();
+function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map {
+ const grouped = new Map();
for (const row of rows) {
+ if (row.type === "item-group") {
+ grouped.set(row.packageId, row.items);
+ continue;
+ }
if (row.type !== "item") continue;
const entries = grouped.get(row.packageId) ?? [];
entries.push(row);
@@ -56,6 +86,10 @@ function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map {
+ return new Map(rows.filter((row): row is DownloadDisclosureGroupRow => row.type === "item-group").map((row) => [row.packageId, row]));
+}
+
export function prepareDownloadDisclosureTransition(
current: readonly DownloadDisclosureSourceRow[],
desired: readonly DownloadLogicalRow[]
@@ -65,14 +99,9 @@ export function prepareDownloadDisclosureTransition(
}
const currentItems = itemRowsByPackage(current);
+ const currentGroups = groupRowsByPackage(current);
const currentPackages = new Map(current.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
- const desiredItems = new Map();
- for (const row of desired) {
- if (row.type !== "item") continue;
- const entries = desiredItems.get(row.packageId) ?? [];
- entries.push(row);
- desiredItems.set(row.packageId, entries);
- }
+ const desiredItems = itemRowsByPackage(desired);
let animatedItemCount = 0;
for (const row of desired) {
@@ -93,6 +122,7 @@ export function prepareDownloadDisclosureTransition(
const before = currentItems.get(row.packageId) ?? [];
const after = desiredItems.get(row.packageId) ?? [];
const previousPackage = currentPackages.get(row.packageId);
+ const previousGroup = currentGroups.get(row.packageId);
const disclosureChanged = previousPackage?.type === "package" && previousPackage.packageRow.collapsed !== row.packageRow.collapsed;
if (!disclosureChanged) {
rows.push(...after.map(stableRow));
@@ -100,21 +130,12 @@ export function prepareDownloadDisclosureTransition(
}
if (row.packageRow.collapsed && before.length > 0) {
animated = true;
- rows.push(...before.map((entry) => ({ ...entry, disclosureOpacity: disclosureOpacity(entry), disclosurePhase: "leaving" as const })));
+ rows.push(itemGroup(row.packageId, before, previousGroup?.height ?? before.length * DOWNLOAD_FILE_ROW_HEIGHT, "leaving"));
continue;
}
- const beforeById = new Map(before.map((entry) => [entry.id, entry]));
- for (const entry of after) {
- const previous = beforeById.get(entry.id);
- if (!previous) {
- animated = true;
- rows.push({ ...entry, height: 0, disclosureOpacity: 0, disclosurePhase: "entering" });
- } else if (("disclosurePhase" in previous && previous.disclosurePhase === "leaving") || previous.height === 0) {
- animated = true;
- rows.push({ ...entry, height: previous.height, disclosureOpacity: disclosureOpacity(previous), disclosurePhase: "entering" });
- } else {
- rows.push(stableRow(entry));
- }
+ if (after.length > 0) {
+ animated = true;
+ rows.push(itemGroup(row.packageId, after, previousGroup?.height ?? 0, "entering"));
}
}
return { animated, rows };
@@ -124,33 +145,59 @@ export function mergeDownloadDisclosureRows(
transition: readonly DownloadDisclosureRow[],
desired: readonly DownloadLogicalRow[]
): DownloadDisclosureRow[] {
- const transitionById = new Map(transition.map((row) => [`${row.type}:${row.id}`, row]));
- const desiredIds = new Set(desired.map((row) => `${row.type}:${row.id}`));
- const leavingByPackage = new Map();
- for (const row of transition) {
- if (row.type !== "item" || row.disclosurePhase !== "leaving" || desiredIds.has(`item:${row.id}`)) continue;
- const entries = leavingByPackage.get(row.packageId) ?? [];
- entries.push(row);
- leavingByPackage.set(row.packageId, entries);
+ const transitionPackages = new Map(transition.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
+ const transitionGroups = groupRowsByPackage(transition);
+ const desiredItems = itemRowsByPackage(desired);
+ const desiredPackages = new Map(desired.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
+ const freshGroupItems = new Map();
+ let groupedItemCount = 0;
+ for (const group of transitionGroups.values()) {
+ const packageRow = desiredPackages.get(group.packageId);
+ if (!packageRow || packageRow.type !== "package") continue;
+ const items = desiredItems.get(group.packageId) ?? packageRow.packageRow.items.map((item): DownloadItemLogicalRow => ({
+ type: "item",
+ id: item.id,
+ packageId: item.packageId,
+ item,
+ height: DOWNLOAD_FILE_ROW_HEIGHT
+ }));
+ groupedItemCount += items.length;
+ if (groupedItemCount > DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS) {
+ return stableDownloadDisclosureRows(desired);
+ }
+ freshGroupItems.set(group.packageId, items);
}
-
const rows: DownloadDisclosureRow[] = [];
+
for (const row of desired) {
- const animated = transitionById.get(`${row.type}:${row.id}`);
- rows.push(animated
- ? { ...row, height: animated.height, disclosureOpacity: animated.disclosureOpacity, disclosurePhase: animated.disclosurePhase }
+ if (row.type !== "package") continue;
+ const animatedPackage = transitionPackages.get(row.packageId);
+ rows.push(animatedPackage?.type === "package"
+ ? { ...row, height: animatedPackage.height, disclosureOpacity: animatedPackage.disclosureOpacity, disclosurePhase: animatedPackage.disclosurePhase }
: stableRow(row));
- if (row.type === "package") rows.push(...(leavingByPackage.get(row.packageId) ?? []));
+
+ const group = transitionGroups.get(row.packageId);
+ if (!group) {
+ rows.push(...(desiredItems.get(row.packageId) ?? []).map(stableRow));
+ continue;
+ }
+
+ const freshItems = freshGroupItems.get(row.packageId) ?? [];
+ rows.push({
+ ...group,
+ items: freshItems,
+ height: group.height > 0 ? freshItems.length * DOWNLOAD_FILE_ROW_HEIGHT : 0
+ });
}
return rows;
}
export function activateDownloadDisclosureTransition(rows: readonly DownloadDisclosureRow[]): DownloadDisclosureRow[] {
return rows.map((row) => {
- if (row.type !== "item" || row.disclosurePhase === "stable") return row;
+ if (row.type !== "item-group") return row;
return row.disclosurePhase === "entering"
- ? { ...row, height: DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
- : { ...row, height: 0, disclosureOpacity: 0 };
+ ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT }
+ : { ...row, height: 0 };
});
}
diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css
index 759563d..f4a2bba 100644
--- a/src/renderer/views/downloads/downloads.css
+++ b/src/renderer/views/downloads/downloads.css
@@ -306,6 +306,10 @@
height: var(--downloads-virtual-row-height);
}
+.downloads-disclosure-item-group {
+ width: 100%;
+}
+
.downloads-package-card {
box-sizing: border-box;
width: 100%;
diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx
index 24887ae..b278b0a 100644
--- a/tests/downloads-view.test.tsx
+++ b/tests/downloads-view.test.tsx
@@ -253,7 +253,7 @@ describe("virtualisierte Paketanimation", () => {
expect(cancelled).toContain(2);
});
- it("fährt neue Unterzeilen von null auf ihre feste Höhe aus und verschiebt Folgepakete unter stabilen IDs", () => {
+ it("fährt einen gemeinsamen Paketbereich aus und lässt seine Unterzeilen auf fester Höhe", () => {
const collapsed = logicalRows([packageA.id]);
const expanded = logicalRows([]);
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(collapsed), expanded);
@@ -261,15 +261,18 @@ describe("virtualisierte Paketanimation", () => {
expect(prepared.animated).toBe(true);
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
["package-a", 40, "stable", 1],
- ["a-1", 0, "entering", 0],
- ["a-2", 0, "entering", 0],
+ ["package-a:items", 0, "entering", 1],
["package-b", 40, "stable", 1],
["b-1", 38, "stable", 1]
]);
+ const preparedGroup = prepared.rows.find((row) => row.type === "item-group");
+ expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => [row.id, row.height]) : []).toEqual([
+ ["a-1", 38],
+ ["a-2", 38]
+ ]);
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
["package-a", 40, 1],
- ["a-1", 38, 1],
- ["a-2", 38, 1],
+ ["package-a:items", 76, 1],
["package-b", 40, 1],
["b-1", 38, 1]
]);
@@ -283,22 +286,22 @@ describe("virtualisierte Paketanimation", () => {
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)[\s\S]*\.downloads-virtual-row\s*\{[^}]*transition-duration:\s*1500ms, 1500ms, 1500ms !important;/s);
});
- it("behält ausfahrende Unterzeilen bis zum Animationsende und reduziert ihre Höhe auf null", () => {
+ it("schneidet beim Einfahren den gemeinsamen Paketbereich ab statt Unterzeilen zusammenzuziehen", () => {
const expanded = logicalRows([]);
const collapsed = logicalRows([packageA.id]);
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(expanded), collapsed);
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
["package-a", 40, "stable", 1],
- ["a-1", 38, "leaving", 1],
- ["a-2", 38, "leaving", 1],
+ ["package-a:items", 76, "leaving", 1],
["package-b", 40, "stable", 1],
["b-1", 38, "stable", 1]
]);
+ const preparedGroup = prepared.rows.find((row) => row.type === "item-group");
+ expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]);
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
["package-a", 40, 1],
- ["a-1", 0, 0],
- ["a-2", 0, 0],
+ ["package-a:items", 0, 1],
["package-b", 40, 1],
["b-1", 38, 1]
]);
@@ -402,15 +405,59 @@ describe("virtualisierte Paketanimation", () => {
const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
const updated = logicalRows([]).map((row) => row.type === "item" && row.id === "a-1" ? { ...row, item: { ...row.item, downloadedBytes: 987_654_321 } } : row);
const merged = mergeDownloadDisclosureRows(entering, updated);
- const updatedItem = merged.find((row) => row.type === "item" && row.id === "a-1");
- expect(updatedItem?.type === "item" ? updatedItem.item.downloadedBytes : 0).toBe(987_654_321);
- expect(updatedItem).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 38, disclosureOpacity: 1 }));
+ const enteringGroup = merged.find((row) => row.type === "item-group");
+ const updatedItem = enteringGroup?.type === "item-group" ? enteringGroup.items.find((row) => row.id === "a-1") : null;
+ expect(updatedItem?.item.downloadedBytes).toBe(987_654_321);
+ expect(enteringGroup).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 76, disclosureOpacity: 1 }));
const collapsed = logicalRows([packageA.id]);
const leaving = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([])), collapsed).rows);
const reversed = prepareDownloadDisclosureTransition(leaving, logicalRows([]));
expect(reversed.animated).toBe(true);
- expect(reversed.rows.filter((row) => row.packageId === packageA.id && row.type === "item").every((row) => row.disclosurePhase === "entering")).toBe(true);
+ expect(reversed.rows.find((row) => row.packageId === packageA.id && row.type === "item-group")).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 0 }));
+ });
+
+ it("gleicht die Unterzeilenmenge einer laufenden Paketbewegung sofort mit dem aktuellen Modell ab", () => {
+ const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
+ const current = logicalRows([]);
+ const replacement = item("a-3", packageA.id, "queued");
+ const updated = current.flatMap((row) => {
+ if (row.type === "item" && row.id === "a-2") {
+ return [{ ...row, id: replacement.id, item: replacement }];
+ }
+ return [row];
+ });
+ const merged = mergeDownloadDisclosureRows(entering, updated);
+ const group = merged.find((row) => row.type === "item-group");
+
+ expect(group?.type === "item-group" ? group.items.map((row) => row.id) : []).toEqual(["a-1", "a-3"]);
+ expect(group).toEqual(expect.objectContaining({ height: 76 }));
+ });
+
+ it("bricht eine laufende Gruppenbewegung ab wenn ein Filterwechsel die Virtualisierungsgrenze überschreitet", () => {
+ const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
+ const itemIds = Array.from({ length: DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 100 }, (_, index) => `expanded-${index}`);
+ const expandedPackage = pkg(packageA.id, packageA.name, itemIds);
+ const expandedItems = Object.fromEntries(itemIds.map((id) => [id, item(id, packageA.id, "queued")]));
+ const expandedRows = buildDownloadLogicalRows(buildDownloadsViewModel({
+ packageOrder: [expandedPackage.id],
+ packages: { [expandedPackage.id]: expandedPackage },
+ items: expandedItems,
+ displayMode: "packages",
+ filter: "all",
+ providerFilter: "all",
+ query: "",
+ collapsedPackageIds: [],
+ selectedIds: [],
+ hideExtractedItems: false,
+ showAllPackages: true,
+ renderLimit: itemIds.length + 1
+ }));
+ const merged = mergeDownloadDisclosureRows(entering, expandedRows);
+ const window = calculateDownloadVirtualWindow(merged, { scrollTop: 0, viewportHeight: 720, overscan: 8 });
+
+ expect(merged.some((row) => row.type === "item-group")).toBe(false);
+ expect(window.rows.length).toBeLessThan(40);
});
});