diff --git a/src/main/constants.ts b/src/main/constants.ts index 8369a4f..c89a948 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -105,6 +105,7 @@ export function defaultSettings(): AppSettings { theme: "dark" as const, logStorageLocation: "appdata", collapseNewPackages: true, + animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 232d9ba..25ce8c4 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -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, diff --git a/src/main/storage.ts b/src/main/storage.ts index 7f62366..e329a53 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -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, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa0c438..9a3556c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 52f7b4a..bd55832 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -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"], diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 930856a..86c65a0 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -48,6 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { sortColumn?: DownloadSortColumn; sortDirection?: "asc" | "desc"; disclosureRevision: number; + animatePackageDisclosure: boolean; status: DownloadsStatusModel; } diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 47ae459..45dc711 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -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(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index ab5b27f..715e89f 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -92,9 +92,10 @@ function groupRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map row.type === "package")) { + if (!enabled || !desired.some((row) => row.type === "package")) { return { animated: false, rows: stableDownloadDisclosureRows(desired) }; } diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index cb9ba5b..5ff2fc8 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -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 }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 6376d2e..b2e7c84 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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; diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index b278b0a..205c563 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -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 { 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); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 65272b7..1fd8b3f 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -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; + 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); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 425b966..1d61e4e 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -117,6 +117,7 @@ function createSettings(): AppSettings { theme: "dark", logStorageLocation: "appdata", collapseNewPackages: false, + animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0,