Bughunt: smooth UI actions and skip redundant settings updates
This commit is contained in:
parent
6b65da7f66
commit
c83fa3b86a
@ -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;
|
||||
|
||||
@ -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);
|
||||
}}>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user