feat: make package disclosure motion optional

Add a persisted general setting that keeps package expand and collapse motion enabled by default while allowing remote and lower-performance systems to switch it off. Route the setting through normalized application state and renderer settings, bypass transition grouping when disabled, and retain the existing virtualized package list behavior. Cover legacy defaults, explicit opt-out persistence, settings presentation, and the immediate disclosure path with focused regression tests.
This commit is contained in:
Sucukdeluxe
2026-08-14 23:59:51 +02:00
parent 08512d38a4
commit d1d1817f86
14 changed files with 61 additions and 7 deletions
+1
View File
@@ -105,6 +105,7 @@ export function defaultSettings(): AppSettings {
theme: "dark" as const, theme: "dark" as const,
logStorageLocation: "appdata", logStorageLocation: "appdata",
collapseNewPackages: true, collapseNewPackages: true,
animatePackageDisclosure: true,
historyRetentionMode: "permanent", historyRetentionMode: "permanent",
historyMaxEntries: 500, historyMaxEntries: 500,
historyMaxAgeDays: 0, historyMaxAgeDays: 0,
+1
View File
@@ -181,6 +181,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
theme: settings.theme, theme: settings.theme,
logStorageLocation: settings.logStorageLocation, logStorageLocation: settings.logStorageLocation,
collapseNewPackages: settings.collapseNewPackages, collapseNewPackages: settings.collapseNewPackages,
animatePackageDisclosure: settings.animatePackageDisclosure,
historyRetentionMode: settings.historyRetentionMode, historyRetentionMode: settings.historyRetentionMode,
historyMaxEntries: settings.historyMaxEntries, historyMaxEntries: settings.historyMaxEntries,
historyMaxAgeDays: settings.historyMaxAgeDays, historyMaxAgeDays: settings.historyMaxAgeDays,
+1
View File
@@ -527,6 +527,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
? settings.logStorageLocation ? settings.logStorageLocation
: defaults.logStorageLocation, : defaults.logStorageLocation,
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
animatePackageDisclosure: settings.animatePackageDisclosure !== undefined ? Boolean(settings.animatePackageDisclosure) : defaults.animatePackageDisclosure,
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
? settings.historyRetentionMode ? settings.historyRetentionMode
: defaults.historyRetentionMode, : defaults.historyRetentionMode,
+3 -2
View File
@@ -821,7 +821,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@@ -4597,6 +4597,7 @@ export function App(): ReactElement {
sortColumn: downloadsSortColumn, sortColumn: downloadsSortColumn,
sortDirection: downloadsSortDescending ? "desc" : "asc", sortDirection: downloadsSortDescending ? "desc" : "asc",
disclosureRevision: downloadDisclosureRevision, disclosureRevision: downloadDisclosureRevision,
animatePackageDisclosure: snapshot.settings.animatePackageDisclosure,
status: { status: {
packages: snapshot.stats.totalPackages, packages: snapshot.stats.totalPackages,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
@@ -4610,7 +4611,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText eta: snapshot.etaText
} }
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = { const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode, onDisplayModeChange: setDownloadDisplayMode,
+1 -1
View File
@@ -12,7 +12,7 @@ const pairs = [
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Paket-Ein-/Ausklappen animieren", "Animate package expand/collapse"],
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
@@ -48,6 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortColumn?: DownloadSortColumn; sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc"; sortDirection?: "asc" | "desc";
disclosureRevision: number; disclosureRevision: number;
animatePackageDisclosure: boolean;
status: DownloadsStatusModel; status: DownloadsStatusModel;
} }
@@ -117,7 +117,11 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
}, [desiredRows]); }, [desiredRows]);
useRendererLayoutEffect(() => { useRendererLayoutEffect(() => {
const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current); const prepared = prepareDownloadDisclosureTransition(
transitionRowsRef.current ?? previousRowsRef.current,
desiredRowsRef.current,
model.animatePackageDisclosure
);
if (!prepared.animated) { if (!prepared.animated) {
transitionRowsRef.current = null; transitionRowsRef.current = null;
transitionPinnedIdsRef.current = []; transitionPinnedIdsRef.current = [];
@@ -148,7 +152,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
cancelActivation(); cancelActivation();
if (settleTimer) window.clearTimeout(settleTimer); if (settleTimer) window.clearTimeout(settleTimer);
}; };
}, [model.disclosureRevision, model.displayMode]); }, [model.animatePackageDisclosure, model.disclosureRevision, model.displayMode]);
const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); const renderedRows = useMemo<DownloadDisclosureRow[]>(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]);
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
@@ -92,9 +92,10 @@ function groupRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<s
export function prepareDownloadDisclosureTransition( export function prepareDownloadDisclosureTransition(
current: readonly DownloadDisclosureSourceRow[], current: readonly DownloadDisclosureSourceRow[],
desired: readonly DownloadLogicalRow[] desired: readonly DownloadLogicalRow[],
enabled = true
): { animated: boolean; rows: DownloadDisclosureRow[] } { ): { animated: boolean; rows: DownloadDisclosureRow[] } {
if (!desired.some((row) => row.type === "package")) { if (!enabled || !desired.some((row) => row.type === "package")) {
return { animated: false, rows: stableDownloadDisclosureRows(desired) }; return { animated: false, rows: stableDownloadDisclosureRows(desired) };
} }
@@ -519,6 +519,7 @@ export function buildSettingsFormViewModel({
title: "Oberfläche und Bedienung", title: "Oberfläche und Bedienung",
fields: [ fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages }, { id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "animatePackageDisclosure", kind: "switch", label: "Paket-Ein-/Ausklappen animieren", value: settings.animatePackageDisclosure },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray }, { id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection }, { id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads }, { id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
+2
View File
@@ -131,6 +131,7 @@ export interface AppSettings {
theme: AppTheme; theme: AppTheme;
logStorageLocation: LogStorageLocation; logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean; collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
historyRetentionMode: HistoryRetentionMode; historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number; historyMaxEntries: number;
historyMaxAgeDays: number; historyMaxAgeDays: number;
@@ -250,6 +251,7 @@ export interface RendererSettings {
theme: AppTheme; theme: AppTheme;
logStorageLocation: LogStorageLocation; logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean; collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
historyRetentionMode: HistoryRetentionMode; historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number; historyMaxEntries: number;
historyMaxAgeDays: number; historyMaxAgeDays: number;
+14
View File
@@ -227,6 +227,19 @@ describe("virtualisierte Paketanimation", () => {
renderLimit: 100 renderLimit: 100
})); }));
it("überspringt die Paketbewegung, wenn sie in den Einstellungen deaktiviert ist", () => {
const expanded = logicalRows([]);
const prepared = prepareDownloadDisclosureTransition(
stableDownloadDisclosureRows(logicalRows([packageA.id])),
expanded,
false
);
expect(prepared.animated).toBe(false);
expect(prepared.rows).toEqual(stableDownloadDisclosureRows(expanded));
expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false);
});
it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => { it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => {
const frames: FrameRequestCallback[] = []; const frames: FrameRequestCallback[] = [];
const cancelled: number[] = []; const cancelled: number[] = [];
@@ -659,6 +672,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
scheduleLabel: "", scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 }, packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0, disclosureRevision: 0,
animatePackageDisclosure: true,
editingPackageId: null, editingPackageId: null,
editingName: "", editingName: "",
columnOrder: ["name", "size", "hoster", "progress"] as const, columnOrder: ["name", "size", "hoster", "progress"] as const,
+18
View File
@@ -506,6 +506,24 @@ describe("settings views", () => {
expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s); expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s);
}); });
it("offers a general switch for package expand and collapse motion", () => {
const form = buildSettingsFormViewModel({
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const animation = form.groups.flatMap((group) => group.fields)
.find((field) => field.id === "animatePackageDisclosure");
expect(animation).toEqual({
id: "animatePackageDisclosure",
kind: "switch",
label: "Paket-Ein-/Ausklappen animieren",
value: true
});
});
it("supports keyboard navigation in animated settings selects", () => { it("supports keyboard navigation in animated settings selects", () => {
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2); expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0); expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
+8
View File
@@ -36,6 +36,14 @@ afterEach(() => {
}); });
describe("settings storage", () => { describe("settings storage", () => {
it("enables package disclosure motion by default and preserves an explicit opt-out", () => {
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
delete legacy.animatePackageDisclosure;
expect(normalizeSettings(legacy as AppSettings).animatePackageDisclosure).toBe(true);
expect(normalizeSettings({ ...defaultSettings(), animatePackageDisclosure: false }).animatePackageDisclosure).toBe(false);
});
it("repairs a persisted version-2 column order that lost availability during default merging", () => { it("repairs a persisted version-2 column order that lost availability during default merging", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);
+1
View File
@@ -117,6 +117,7 @@ function createSettings(): AppSettings {
theme: "dark", theme: "dark",
logStorageLocation: "appdata", logStorageLocation: "appdata",
collapseNewPackages: false, collapseNewPackages: false,
animatePackageDisclosure: true,
historyRetentionMode: "permanent", historyRetentionMode: "permanent",
historyMaxEntries: 500, historyMaxEntries: 500,
historyMaxAgeDays: 0, historyMaxAgeDays: 0,