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:
+30
-3
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user