feat(history): update the open view live
This commit is contained in:
@@ -120,6 +120,8 @@ export class AppController {
|
|||||||
|
|
||||||
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 autoResumePending = false;
|
||||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
||||||
private lastMemoryWarnAt = 0;
|
private lastMemoryWarnAt = 0;
|
||||||
@@ -162,7 +164,7 @@ export class AppController {
|
|||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
protectEmptyClobber: loadResult.status === "empty-unreadable",
|
||||||
onHistoryEntry: (entry: HistoryEntry) => {
|
onHistoryEntry: (entry: HistoryEntry) => {
|
||||||
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
|
this.recordHistoryEntry(entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||||
@@ -453,6 +455,10 @@ export class AppController {
|
|||||||
overlayLiveUsageCounters(target, liveSettings, this.manager.getLiveTotalRuntimeMs());
|
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 {
|
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
|
||||||
let restoredSettings = normalizeSettings(importedSettings);
|
let restoredSettings = normalizeSettings(importedSettings);
|
||||||
if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation
|
if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation
|
||||||
@@ -1344,6 +1350,13 @@ export class AppController {
|
|||||||
return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays };
|
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[] {
|
public getHistory(): HistoryEntry[] {
|
||||||
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
|
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -929,6 +929,12 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
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 {
|
function formatRendererErrorReport(rawReport: unknown): string {
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ const api: ElectronApi = {
|
|||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
getHistory: (): Promise<HistoryEntry[]> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||||
revealHistoryEntry: (entryId: string): Promise<HistoryRevealResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId),
|
revealHistoryEntry: (entryId: string): Promise<HistoryRevealResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId),
|
||||||
|
|||||||
+40
-1
@@ -73,6 +73,7 @@ import {
|
|||||||
} from "./views/collector/CollectorView";
|
} from "./views/collector/CollectorView";
|
||||||
import {
|
import {
|
||||||
buildHistoryViewModel,
|
buildHistoryViewModel,
|
||||||
|
mergeLiveHistoryEntry,
|
||||||
pruneHistoryIds,
|
pruneHistoryIds,
|
||||||
selectVisibleHistoryIds,
|
selectVisibleHistoryIds,
|
||||||
type HistoryFilter
|
type HistoryFilter
|
||||||
@@ -1658,6 +1659,8 @@ export function App(): ReactElement {
|
|||||||
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
|
const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null);
|
||||||
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
|
const historyCtxMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const historyLoadGenerationRef = useRef(0);
|
const historyLoadGenerationRef = useRef(0);
|
||||||
|
const historyLoadActiveRef = useRef(false);
|
||||||
|
const pendingLiveHistoryEntriesRef = useRef<HistoryEntry[]>([]);
|
||||||
const historyVisibleIdsRef = useRef<string[]>([]);
|
const historyVisibleIdsRef = useRef<string[]>([]);
|
||||||
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
|
const [allDebridHostInfo, setAllDebridHostInfo] = useState<AllDebridHostInfo | null>(null);
|
||||||
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
||||||
@@ -1807,6 +1810,8 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const loadHistoryEntries = useCallback(async (): Promise<void> => {
|
const loadHistoryEntries = useCallback(async (): Promise<void> => {
|
||||||
const generation = ++historyLoadGenerationRef.current;
|
const generation = ++historyLoadGenerationRef.current;
|
||||||
|
historyLoadActiveRef.current = true;
|
||||||
|
pendingLiveHistoryEntriesRef.current = [];
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
setHistoryError("");
|
setHistoryError("");
|
||||||
try {
|
try {
|
||||||
@@ -1814,13 +1819,24 @@ export function App(): ReactElement {
|
|||||||
if (!mountedRef.current || generation !== historyLoadGenerationRef.current) {
|
if (!mountedRef.current || generation !== historyLoadGenerationRef.current) {
|
||||||
return;
|
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 {
|
} catch {
|
||||||
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
||||||
setHistoryError("Verlauf konnte nicht geladen werden");
|
setHistoryError("Verlauf konnte nicht geladen werden");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
if (mountedRef.current && generation === historyLoadGenerationRef.current) {
|
||||||
|
historyLoadActiveRef.current = false;
|
||||||
|
pendingLiveHistoryEntriesRef.current = [];
|
||||||
setHistoryLoading(false);
|
setHistoryLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1833,9 +1849,32 @@ export function App(): ReactElement {
|
|||||||
void loadHistoryEntries();
|
void loadHistoryEntries();
|
||||||
return () => {
|
return () => {
|
||||||
historyLoadGenerationRef.current += 1;
|
historyLoadGenerationRef.current += 1;
|
||||||
|
historyLoadActiveRef.current = false;
|
||||||
|
pendingLiveHistoryEntriesRef.current = [];
|
||||||
};
|
};
|
||||||
}, [loadHistoryEntries, tab]);
|
}, [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<void> => {
|
const loadAllDebridHostInfo = useCallback(async (silent = false): Promise<void> => {
|
||||||
const requestId = allDebridHostRequestRef.current + 1;
|
const requestId = allDebridHostRequestRef.current + 1;
|
||||||
allDebridHostRequestRef.current = requestId;
|
allDebridHostRequestRef.current = requestId;
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ export interface HistoryPage {
|
|||||||
|
|
||||||
export const HISTORY_PAGE_SIZE = 100;
|
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 const HISTORY_TABLE_COLUMN_IDS = ["name", "status", "size", "hoster", "started", "completed"] as const;
|
||||||
export type HistoryTableColumnId = typeof HISTORY_TABLE_COLUMN_IDS[number];
|
export type HistoryTableColumnId = typeof HISTORY_TABLE_COLUMN_IDS[number];
|
||||||
export type HistoryTableColumnWidths = Record<HistoryTableColumnId, number>;
|
export type HistoryTableColumnWidths = Record<HistoryTableColumnId, number>;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const IPC_CHANNELS = {
|
|||||||
EXTRACT_NOW: "queue:extract-now",
|
EXTRACT_NOW: "queue:extract-now",
|
||||||
RESET_PACKAGE: "queue:reset-package",
|
RESET_PACKAGE: "queue:reset-package",
|
||||||
GET_HISTORY: "history:get",
|
GET_HISTORY: "history:get",
|
||||||
|
HISTORY_ENTRY_ADDED: "history:entry-added",
|
||||||
CLEAR_HISTORY: "history:clear",
|
CLEAR_HISTORY: "history:clear",
|
||||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||||
REVEAL_HISTORY_ENTRY: "history:reveal-entry",
|
REVEAL_HISTORY_ENTRY: "history:reveal-entry",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export interface ElectronApi {
|
|||||||
extractNow: (packageId: string) => Promise<void>;
|
extractNow: (packageId: string) => Promise<void>;
|
||||||
resetPackage: (packageId: string) => Promise<void>;
|
resetPackage: (packageId: string) => Promise<void>;
|
||||||
getHistory: () => Promise<HistoryEntry[]>;
|
getHistory: () => Promise<HistoryEntry[]>;
|
||||||
|
onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void;
|
||||||
clearHistory: () => Promise<void>;
|
clearHistory: () => Promise<void>;
|
||||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||||
revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>;
|
revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>;
|
||||||
|
|||||||
@@ -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<string, (...args: unknown[]) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getHistoryTableGridTemplate,
|
getHistoryTableGridTemplate,
|
||||||
getHistoryTableMinWidth,
|
getHistoryTableMinWidth,
|
||||||
HISTORY_PAGE_SIZE,
|
HISTORY_PAGE_SIZE,
|
||||||
|
mergeLiveHistoryEntry,
|
||||||
paginateHistoryRows,
|
paginateHistoryRows,
|
||||||
pruneHistoryIds,
|
pruneHistoryIds,
|
||||||
resizeHistoryTableColumn,
|
resizeHistoryTableColumn,
|
||||||
@@ -107,6 +108,18 @@ const entries: HistoryViewEntry[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("history model", () => {
|
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", () => {
|
it("separates today, previous six calendar days, older and status filters at exact boundaries", () => {
|
||||||
const expected: Record<HistoryFilter, string[]> = {
|
const expected: Record<HistoryFilter, string[]> = {
|
||||||
all: ["today", "week-edge", "week", "older"],
|
all: ["today", "week-edge", "week", "older"],
|
||||||
@@ -615,6 +628,18 @@ describe("visual history states", () => {
|
|||||||
expect(cleanup).toBeGreaterThan(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 () => {
|
it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => {
|
||||||
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
|
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
|
||||||
await expect(loadingApi.getHistory()).resolves.toHaveLength(2);
|
await expect(loadingApi.getHistory()).resolves.toHaveLength(2);
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ export function createVisualElectronApi(
|
|||||||
stateUpdateListeners.add(callback);
|
stateUpdateListeners.add(callback);
|
||||||
return () => stateUpdateListeners.delete(callback);
|
return () => stateUpdateListeners.delete(callback);
|
||||||
},
|
},
|
||||||
|
onHistoryEntryAdded: () => stableNoopUnsubscribe,
|
||||||
onClipboardDetected: () => stableNoopUnsubscribe,
|
onClipboardDetected: () => stableNoopUnsubscribe,
|
||||||
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
onUpdateInstallProgress: () => stableNoopUnsubscribe
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user