From 3a95136ef1e3fe9d2690288fca8d46e07fcb3816 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 21 Aug 2026 00:48:42 +0200 Subject: [PATCH] feat(history): update the open view live --- src/main/app-controller.ts | 31 +++++++++---- src/main/main.ts | 14 ++++-- src/preload/preload.ts | 7 +++ src/renderer/App.tsx | 41 ++++++++++++++++- src/renderer/views/history/history-model.ts | 13 ++++++ src/shared/ipc.ts | 1 + src/shared/preload-api.ts | 1 + tests/history-preload.test.ts | 51 +++++++++++++++++++++ tests/history-view.test.tsx | 35 ++++++++++++-- tests/visual/mock-electron-api.ts | 1 + 10 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 tests/history-preload.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index be9ca4f..5c7c5df 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -118,7 +118,9 @@ export class AppController { private logDirectory = this.storagePaths.baseDir; - private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; + private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; + + private onHistoryEntryAddedHandler: ((entry: HistoryEntry) => void) | null = null; private autoResumePending = false; private runtimeStatsTimer: NodeJS.Timeout | null = null; @@ -160,10 +162,10 @@ export class AppController { realDebridWebUnrestrict: (accountId: string, link: string, signal?: AbortSignal) => this.unrestrictRealDebridWebAccount(accountId, link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), - protectEmptyClobber: loadResult.status === "empty-unreadable", - onHistoryEntry: (entry: HistoryEntry) => { - addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); - } + protectEmptyClobber: loadResult.status === "empty-unreadable", + onHistoryEntry: (entry: HistoryEntry) => { + this.recordHistoryEntry(entry); + } }); this.manager.on("state", (snapshot: UiSnapshot) => { this.onStateHandler?.(snapshot); @@ -270,7 +272,7 @@ export class AppController { return this.onStateHandler; } - public set onState(handler: ((snapshot: UiSnapshot) => void) | null) { + public set onState(handler: ((snapshot: UiSnapshot) => void) | null) { this.onStateHandler = handler; if (handler) { handler(this.manager.getSnapshot()); @@ -453,6 +455,10 @@ export class AppController { overlayLiveUsageCounters(target, liveSettings, this.manager.getLiveTotalRuntimeMs()); } + public set onHistoryEntryAdded(handler: ((entry: HistoryEntry) => void) | null) { + this.onHistoryEntryAddedHandler = handler; + } + private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void { let restoredSettings = normalizeSettings(importedSettings); if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation @@ -1340,9 +1346,16 @@ export class AppController { return true; } - private historyLimits(): { maxEntries: number; maxAgeDays: number } { - return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; - } + private historyLimits(): { maxEntries: number; maxAgeDays: number } { + return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; + } + + private recordHistoryEntry(entry: HistoryEntry): void { + const entries = addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); + if (entries[0]?.id === entry.id) { + this.onHistoryEntryAddedHandler?.(entry); + } + } public getHistory(): HistoryEntry[] { return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()); diff --git a/src/main/main.ts b/src/main/main.ts index 3d7d160..e9a2ac7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -923,13 +923,19 @@ function registerIpcHandlers(): void { } }); - controller.onState = (snapshot) => { + controller.onState = (snapshot) => { if (!mainWindow || mainWindow.isDestroyed()) { return; } - mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot); - }; -} + mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot); + }; + controller.onHistoryEntryAdded = (entry) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + mainWindow.webContents.send(IPC_CHANNELS.HISTORY_ENTRY_ADDED, entry); + }; +} function formatRendererErrorReport(rawReport: unknown): string { const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 3a9ffea..b38f26b 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -115,6 +115,13 @@ const api: ElectronApi = { extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), + onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void): (() => void) => { + const listener = (_event: unknown, entry: HistoryEntry): void => callback(entry); + ipcRenderer.on(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener); + }; + }, clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), revealHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dae8824..2bff9ff 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -73,6 +73,7 @@ import { } from "./views/collector/CollectorView"; import { buildHistoryViewModel, + mergeLiveHistoryEntry, pruneHistoryIds, selectVisibleHistoryIds, type HistoryFilter @@ -1658,6 +1659,8 @@ export function App(): ReactElement { const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); const historyCtxMenuRef = useRef(null); const historyLoadGenerationRef = useRef(0); + const historyLoadActiveRef = useRef(false); + const pendingLiveHistoryEntriesRef = useRef([]); const historyVisibleIdsRef = useRef([]); const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); @@ -1807,6 +1810,8 @@ export function App(): ReactElement { const loadHistoryEntries = useCallback(async (): Promise => { const generation = ++historyLoadGenerationRef.current; + historyLoadActiveRef.current = true; + pendingLiveHistoryEntriesRef.current = []; setHistoryLoading(true); setHistoryError(""); try { @@ -1814,13 +1819,24 @@ export function App(): ReactElement { if (!mountedRef.current || generation !== historyLoadGenerationRef.current) { return; } - applyHistoryEntries(entries); + const settings = snapshotRef.current.settings; + const merged = [...pendingLiveHistoryEntriesRef.current].reverse().reduce( + (current, entry) => mergeLiveHistoryEntry(current, entry, { + maxEntries: settings.historyMaxEntries, + maxAgeDays: settings.historyMaxAgeDays + }), + entries + ); + pendingLiveHistoryEntriesRef.current = []; + applyHistoryEntries(merged); } catch { if (mountedRef.current && generation === historyLoadGenerationRef.current) { setHistoryError("Verlauf konnte nicht geladen werden"); } } finally { if (mountedRef.current && generation === historyLoadGenerationRef.current) { + historyLoadActiveRef.current = false; + pendingLiveHistoryEntriesRef.current = []; setHistoryLoading(false); } } @@ -1833,9 +1849,32 @@ export function App(): ReactElement { void loadHistoryEntries(); return () => { historyLoadGenerationRef.current += 1; + historyLoadActiveRef.current = false; + pendingLiveHistoryEntriesRef.current = []; }; }, [loadHistoryEntries, tab]); + useEffect(() => { + const unsubscribeHistoryEntryAdded = window.rd.onHistoryEntryAdded((entry) => { + if (activeTabRef.current !== "history") { + return; + } + if (historyLoadActiveRef.current) { + pendingLiveHistoryEntriesRef.current = [ + entry, + ...pendingLiveHistoryEntriesRef.current.filter((pending) => pending.id !== entry.id) + ]; + } + const settings = snapshotRef.current.settings; + applyHistoryEntries(mergeLiveHistoryEntry(historyEntriesRef.current, entry, { + maxEntries: settings.historyMaxEntries, + maxAgeDays: settings.historyMaxAgeDays + })); + setHistoryError(""); + }); + return unsubscribeHistoryEntryAdded; + }, [applyHistoryEntries]); + const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { const requestId = allDebridHostRequestRef.current + 1; allDebridHostRequestRef.current = requestId; diff --git a/src/renderer/views/history/history-model.ts b/src/renderer/views/history/history-model.ts index ae6df26..fc31d0f 100644 --- a/src/renderer/views/history/history-model.ts +++ b/src/renderer/views/history/history-model.ts @@ -50,6 +50,19 @@ export interface HistoryPage { export const HISTORY_PAGE_SIZE = 100; +export function mergeLiveHistoryEntry( + entries: readonly HistoryEntry[], + incoming: HistoryEntry, + limits: { maxEntries: number; maxAgeDays: number }, + nowMs: number = Date.now() +): HistoryEntry[] { + const maxEntries = limits.maxEntries > 0 ? Math.min(limits.maxEntries, 100_000) : 500; + const cutoff = limits.maxAgeDays > 0 ? nowMs - limits.maxAgeDays * 86_400_000 : 0; + const merged = [incoming, ...entries.filter((entry) => entry.id !== incoming.id)]; + const retained = cutoff > 0 ? merged.filter((entry) => entry.completedAt >= cutoff) : merged; + return retained.length > maxEntries ? retained.slice(0, maxEntries) : retained; +} + export const HISTORY_TABLE_COLUMN_IDS = ["name", "status", "size", "hoster", "started", "completed"] as const; export type HistoryTableColumnId = typeof HISTORY_TABLE_COLUMN_IDS[number]; export type HistoryTableColumnWidths = Record; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 022191a..150b3f0 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -79,6 +79,7 @@ export const IPC_CHANNELS = { EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", GET_HISTORY: "history:get", + HISTORY_ENTRY_ADDED: "history:entry-added", CLEAR_HISTORY: "history:clear", REMOVE_HISTORY_ENTRY: "history:remove-entry", REVEAL_HISTORY_ENTRY: "history:reveal-entry", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 200c78f..20ef5e7 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -142,6 +142,7 @@ export interface ElectronApi { extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; getHistory: () => Promise; + onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void; clearHistory: () => Promise; removeHistoryEntry: (entryId: string) => Promise; revealHistoryEntry: (entryId: string) => Promise; diff --git a/tests/history-preload.test.ts b/tests/history-preload.test.ts new file mode 100644 index 0000000..6e64c06 --- /dev/null +++ b/tests/history-preload.test.ts @@ -0,0 +1,51 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { IPC_CHANNELS } from "../src/shared/ipc"; +import type { ElectronApi } from "../src/shared/preload-api"; +import type { HistoryEntry } from "../src/shared/types"; + +const electron = vi.hoisted(() => ({ + api: undefined as ElectronApi | undefined, + listeners: new Map void>(), + on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + electron.listeners.set(channel, listener); + }), + removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { + if (electron.listeners.get(channel) === listener) { + electron.listeners.delete(channel); + } + }) +})); + +vi.mock("electron", () => ({ + contextBridge: { + exposeInMainWorld: (_name: string, api: ElectronApi) => { + electron.api = api; + } + }, + ipcRenderer: { + invoke: vi.fn(), + on: electron.on, + removeListener: electron.removeListener, + send: vi.fn() + } +})); + +describe("history preload contract", () => { + beforeAll(async () => { + await import("../src/preload/preload"); + }); + + it("subscribes to saved history entries and removes the exact listener", () => { + const received: HistoryEntry[] = []; + const entry = { id: "history-live" } as HistoryEntry; + const unsubscribe = electron.api?.onHistoryEntryAdded((value) => received.push(value)); + const listener = electron.listeners.get(IPC_CHANNELS.HISTORY_ENTRY_ADDED); + + expect(listener).toBeTypeOf("function"); + listener?.({}, entry); + expect(received).toEqual([entry]); + + unsubscribe?.(); + expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener); + }); +}); diff --git a/tests/history-view.test.tsx b/tests/history-view.test.tsx index e13b95b..cfa2984 100644 --- a/tests/history-view.test.tsx +++ b/tests/history-view.test.tsx @@ -11,8 +11,9 @@ import { createHistoryTableColumnWidths, getHistoryTableGridTemplate, getHistoryTableMinWidth, - HISTORY_PAGE_SIZE, - paginateHistoryRows, + HISTORY_PAGE_SIZE, + mergeLiveHistoryEntry, + paginateHistoryRows, pruneHistoryIds, resizeHistoryTableColumn, selectVisibleHistoryIds, @@ -106,8 +107,20 @@ const entries: HistoryViewEntry[] = [ entry({ id: "older", name: "Altes Paket", completedAt: weekStart - 1, status: "failed", provider: null, outputDir: "D:\\Archiv\\Alt", urls: ["https://sub.example.test/a"] }) ]; -describe("history model", () => { - it("separates today, previous six calendar days, older and status filters at exact boundaries", () => { +describe("history model", () => { + it("prepends live entries without duplicates and applies retention limits", () => { + const incoming = entry({ id: "live", name: "Live", completedAt: now }) as HistoryEntry; + const existing: HistoryEntry[] = [ + entry({ id: "current", name: "Current", completedAt: now - 1_000 }) as HistoryEntry, + entry({ id: "live", name: "Old live", completedAt: now - 2_000 }) as HistoryEntry, + entry({ id: "expired", name: "Expired", completedAt: now - 3 * 86_400_000 }) as HistoryEntry + ]; + + expect(mergeLiveHistoryEntry(existing, incoming, { maxEntries: 2, maxAgeDays: 2 }, now).map((item) => item.id)) + .toEqual(["live", "current"]); + }); + + it("separates today, previous six calendar days, older and status filters at exact boundaries", () => { const expected: Record = { all: ["today", "week-edge", "week", "older"], today: ["today"], @@ -613,7 +626,19 @@ describe("visual history states", () => { expect(setup).toBeGreaterThan(effectStart); expect(setup).toBeLessThan(firstRequest); expect(cleanup).toBeGreaterThan(firstRequest); - }); + }); + + it("merges pushed history entries only while the history tab is open", () => { + const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const listenerStart = source.indexOf("window.rd.onHistoryEntryAdded"); + const listenerEnd = source.indexOf("return unsubscribeHistoryEntryAdded", listenerStart); + const listener = source.slice(listenerStart, listenerEnd); + + expect(listenerStart).toBeGreaterThan(-1); + expect(listener).toContain('activeTabRef.current !== "history"'); + expect(listener).toContain("mergeLiveHistoryEntry"); + expect(listener).not.toContain("getHistory()"); + }); 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/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 58e7724..6df7781 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -449,6 +449,7 @@ export function createVisualElectronApi( stateUpdateListeners.add(callback); return () => stateUpdateListeners.delete(callback); }, + onHistoryEntryAdded: () => stableNoopUnsubscribe, onClipboardDetected: () => stableNoopUnsubscribe, onUpdateInstallProgress: () => stableNoopUnsubscribe };