From 869dbc25be284ab7f53679bedbf2b6b4fa43a5ac Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 21 Aug 2026 00:59:45 +0200 Subject: [PATCH] fix(history): remove selections atomically --- src/main/app-controller.ts | 14 +++++++---- src/main/main.ts | 11 ++++++++ src/main/storage.ts | 22 +++++++++++----- src/preload/preload.ts | 1 + src/renderer/App.tsx | 7 +++--- src/shared/ipc.ts | 1 + src/shared/preload-api.ts | 1 + tests/history-preload.test.ts | 12 ++++++++- tests/history-view.test.tsx | 11 ++++++++ tests/storage.test.ts | 42 +++++++++++++++++++++++++++---- tests/visual/mock-electron-api.ts | 5 ++++ 11 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 5c7c5df..06a0cfb 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -53,7 +53,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { runInstallWithResume } from "./update-install-flow"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; @@ -1381,10 +1381,14 @@ export class AppController { this.manager.resetItems(itemIds); } - public removeHistoryEntry(entryId: string): void { - this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); - removeHistoryEntry(this.storagePaths, entryId); - } + public removeHistoryEntry(entryId: string): void { + this.removeHistoryEntries([entryId]); + } + + public removeHistoryEntries(entryIds: string[]): void { + this.audit("INFO", "Verlaufseinträge entfernt", { count: entryIds.length }); + removeHistoryEntries(this.storagePaths, entryIds, this.historyLimits()); + } public addToHistory(entry: HistoryEntry): void { this.audit("INFO", "Verlaufseintrag hinzugefügt", { diff --git a/src/main/main.ts b/src/main/main.ts index e9a2ac7..20487df 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -97,6 +97,14 @@ function isDevMode(): boolean { return process.env.NODE_ENV === "development"; } +function validateHistoryEntryIds(value: unknown): string[] { + const entryIds = validateStringArray(value, "entryIds"); + if (entryIds.length > 100_000 || entryIds.some((entryId) => entryId.length === 0 || entryId.length > 4_096)) { + throw new Error("entryIds ist ungültig"); + } + return [...new Set(entryIds)]; +} + function getRendererFileUrl(): string { return pathToFileURL(path.join(app.getAppPath(), "build", "renderer", "index.html")).toString(); } @@ -588,6 +596,9 @@ function registerIpcHandlers(): void { validateString(entryId, "entryId"); return controller.removeHistoryEntry(entryId); }); + handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, (_event: IpcMainInvokeEvent, entryIds: unknown) => { + return controller.removeHistoryEntries(validateHistoryEntryIds(entryIds)); + }); handleTrusted(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { return revealHistoryEntry({ entryId }, { loadHistory: () => controller.getHistory(), diff --git a/src/main/storage.ts b/src/main/storage.ts index 35a4bd8..8b89cdf 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1546,12 +1546,22 @@ export function resetHistoryForRetention(paths: StoragePaths, retentionMode: His clearHistory(paths); } -export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { - const existing = loadHistory(paths); - const updated = existing.filter(e => e.id !== entryId); - saveHistory(paths, updated); - return updated; -} +export function removeHistoryEntries(paths: StoragePaths, entryIds: readonly string[], limits?: HistoryLimits): HistoryEntry[] { + const existing = loadHistory(paths, limits); + const removedIds = new Set(entryIds); + if (removedIds.size === 0) { + return existing; + } + const updated = existing.filter((entry) => !removedIds.has(entry.id)); + if (updated.length !== existing.length) { + saveHistory(paths, updated, limits); + } + return updated; +} + +export function removeHistoryEntry(paths: StoragePaths, entryId: string, limits?: HistoryLimits): HistoryEntry[] { + return removeHistoryEntries(paths, [entryId], limits); +} export function clearHistory(paths: StoragePaths): void { ensureBaseDir(paths.baseDir); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index b38f26b..8e9555b 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -124,6 +124,7 @@ const api: ElectronApi = { }, clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), + removeHistoryEntries: (entryIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, entryIds), revealHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId), setPackagePriority: (packageId: string, priority: PackagePriority): Promise => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), skipItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2bff9ff..1442915 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3285,9 +3285,10 @@ export function App(): ReactElement { if (!confirmed) { return; } - const results = await Promise.allSettled(ids.map((id) => window.rd.removeHistoryEntry(id))); - if (results.some((result) => result.status === "rejected")) { - showToast("Einige Verlaufseinträge konnten nicht entfernt werden"); + try { + await window.rd.removeHistoryEntries(ids); + } catch { + showToast(ids.length === 1 ? "Verlaufseintrag konnte nicht entfernt werden" : "Verlaufseinträge konnten nicht entfernt werden"); await loadHistoryEntries(); return; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 150b3f0..6d91a3e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -82,6 +82,7 @@ export const IPC_CHANNELS = { HISTORY_ENTRY_ADDED: "history:entry-added", CLEAR_HISTORY: "history:clear", REMOVE_HISTORY_ENTRY: "history:remove-entry", + REMOVE_HISTORY_ENTRIES: "history:remove-entries", REVEAL_HISTORY_ENTRY: "history:reveal-entry", SET_PACKAGE_PRIORITY: "queue:set-package-priority", SKIP_ITEMS: "queue:skip-items", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 20ef5e7..eaf117c 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -145,6 +145,7 @@ export interface ElectronApi { onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void; clearHistory: () => Promise; removeHistoryEntry: (entryId: string) => Promise; + removeHistoryEntries: (entryIds: string[]) => Promise; revealHistoryEntry: (entryId: string) => Promise; setPackagePriority: (packageId: string, priority: PackagePriority) => Promise; skipItems: (itemIds: string[]) => Promise; diff --git a/tests/history-preload.test.ts b/tests/history-preload.test.ts index 6e64c06..0d708b2 100644 --- a/tests/history-preload.test.ts +++ b/tests/history-preload.test.ts @@ -6,6 +6,7 @@ import type { HistoryEntry } from "../src/shared/types"; const electron = vi.hoisted(() => ({ api: undefined as ElectronApi | undefined, listeners: new Map void>(), + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined), on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { electron.listeners.set(channel, listener); }), @@ -23,7 +24,7 @@ vi.mock("electron", () => ({ } }, ipcRenderer: { - invoke: vi.fn(), + invoke: electron.invoke, on: electron.on, removeListener: electron.removeListener, send: vi.fn() @@ -48,4 +49,13 @@ describe("history preload contract", () => { unsubscribe?.(); expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener); }); + + it("forwards a history selection through one bulk removal channel", async () => { + electron.invoke.mockClear(); + + await electron.api?.removeHistoryEntries(["history-a", "history-b"]); + + expect(electron.invoke).toHaveBeenCalledTimes(1); + expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, ["history-a", "history-b"]); + }); }); diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index cfa2984..bedacb5 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -639,6 +639,17 @@ describe("visual history states", () => { expect(listener).toContain("mergeLiveHistoryEntry"); expect(listener).not.toContain("getHistory()"); }); + + it("removes the selected history ids through one backend call", () => { + const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const removalStart = source.indexOf("const removeHistoryEntries = useCallback"); + const removalEnd = source.indexOf("const clearHistoryEntries", removalStart); + const removal = source.slice(removalStart, removalEnd); + + expect(removal).toContain("window.rd.removeHistoryEntries(ids)"); + expect(removal).not.toContain("Promise.allSettled"); + expect(removal).not.toContain("window.rd.removeHistoryEntry("); + }); it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => { const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading"); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 116a40d..96ce2da 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; @@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { configureCredentialProtector } from "../src/main/credential-protection"; -import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; +import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; const tempDirs: string[] = []; type SettingsSaveMode = "sync" | "async"; @@ -30,8 +30,9 @@ beforeEach(() => { }); }); -afterEach(() => { - for (const dir of tempDirs.splice(0)) { +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); @@ -938,7 +939,7 @@ describe("settings storage", () => { expect(normalized.historyRetentionMode).toBe("permanent"); }); - it("skips adding persisted history entries when history retention is never", () => { + it("skips adding persisted history entries when history retention is never", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); const paths = createStoragePaths(dir); @@ -1200,6 +1201,37 @@ describe("settings storage", () => { })); }); + it("removes a history selection in one pass without applying the default 500-entry limit", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const limits = { maxEntries: 1_000, maxAgeDays: 0 }; + const entries = Array.from({ length: 600 }, (_, index) => ({ + id: `hist-${index}`, + name: `Paket ${index}`, + totalBytes: 1024, + downloadedBytes: 1024, + fileCount: 1, + provider: "realdebrid" as const, + completedAt: Date.now() - index, + durationSeconds: 12, + status: "completed" as const, + outputDir: path.join(dir, `out-${index}`), + urls: [`https://example.com/file-${index}.rar`] + })); + saveHistory(paths, entries, limits); + const renameSpy = vi.spyOn(fs, "renameSync"); + renameSpy.mockClear(); + + const updated = removeHistoryEntries(paths, ["hist-0", "hist-599"], limits); + + expect(renameSpy).toHaveBeenCalledTimes(1); + renameSpy.mockRestore(); + expect(updated).toHaveLength(598); + expect(updated.some((entry) => entry.id === "hist-0" || entry.id === "hist-599")).toBe(false); + expect(loadHistory(paths, limits)).toHaveLength(598); + }); + it("returns empty session when session file contains invalid JSON", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 6df7781..a9f00a1 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -388,6 +388,11 @@ export function createVisualElectronApi( fixture.history.splice(index, 1); } }, + removeHistoryEntries: async (entryIds) => { + const removed = new Set(entryIds); + const retained = fixture.history.filter((entry) => !removed.has(entry.id)); + fixture.history.splice(0, fixture.history.length, ...retained); + }, revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId) ? { ok: true } : { ok: false, reason: "entry-not-found" },