Bughunt: smooth UI actions and skip redundant settings updates

This commit is contained in:
Sucukdeluxe 2026-02-27 17:15:20 +01:00
parent 6b65da7f66
commit c83fa3b86a
3 changed files with 164 additions and 19 deletions

View File

@ -70,11 +70,17 @@ export class AppController {
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
this.settings = normalizeSettings({
const nextSettings = normalizeSettings({
...defaultSettings(),
...this.settings,
...partial
});
if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) {
return this.settings;
}
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
return this.settings;

View File

@ -1,4 +1,4 @@
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { AppSettings, AppTheme, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStats, PackageEntry, UiSnapshot, UpdateCheckResult } from "../shared/types";
type Tab = "collector" | "downloads" | "settings";
@ -66,6 +66,8 @@ export function App(): ReactElement {
const [tab, setTab] = useState<Tab>("collector");
const [statusToast, setStatusToast] = useState("");
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const [settingsDirty, setSettingsDirty] = useState(false);
const settingsDirtyRef = useRef(false);
const latestStateRef = useRef<UiSnapshot | null>(null);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -81,7 +83,9 @@ export function App(): ReactElement {
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const actionBusyRef = useRef(false);
const dragOverRef = useRef(false);
const dragDepthRef = useRef(0);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -89,6 +93,10 @@ export function App(): ReactElement {
activeCollectorTabRef.current = activeCollectorTab;
}, [activeCollectorTab]);
useEffect(() => {
settingsDirtyRef.current = settingsDirty;
}, [settingsDirty]);
const showToast = (message: string, timeoutMs = 2200): void => {
setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
@ -104,6 +112,7 @@ export function App(): ReactElement {
void window.rd.getSnapshot().then((state) => {
setSnapshot(state);
setSettingsDraft(state.settings);
setSettingsDirty(false);
applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
@ -119,7 +128,11 @@ export function App(): ReactElement {
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
if (latestStateRef.current) {
setSnapshot(latestStateRef.current);
const next = latestStateRef.current;
setSnapshot(next);
if (!settingsDirtyRef.current) {
setSettingsDraft(next.settings);
}
latestStateRef.current = null;
}
}, 220);
@ -145,23 +158,28 @@ export function App(): ReactElement {
.map((id: string) => snapshot.session.packages[id])
.filter(Boolean), [snapshot]);
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
useEffect(() => {
setCollapsedPackages((prev) => {
const next: Record<string, boolean> = {};
for (const pkg of packages) {
next[pkg.id] = prev[pkg.id] ?? false;
const defaultCollapsed = snapshot.session.packageOrder.length >= 24;
for (const packageId of snapshot.session.packageOrder) {
next[packageId] = prev[packageId] ?? defaultCollapsed;
}
return next;
});
}, [packages]);
}, [packageOrderKey, snapshot.session.packageOrder.length]);
const deferredDownloadSearch = useDeferredValue(downloadSearch);
const filteredPackages = useMemo(() => {
const query = downloadSearch.trim().toLowerCase();
const query = deferredDownloadSearch.trim().toLowerCase();
if (!query) {
return packages;
}
return packages.filter((pkg) => pkg.name.toLowerCase().includes(query));
}, [packages, downloadSearch]);
}, [packages, deferredDownloadSearch]);
const allPackagesCollapsed = useMemo(() => (
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
@ -250,6 +268,7 @@ export function App(): ReactElement {
try {
const result = await window.rd.updateSettings(normalizedSettingsDraft);
setSettingsDraft(result);
setSettingsDirty(false);
applyTheme(result.theme);
showToast("Einstellungen gespeichert", 1800);
} catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); }
@ -285,6 +304,7 @@ export function App(): ReactElement {
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
event.preventDefault();
dragDepthRef.current = 0;
dragOverRef.current = false;
setDragOver(false);
const files = Array.from(event.dataTransfer.files ?? []) as File[];
@ -338,18 +358,29 @@ export function App(): ReactElement {
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }
};
const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); };
const setBool = (key: keyof AppSettings, value: boolean): void => {
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setText = (key: keyof AppSettings, value: string): void => {
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setNum = (key: keyof AppSettings, value: number): void => {
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, [key]: value }));
};
const setSpeedLimitMbps = (value: number): void => {
const mbps = Number.isFinite(value) ? Math.max(0, value) : 0;
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
};
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => {
if (actionBusy) {
if (actionBusyRef.current) {
return;
}
actionBusyRef.current = true;
setActionBusy(true);
try {
await action();
@ -357,8 +388,9 @@ export function App(): ReactElement {
showToast(`Fehler: ${String(error)}`, 2600);
} finally {
setTimeout(() => {
actionBusyRef.current = false;
setActionBusy(false);
}, 100);
}, 80);
}
};
@ -464,15 +496,20 @@ export function App(): ReactElement {
return (
<div
className={`app-shell${dragOver ? " drag-over" : ""}`}
onDragOver={(e) => {
e.preventDefault();
onDragEnter={(event) => {
event.preventDefault();
dragDepthRef.current += 1;
if (!dragOverRef.current) {
dragOverRef.current = true;
setDragOver(true);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDragLeave={() => {
if (dragOverRef.current) {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0 && dragOverRef.current) {
dragOverRef.current = false;
setDragOver(false);
}
@ -648,6 +685,7 @@ export function App(): ReactElement {
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark";
setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
applyTheme(next as AppTheme);
}}>

View File

@ -500,7 +500,7 @@ describe("download manager", () => {
fs.mkdirSync(pkgDir, { recursive: true });
const existingTargetPath = path.join(pkgDir, "resume.mkv");
fs.writeFileSync(existingTargetPath, binary.subarray(0, partialSize));
let seenRangeStart = -1;
let sawResumeRange = false;
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/resume") {
@ -512,7 +512,9 @@ describe("download manager", () => {
const range = String(req.headers.range || "");
const match = range.match(/bytes=(\d+)-/i);
const start = match ? Number(match[1]) : 0;
seenRangeStart = start;
if (start === partialSize) {
sawResumeRange = true;
}
const chunk = binary.subarray(start);
if (start > 0) {
@ -611,7 +613,7 @@ describe("download manager", () => {
const item = manager.getSnapshot().session.items[itemId];
expect(item?.status).toBe("completed");
expect(item?.targetPath).toBe(existingTargetPath);
expect(seenRangeStart).toBe(partialSize);
expect(sawResumeRange).toBe(true);
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
} finally {
server.close();
@ -1945,6 +1947,105 @@ describe("download manager", () => {
}
});
it("handles rapid pause/resume toggles without deadlock", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const binary = Buffer.alloc(320 * 1024, 11);
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/toggle-stress") {
res.statusCode = 404;
res.end("not-found");
return;
}
const range = String(req.headers.range || "");
const match = range.match(/bytes=(\d+)-/i);
const start = match ? Number(match[1]) : 0;
const chunk = binary.subarray(start);
if (start > 0) {
res.statusCode = 206;
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
} else {
res.statusCode = 200;
}
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(chunk.length));
const split = Math.max(1, Math.floor(chunk.length / 4));
res.write(chunk.subarray(0, split));
setTimeout(() => {
if (!res.writableEnded && !res.destroyed) {
res.end(chunk.subarray(split));
}
}, 260);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("server address unavailable");
}
const directUrl = `http://127.0.0.1:${address.port}/toggle-stress`;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(
JSON.stringify({
download: directUrl,
filename: "stress.part01.rar",
filesize: binary.length
}),
{
status: 200,
headers: { "Content-Type": "application/json" }
}
);
}
return originalFetch(input, init);
};
try {
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "toggle-stress", links: ["https://dummy/toggle-stress"] }]);
manager.start();
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 12000);
for (let i = 0; i < 6; i += 1) {
manager.togglePause();
await new Promise((resolve) => setTimeout(resolve, 90));
}
if (manager.getSnapshot().session.paused) {
manager.togglePause();
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed");
expect(fs.existsSync(item?.targetPath || "")).toBe(true);
expect(fs.statSync(item?.targetPath || "").size).toBe(binary.length);
} finally {
server.close();
await once(server, "close");
}
});
it("keeps active downloads resumable on shutdown preparation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);