Bughunt: harden async UI flows and optimize large-queue rendering

This commit is contained in:
Sucukdeluxe 2026-02-27 17:31:05 +01:00
parent c83fa3b86a
commit f5e020da4e

View File

@ -160,6 +160,29 @@ export function App(): ReactElement {
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
const packagePosition = useMemo(() => {
const map = new Map<string, number>();
snapshot.session.packageOrder.forEach((id, index) => {
map.set(id, index);
});
return map;
}, [packageOrderKey]);
const itemsByPackage = useMemo(() => {
const map = new Map<string, DownloadItem[]>();
for (const packageId of snapshot.session.packageOrder) {
const pkg = snapshot.session.packages[packageId];
if (!pkg) {
continue;
}
const items = pkg.itemIds
.map((id) => snapshot.session.items[id])
.filter(Boolean) as DownloadItem[];
map.set(packageId, items);
}
return map;
}, [packageOrderKey, snapshot.session.items, snapshot.session.packages]);
useEffect(() => { useEffect(() => {
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
@ -265,41 +288,51 @@ export function App(): ReactElement {
}; };
const onSaveSettings = async (): Promise<void> => { const onSaveSettings = async (): Promise<void> => {
try { await performQuickAction(async () => {
const result = await window.rd.updateSettings(normalizedSettingsDraft); const result = await window.rd.updateSettings(normalizedSettingsDraft);
setSettingsDraft(result); setSettingsDraft(result);
setSettingsDirty(false); setSettingsDirty(false);
applyTheme(result.theme); applyTheme(result.theme);
showToast("Einstellungen gespeichert", 1800); showToast("Einstellungen gespeichert", 1800);
} catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); } }, (error) => {
showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800);
});
}; };
const onCheckUpdates = async (): Promise<void> => { const onCheckUpdates = async (): Promise<void> => {
try { await performQuickAction(async () => {
const result = await window.rd.checkUpdates(); const result = await window.rd.checkUpdates();
await handleUpdateResult(result, "manual"); await handleUpdateResult(result, "manual");
} catch (error) { showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); } }, (error) => {
showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800);
});
}; };
const onAddLinks = async (): Promise<void> => { const onAddLinks = async (): Promise<void> => {
try { await performQuickAction(async () => {
await window.rd.updateSettings(normalizedSettingsDraft); await window.rd.updateSettings(normalizedSettingsDraft);
const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName });
if (result.addedLinks > 0) { if (result.addedLinks > 0) {
showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`);
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t)); setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t));
} else { showToast("Keine gültigen Links gefunden"); } } else {
} catch (error) { showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); } showToast("Keine gültigen Links gefunden");
}
}, (error) => {
showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600);
});
}; };
const onImportDlc = async (): Promise<void> => { const onImportDlc = async (): Promise<void> => {
try { await performQuickAction(async () => {
const files = await window.rd.pickContainers(); const files = await window.rd.pickContainers();
if (files.length === 0) { return; } if (files.length === 0) { return; }
await window.rd.updateSettings(normalizedSettingsDraft); await window.rd.updateSettings(normalizedSettingsDraft);
const result = await window.rd.addContainers(files); const result = await window.rd.addContainers(files);
showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); } }, (error) => {
showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600);
});
}; };
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => { const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
@ -311,11 +344,13 @@ export function App(): ReactElement {
const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v); const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v);
const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || "";
if (dlc.length > 0) { if (dlc.length > 0) {
try { await performQuickAction(async () => {
await window.rd.updateSettings(normalizedSettingsDraft); await window.rd.updateSettings(normalizedSettingsDraft);
const result = await window.rd.addContainers(dlc); const result = await window.rd.addContainers(dlc);
showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); } }, (error) => {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} else if (droppedText.trim()) { } else if (droppedText.trim()) {
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t)); ? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
@ -325,7 +360,7 @@ export function App(): ReactElement {
}; };
const onExportQueue = async (): Promise<void> => { const onExportQueue = async (): Promise<void> => {
try { await performQuickAction(async () => {
const json = await window.rd.exportQueue(); const json = await window.rd.exportQueue();
const blob = new Blob([json], { type: "application/json" }); const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -335,27 +370,33 @@ export function App(): ReactElement {
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast("Queue exportiert"); showToast("Queue exportiert");
} catch (error) { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); } }, (error) => {
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
});
}; };
const onImportQueue = async (): Promise<void> => { const onImportQueue = async (): Promise<void> => {
try { if (actionBusyRef.current) {
const input = document.createElement("input"); return;
input.type = "file"; }
input.accept = ".json";
input.onchange = async () => { const input = document.createElement("input");
const file = input.files?.[0]; input.type = "file";
if (!file) { return; } input.accept = ".json";
try { input.onchange = async () => {
const text = await file.text(); const file = input.files?.[0];
const result = await window.rd.importQueue(text); if (!file) {
showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); return;
} catch (error) { }
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); await performQuickAction(async () => {
} const text = await file.text();
}; const result = await window.rd.importQueue(text);
input.click(); showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`);
} catch (error) { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); } }, (error) => {
showToast(`Import fehlgeschlagen: ${String(error)}`, 2600);
});
};
input.click();
}; };
const setBool = (key: keyof AppSettings, value: boolean): void => { const setBool = (key: keyof AppSettings, value: boolean): void => {
@ -376,7 +417,10 @@ export function App(): ReactElement {
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
}; };
const performQuickAction = async (action: () => Promise<unknown>): Promise<void> => { const performQuickAction = async (
action: () => Promise<unknown>,
onError?: (error: unknown) => void
): Promise<void> => {
if (actionBusyRef.current) { if (actionBusyRef.current) {
return; return;
} }
@ -385,7 +429,11 @@ export function App(): ReactElement {
try { try {
await action(); await action();
} catch (error) { } catch (error) {
showToast(`Fehler: ${String(error)}`, 2600); if (onError) {
onError(error);
} else {
showToast(`Fehler: ${String(error)}`, 2600);
}
} finally { } finally {
setTimeout(() => { setTimeout(() => {
actionBusyRef.current = false; actionBusyRef.current = false;
@ -579,10 +627,10 @@ export function App(): ReactElement {
<div className="collector-header"> <div className="collector-header">
<h3>Linksammler</h3> <h3>Linksammler</h3>
<div className="link-actions"> <div className="link-actions">
<button className="btn" onClick={onImportDlc}>DLC import</button> <button className="btn" disabled={actionBusy} onClick={onImportDlc}>DLC import</button>
<button className="btn" onClick={onExportQueue}>Queue Export</button> <button className="btn" disabled={actionBusy} onClick={onExportQueue}>Queue Export</button>
<button className="btn" onClick={onImportQueue}>Queue Import</button> <button className="btn" disabled={actionBusy} onClick={onImportQueue}>Queue Import</button>
<button className="btn accent" onClick={onAddLinks}>Zur Queue hinzufügen</button> <button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
</div> </div>
</div> </div>
<div className="collector-tabs"> <div className="collector-tabs">
@ -648,10 +696,10 @@ export function App(): ReactElement {
<PackageCard <PackageCard
key={pkg.id} key={pkg.id}
pkg={pkg} pkg={pkg}
items={pkg.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean)} items={itemsByPackage.get(pkg.id) ?? []}
packageSpeed={packageSpeedMap.get(pkg.id) ?? 0} packageSpeed={packageSpeedMap.get(pkg.id) ?? 0}
isFirst={snapshot.session.packageOrder.indexOf(pkg.id) === 0} isFirst={(packagePosition.get(pkg.id) ?? -1) === 0}
isLast={snapshot.session.packageOrder.indexOf(pkg.id) === snapshot.session.packageOrder.length - 1} isLast={(packagePosition.get(pkg.id) ?? -1) === snapshot.session.packageOrder.length - 1}
isEditing={editingPackageId === pkg.id} isEditing={editingPackageId === pkg.id}
editingName={editingName} editingName={editingName}
collapsed={collapsedPackages[pkg.id] ?? false} collapsed={collapsedPackages[pkg.id] ?? false}
@ -682,7 +730,7 @@ export function App(): ReactElement {
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span> <span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
</div> </div>
<div className="settings-toolbar-actions"> <div className="settings-toolbar-actions">
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button> <button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => { <button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark"; const next = settingsDraft.theme === "dark" ? "light" : "dark";
setSettingsDirty(true); setSettingsDirty(true);
@ -691,7 +739,7 @@ export function App(): ReactElement {
}}> }}>
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"} {settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
</button> </button>
<button className="btn accent" onClick={onSaveSettings}>Einstellungen speichern</button> <button className="btn accent" disabled={actionBusy} onClick={onSaveSettings}>Einstellungen speichern</button>
</div> </div>
</article> </article>