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,
logStorageLocation: "appdata",
collapseNewPackages: true,
animatePackageDisclosure: true,
historyRetentionMode: "permanent",
historyMaxEntries: 500,
historyMaxAgeDays: 0,
+1
View File
@@ -181,6 +181,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
theme: settings.theme,
logStorageLocation: settings.logStorageLocation,
collapseNewPackages: settings.collapseNewPackages,
animatePackageDisclosure: settings.animatePackageDisclosure,
historyRetentionMode: settings.historyRetentionMode,
historyMaxEntries: settings.historyMaxEntries,
historyMaxAgeDays: settings.historyMaxAgeDays,
+1
View File
@@ -527,6 +527,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
? settings.logStorageLocation
: defaults.logStorageLocation,
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)
? settings.historyRetentionMode
: defaults.historyRetentionMode,
+3 -2
View File
@@ -821,7 +821,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
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,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@@ -4597,6 +4597,7 @@ export function App(): ReactElement {
sortColumn: downloadsSortColumn,
sortDirection: downloadsSortDescending ? "desc" : "asc",
disclosureRevision: downloadDisclosureRevision,
animatePackageDisclosure: snapshot.settings.animatePackageDisclosure,
status: {
packages: snapshot.stats.totalPackages,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
@@ -4610,7 +4611,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
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 = {
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"],
["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"],
["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"],
["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"],
@@ -48,6 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
disclosureRevision: number;
animatePackageDisclosure: boolean;
status: DownloadsStatusModel;
}
@@ -117,7 +117,11 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
}, [desiredRows]);
useRendererLayoutEffect(() => {
const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current);
const prepared = prepareDownloadDisclosureTransition(
transitionRowsRef.current ?? previousRowsRef.current,
desiredRowsRef.current,
model.animatePackageDisclosure
);
if (!prepared.animated) {
transitionRowsRef.current = null;
transitionPinnedIdsRef.current = [];
@@ -148,7 +152,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
cancelActivation();
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 virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
@@ -92,9 +92,10 @@ function groupRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<s
export function prepareDownloadDisclosureTransition(
current: readonly DownloadDisclosureSourceRow[],
desired: readonly DownloadLogicalRow[]
desired: readonly DownloadLogicalRow[],
enabled = true
): { 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) };
}
@@ -519,6 +519,7 @@ export function buildSettingsFormViewModel({
title: "Oberfläche und Bedienung",
fields: [
{ 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: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
+2
View File
@@ -131,6 +131,7 @@ export interface AppSettings {
theme: AppTheme;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
historyMaxAgeDays: number;
@@ -250,6 +251,7 @@ export interface RendererSettings {
theme: AppTheme;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
animatePackageDisclosure: boolean;
historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
historyMaxAgeDays: number;
+14
View File
@@ -227,6 +227,19 @@ describe("virtualisierte Paketanimation", () => {
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", () => {
const frames: FrameRequestCallback[] = [];
const cancelled: number[] = [];
@@ -659,6 +672,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0,
animatePackageDisclosure: true,
editingPackageId: null,
editingName: "",
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);
});
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", () => {
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
+8
View File
@@ -36,6 +36,14 @@ afterEach(() => {
});
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", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
+1
View File
@@ -117,6 +117,7 @@ function createSettings(): AppSettings {
theme: "dark",
logStorageLocation: "appdata",
collapseNewPackages: false,
animatePackageDisclosure: true,
historyRetentionMode: "permanent",
historyMaxEntries: 500,
historyMaxAgeDays: 0,