fix(ui): harden main window interactions

This commit is contained in:
NimBold
2026-07-15 01:25:59 +03:30
parent 0eee47b97f
commit 76850f2433
14 changed files with 258 additions and 97 deletions
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
}
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
progressMap: {},
updateDownloadProgress: (id, payload) =>
set((state) => ({
progressMap: {
...state.progressMap,
[id]: payload,
},
})),
clearDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
return { progressMap: next };
}),
}));
+30
View File
@@ -83,4 +83,34 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
release();
});
it('clears progress when events arrive after a download row was removed', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadProgressStore.getState().updateDownloadProgress('removed', {
id: 'removed',
fraction: 0.8,
speed: '1 MB/s',
eta: '2s',
size: '8 MB',
size_is_final: false
});
useDownloadStore.setState({ downloads: [] });
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'removed',
fraction: 0.9,
speed: '2 MB/s',
eta: '1s',
size: '9 MB',
size_is_final: false
} });
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
});
+64 -81
View File
@@ -1,36 +1,13 @@
import { create } from 'zustand';
import type { UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import { listenEvent as listen } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import { categoryForFileName } from '../utils/downloads';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
}
import { useDownloadProgressStore } from './downloadProgressStore';
import { useDownloadStore } from './useDownloadStore';
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
progressMap: {},
updateDownloadProgress: (id, payload) =>
set((state) => ({
progressMap: {
...state.progressMap,
[id]: payload,
},
})),
clearDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
return { progressMap: next };
}),
}));
export { useDownloadProgressStore } from './downloadProgressStore';
let unlistenProgress: UnlistenFn | null = null;
let unlistenState: UnlistenFn | null = null;
@@ -54,6 +31,12 @@ const startDownloadListeners = async () => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (!current) {
// A removed row can still have one queued sidecar event in flight.
// Do not let that event recreate an orphaned progress entry.
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
// A sidecar can flush one last progress chunk after a pause, failure,
// or completion event. Do not let that stale chunk repopulate the live
// progress map or overwrite a later lifecycle's first frame.
@@ -61,73 +44,73 @@ const startDownloadListeners = async () => {
return;
}
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
if (current) {
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
const updates: Partial<DownloadItem> = {};
if (current.status === 'downloading' || current.status === 'processing') {
updates.fraction = payload.fraction;
updates.speed = payload.speed;
updates.eta = payload.eta;
}
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
}),
listen('download-state', (event) => {
const payload = event.payload;
const mainStore = useDownloadStore.getState();
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const status = payload.status as DownloadStatus;
if (!current) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
return;
}
const status = payload.status as DownloadStatus;
// Prevent race condition: don't transition backwards from terminal state
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
// Prevent race condition: don't transition backwards from terminal state
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
? { lastTry: new Date().toISOString() }
: {})
};
if (!payload.error && status !== 'failed' && status !== 'retrying') {
updates.lastError = undefined;
}
if (payload.fileName && payload.fileName !== current.fileName) {
updates.fileName = payload.fileName;
updates.category = categoryForFileName(payload.fileName);
}
if (status !== 'downloading') {
updates.speed = '-';
updates.eta = '-';
}
mainStore.updateDownload(payload.id, updates);
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
? { lastTry: new Date().toISOString() }
: {})
};
if (!payload.error && status !== 'failed' && status !== 'retrying') {
updates.lastError = undefined;
}
if (payload.fileName && payload.fileName !== current.fileName) {
updates.fileName = payload.fileName;
updates.category = categoryForFileName(payload.fileName);
}
if (status !== 'downloading') {
updates.speed = '-';
updates.eta = '-';
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused') {
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
} else if (status === 'queued') {
if (!mainStore.pendingOrder.includes(payload.id)) {
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
}
if (status === 'completed' || status === 'failed' || status === 'paused') {
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
} else if (status === 'queued') {
if (!mainStore.pendingOrder.includes(payload.id)) {
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
}
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
if (status === 'completed' || status === 'failed' || status === 'paused') {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
if (status === 'completed' || status === 'failed' || status === 'paused') {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}),
listen('tray-action', (event) => {
+23
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
@@ -91,6 +92,7 @@ describe('useDownloadStore', () => {
pendingAddRequestContexts: {},
pendingAddRequestVersion: 0,
});
useDownloadProgressStore.setState({ progressMap: {} });
});
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
@@ -869,6 +871,27 @@ describe('useDownloadStore', () => {
.toEqual(['active']);
});
it('clears live progress when a download is removed', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'removed-progress', url: 'https://example.com/file', fileName: 'file', status: 'paused', category: 'Other', dateAdded: '' }
] as any[]
});
useDownloadProgressStore.getState().updateDownloadProgress('removed-progress', {
id: 'removed-progress',
fraction: 0.5,
speed: '1 MB/s',
eta: '10s',
size: '2 MB',
size_is_final: false
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().removeDownload('removed-progress');
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
});
it('asks the backend to preserve resumable assets during replacement removal', async () => {
useDownloadStore.setState({
downloads: [
+2
View File
@@ -7,6 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
resolveCategoryDestination
@@ -778,6 +779,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
)
}));
useDownloadProgressStore.getState().clearDownloadProgress(id);
info(`Download ${id} removed`);
syncSystemIntegrations();
},