Backup: nur Settings als Default + 4 Selektions/Flicker-Bugfixes

Backup:
- Neues Setting backupIncludeDownloads (Default aus) — Backup sichert
  standardmaessig NUR Einstellungen, nicht die Download-Liste/History.
- buildBackupPayload/planBackupImport (testbare backup-payload.ts): Export
  omittet session+history wenn Flag aus (explizites kind-Marker); Import folgt
  dem FILE-Inhalt, nicht dem lokalen Toggle.
- importBackup: settings-only -> frueher Return nach setSettings, KEIN stop/
  Queue-Wipe/Relaunch. Return {restored,relaunch,message}; main.ts gated den
  Auto-Relaunch auf relaunch. Renderer re-seeded settingsDraft bei !relaunch.

Bugfixes:
- Ctrl+A waehlte das ungefilterte Paket-Map -> Loeschen nach Suche traf
  versteckte Pakete. Jetzt visibleOrderIds (sichtbare Zeilen, inkl. Items).
- selectedIds nie geprunt bei Delta-Removal -> aufgeblaehte Counts. Neue pure
  pruneSelection (selection.ts) + Effect.
- link-status-dot conditional -> Dateiname sprang ~14px. Platzhalter-Slot.
- sortPackagesForDisplay sortierte aktive Pakete nach Live-Progress -> Reshuffle
  pro Tick. Jetzt stabile Queue-Reihenfolge je Gruppe (Anti-Flicker).

+17 Tests (backup-payload 9, selection 5, package-order anti-flicker 3).
This commit is contained in:
Sucukdeluxe
2026-06-07 04:40:54 +02:00
parent 3ed3877ac9
commit d006a60553
15 changed files with 354 additions and 56 deletions
+30 -3
View File
@@ -32,6 +32,7 @@ import {
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
@@ -850,7 +851,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", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
@@ -2109,6 +2110,13 @@ export function App(): ReactElement {
});
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
// Prune selection when its packages/items disappear (e.g. via delta-removal or
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
// a stale id would otherwise inflate the selection count and the "(N)" labels.
useEffect(() => {
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
}, [snapshot.session.packages, snapshot.session.items]);
const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length)
: 0;
@@ -3539,6 +3547,11 @@ export function App(): ReactElement {
return ids;
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
// Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard
// handler can select exactly what the user sees — not the whole unfiltered map.
const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds);
visibleOrderIdsRef.current = visibleOrderIds;
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
if (dragDidMoveRef.current) return;
if (shiftKey && lastClickedIdRef.current) {
@@ -3838,6 +3851,13 @@ export function App(): ReactElement {
const result = await window.rd.importBackup();
if (result.restored) {
showToast(result.message, 4000);
// A settings-only import applies live without a relaunch, so the editable
// settings form would otherwise keep showing the old values. Pull the
// fresh settings and re-seed the draft so the UI reflects the import.
if (!result.relaunch) {
const fresh = await window.rd.getSnapshot();
applyPersistedSettings(fresh.settings);
}
} else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
}
@@ -3961,7 +3981,10 @@ export function App(): ReactElement {
if (inInput) return;
if (tabRef.current === "downloads") {
e.preventDefault();
setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
// Select exactly the VISIBLE rows (packages + their items), honouring
// the active search / collapse / hide-extracted filters — selecting
// the unfiltered package map would let a later delete hit hidden ones.
setSelectedIds(new Set(visibleOrderIdsRef.current));
} else if (tabRef.current === "history") {
e.preventDefault();
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
@@ -4881,6 +4904,7 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
@@ -6335,7 +6359,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
switch (col) {
case "name": return (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
<span
className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"}
title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined}
/>
{item.fileName}
</span>
);
+15 -27
View File
@@ -36,38 +36,26 @@ export function sortPackagesForDisplay(
return packages;
}
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
const active: PackageEntry[] = [];
const rest: PackageEntry[] = [];
packages.forEach((pkg, index) => {
const items = pkg.itemIds
.map((id) => itemsById[id])
.filter((item): item is DownloadItem => Boolean(item));
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
if (!hasActive) {
rest.push(pkg);
return;
}
const completedRatio = items.length > 0
? items.filter((item) => item.status === "completed").length / items.length
: 0;
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
active.push({ pkg, index, completedRatio, downloadedBytes });
});
// Float packages that have an active item to the top, but keep BOTH groups in
// their original (queue) order. Earlier this sorted the active group by live
// completedRatio/downloadedBytes — which change on every progress tick (every
// 150-700ms), so active packages visibly reshuffled the whole time. A package
// entering/leaving the active bucket is a real, discrete event (start/finish);
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
for (const pkg of packages) {
const hasActive = pkg.itemIds.some((id) => {
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
(hasActive ? active : rest).push(pkg);
}
if (active.length === 0 || active.length === packages.length) {
return packages;
}
active.sort((a, b) => {
if (a.completedRatio !== b.completedRatio) {
return b.completedRatio - a.completedRatio;
}
if (a.downloadedBytes !== b.downloadedBytes) {
return b.downloadedBytes - a.downloadedBytes;
}
return a.index - b.index;
});
return [...active.map((entry) => entry.pkg), ...rest];
return [...active, ...rest];
}
+27
View File
@@ -0,0 +1,27 @@
import type { SessionState } from "../shared/types";
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
* otherwise inflate the selection count and the "(N)" action labels and keep
* "multi" styling alive for ghosts.
*
* Returns the SAME set instance when nothing changed, so callers can use it
* directly as a React state updater without forcing a re-render.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}
+6
View File
@@ -2434,6 +2434,12 @@ td {
background: #f59e0b;
box-shadow: 0 0 4px #f59e0b80;
}
/* Reserve the dot's footprint even before a status exists, so the filename does
not shift ~14px right when the online/offline/checking dot first appears. */
.link-status-dot-empty {
background: transparent;
box-shadow: none;
}
.prio-high {
color: #f59e0b !important;