mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-27 19:17:13 +00:00
fix(ui): harden main window interactions
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 640,
|
"minHeight": 640,
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
"decorations": true
|
"decorations": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
useDownloadStore,
|
useDownloadStore,
|
||||||
getSiteLogin,
|
getSiteLogin,
|
||||||
@@ -154,6 +154,15 @@ export const AddDownloadsModal = () => {
|
|||||||
return hasExtensionRequestContext ? '' : pendingAddFilename;
|
return hasExtensionRequestContext ? '' : pendingAddFilename;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeModalFromDismissAction = useCallback(() => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
const hasPendingInput = Boolean(
|
||||||
|
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
|
||||||
|
);
|
||||||
|
if (hasPendingInput && !window.confirm('Discard this download setup?')) return;
|
||||||
|
toggleAddModal(false);
|
||||||
|
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, toggleAddModal, urls]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAddModalOpen) {
|
if (!isAddModalOpen) {
|
||||||
modalSessionRef.current = false;
|
modalSessionRef.current = false;
|
||||||
@@ -233,18 +242,20 @@ export const AddDownloadsModal = () => {
|
|||||||
}, [isQueueMenuOpen]);
|
}, [isQueueMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQueueMenuOpen && !showingDuplicates) return;
|
if (!isAddModalOpen) return;
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape') return;
|
||||||
if (showingDuplicates) {
|
if (showingDuplicates) {
|
||||||
setShowingDuplicates(false);
|
setShowingDuplicates(false);
|
||||||
} else {
|
} else if (isQueueMenuOpen) {
|
||||||
setIsQueueMenuOpen(false);
|
setIsQueueMenuOpen(false);
|
||||||
|
} else {
|
||||||
|
closeModalFromDismissAction();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', closeOnEscape);
|
window.addEventListener('keydown', closeOnEscape);
|
||||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||||
}, [isQueueMenuOpen, showingDuplicates]);
|
}, [closeModalFromDismissAction, isAddModalOpen, isQueueMenuOpen, showingDuplicates]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestId = ++freeSpaceRequestRef.current;
|
const requestId = ++freeSpaceRequestRef.current;
|
||||||
@@ -859,7 +870,14 @@ export const AddDownloadsModal = () => {
|
|||||||
onCancel={() => setShowingDuplicates(false)}
|
onCancel={() => setShowingDuplicates(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
<div
|
||||||
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) closeModalFromDismissAction();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
|
<div className="app-modal add-download-modal flex flex-col overflow-hidden text-sm">
|
||||||
|
|
||||||
{/* Main Content Split */}
|
{/* Main Content Split */}
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [deleteModalState.isOpen]);
|
}, [deleteModalState.isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deleteModalState.isOpen || isRemoving) return;
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') closeDeleteModal();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [closeDeleteModal, deleteModalState.isOpen, isRemoving]);
|
||||||
|
|
||||||
if (!deleteModalState.isOpen) return null;
|
if (!deleteModalState.isOpen) return null;
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -53,7 +62,14 @@ export const DeleteConfirmationModal: React.FC = () => {
|
|||||||
const handleDeleteFile = () => removeMany(true);
|
const handleDeleteFile = () => removeMany(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div
|
||||||
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !isRemoving) handleCancel();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useDownloadStore } from '../store/useDownloadStore';
|
import { useDownloadStore } from '../store/useDownloadStore';
|
||||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
|
||||||
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
type DuplicateResolution = 'rename' | 'replace' | 'skip';
|
||||||
@@ -20,12 +20,27 @@ interface Props {
|
|||||||
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
||||||
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
|
<div
|
||||||
|
className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
|
||||||
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||||
@@ -11,6 +11,15 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
const [isGranting, setIsGranting] = useState(false);
|
const [isGranting, setIsGranting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showKeychainModal || isGranting) return;
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setShowKeychainModal(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isGranting, setShowKeychainModal, showKeychainModal]);
|
||||||
|
|
||||||
if (!showKeychainModal) {
|
if (!showKeychainModal) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -62,7 +71,14 @@ export const KeychainPermissionModal: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div
|
||||||
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !isGranting) handleLater();
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
className="window-safe-modal bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useSettingsStore } from '../store/useSettingsStore';
|
import { useSettingsStore } from '../store/useSettingsStore';
|
||||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||||
@@ -114,6 +114,15 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedPropertiesDownloadId) return;
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setSelectedPropertiesDownloadId(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||||
|
|
||||||
if (!selectedPropertiesDownloadId || !item) return null;
|
if (!selectedPropertiesDownloadId || !item) return null;
|
||||||
|
|
||||||
const handleBrowse = async () => {
|
const handleBrowse = async () => {
|
||||||
@@ -186,7 +195,14 @@ export const PropertiesModal = () => {
|
|||||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
|
<div
|
||||||
|
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) setSelectedPropertiesDownloadId(null);
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
|
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
|
||||||
|
|
||||||
{/* Header Summary */}
|
{/* Header Summary */}
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
const queueId = selectedFilter.replace('queue:', '');
|
const queueId = selectedFilter.replace('queue:', '');
|
||||||
const q = queues.find(q => q.id === queueId);
|
const q = queues.find(q => q.id === queueId);
|
||||||
if (q && !q.isMain) {
|
if (q && !q.isMain) {
|
||||||
|
if (!window.confirm(`Delete queue "${q.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void removeQueue(queueId).catch(error => {
|
void removeQueue(queueId).catch(error => {
|
||||||
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
|
addToast({ message: `Could not delete queue: ${String(error)}`, variant: 'error', isActionable: true });
|
||||||
});
|
});
|
||||||
@@ -92,7 +95,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [selectedFilter, activeView, queues]);
|
}, [addToast, activeView, queues, removeQueue, selectedFilter]);
|
||||||
|
|
||||||
const getCount = (filter: SidebarFilter) => {
|
const getCount = (filter: SidebarFilter) => {
|
||||||
if (filter.startsWith('queue:')) {
|
if (filter.startsWith('queue:')) {
|
||||||
@@ -383,6 +386,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
|||||||
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
|
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const queueId = contextMenu.id;
|
const queueId = contextMenu.id;
|
||||||
|
const queue = queues.find(q => q.id === queueId);
|
||||||
|
if (!queue || queue.isMain) {
|
||||||
|
setContextMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`Delete queue "${queue.name}"? Its unfinished downloads will move to Main Queue.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void removeQueue(queueId).catch(error => {
|
void removeQueue(queueId).catch(error => {
|
||||||
addToast({
|
addToast({
|
||||||
|
|||||||
+6
-1
@@ -1017,6 +1017,7 @@ html[data-list-density="relaxed"] {
|
|||||||
z-index: 60;
|
z-index: 60;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-resize-handle::after {
|
.sidebar-resize-handle::after {
|
||||||
@@ -1254,6 +1255,7 @@ html[data-list-density="relaxed"] {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-secondary));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle-button:hover {
|
.sidebar-toggle-button:hover {
|
||||||
@@ -1653,7 +1655,7 @@ html[data-list-density="relaxed"] {
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-control-group {
|
.main-control-group {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1662,6 +1664,7 @@ html[data-list-density="relaxed"] {
|
|||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
border: 1px solid hsl(var(--border-modal));
|
border: 1px solid hsl(var(--border-modal));
|
||||||
background: hsl(var(--bg-input));
|
background: hsl(var(--bg-input));
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-control-button {
|
.main-control-button {
|
||||||
@@ -1672,6 +1675,7 @@ html[data-list-density="relaxed"] {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: hsl(var(--text-secondary));
|
color: hsl(var(--text-secondary));
|
||||||
border-right: 1px solid hsl(var(--border-color));
|
border-right: 1px solid hsl(var(--border-color));
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-control-button svg {
|
.main-control-button svg {
|
||||||
@@ -1842,6 +1846,7 @@ html[data-list-density="relaxed"] {
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
.column-resize-handle::after {
|
.column-resize-handle::after {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -83,4 +83,34 @@ describe('useDownloadProgressStore', () => {
|
|||||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||||
release();
|
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
@@ -1,36 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
|
||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
import { listenEvent as listen } from '../ipc';
|
import { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
import { categoryForFileName } from '../utils/downloads';
|
import { categoryForFileName } from '../utils/downloads';
|
||||||
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
interface DownloadProgressState {
|
|
||||||
progressMap: Record<string, DownloadProgressEvent>;
|
|
||||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
|
||||||
clearDownloadProgress: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
import { useDownloadStore } from './useDownloadStore';
|
import { useDownloadStore } from './useDownloadStore';
|
||||||
|
|
||||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
export { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
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 };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
let unlistenProgress: UnlistenFn | null = null;
|
let unlistenProgress: UnlistenFn | null = null;
|
||||||
let unlistenState: UnlistenFn | null = null;
|
let unlistenState: UnlistenFn | null = null;
|
||||||
@@ -54,6 +31,12 @@ const startDownloadListeners = async () => {
|
|||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
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,
|
// A sidecar can flush one last progress chunk after a pause, failure,
|
||||||
// or completion event. Do not let that stale chunk repopulate the live
|
// or completion event. Do not let that stale chunk repopulate the live
|
||||||
// progress map or overwrite a later lifecycle's first frame.
|
// progress map or overwrite a later lifecycle's first frame.
|
||||||
@@ -61,73 +44,73 @@ const startDownloadListeners = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||||
if (current) {
|
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
const updates: Partial<DownloadItem> = {};
|
||||||
const updates: Partial<DownloadItem> = {};
|
if (current.status === 'downloading' || current.status === 'processing') {
|
||||||
if (current.status === 'downloading' || current.status === 'processing') {
|
updates.fraction = payload.fraction;
|
||||||
updates.fraction = payload.fraction;
|
updates.speed = payload.speed;
|
||||||
updates.speed = payload.speed;
|
updates.eta = payload.eta;
|
||||||
updates.eta = payload.eta;
|
}
|
||||||
}
|
if (shouldUpdateSize && current.size !== payload.size) {
|
||||||
if (shouldUpdateSize && current.size !== payload.size) {
|
updates.size = payload.size!;
|
||||||
updates.size = payload.size!;
|
}
|
||||||
}
|
if (Object.keys(updates).length > 0) {
|
||||||
if (Object.keys(updates).length > 0) {
|
mainStore.updateDownload(payload.id, updates);
|
||||||
mainStore.updateDownload(payload.id, updates);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
listen('download-state', (event) => {
|
listen('download-state', (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||||
if (current) {
|
if (!current) {
|
||||||
const status = payload.status as DownloadStatus;
|
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = payload.status as DownloadStatus;
|
||||||
|
|
||||||
// Prevent race condition: don't transition backwards from terminal state
|
// Prevent race condition: don't transition backwards from terminal state
|
||||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||||
status !== current.status) {
|
status !== current.status) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||||
const updates: Partial<DownloadItem> = {
|
const updates: Partial<DownloadItem> = {
|
||||||
status,
|
status,
|
||||||
...(progress ? { fraction: progress.fraction } : {}),
|
...(progress ? { fraction: progress.fraction } : {}),
|
||||||
...(payload.error ? { lastError: payload.error } : {}),
|
...(payload.error ? { lastError: payload.error } : {}),
|
||||||
...((status === 'downloading' || status === 'retrying')
|
...((status === 'downloading' || status === 'retrying')
|
||||||
? { lastTry: new Date().toISOString() }
|
? { lastTry: new Date().toISOString() }
|
||||||
: {})
|
: {})
|
||||||
};
|
};
|
||||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||||
updates.lastError = undefined;
|
updates.lastError = undefined;
|
||||||
}
|
}
|
||||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||||
updates.fileName = payload.fileName;
|
updates.fileName = payload.fileName;
|
||||||
updates.category = categoryForFileName(payload.fileName);
|
updates.category = categoryForFileName(payload.fileName);
|
||||||
}
|
}
|
||||||
if (status !== 'downloading') {
|
if (status !== 'downloading') {
|
||||||
updates.speed = '-';
|
updates.speed = '-';
|
||||||
updates.eta = '-';
|
updates.eta = '-';
|
||||||
}
|
}
|
||||||
mainStore.updateDownload(payload.id, updates);
|
mainStore.updateDownload(payload.id, updates);
|
||||||
|
|
||||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||||
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
|
mainStore.setPendingOrder(mainStore.pendingOrder.filter(id => id !== payload.id));
|
||||||
} else if (status === 'queued') {
|
} else if (status === 'queued') {
|
||||||
if (!mainStore.pendingOrder.includes(payload.id)) {
|
if (!mainStore.pendingOrder.includes(payload.id)) {
|
||||||
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
|
||||||
mainStore.registerBackendIds([payload.id]);
|
mainStore.registerBackendIds([payload.id]);
|
||||||
} else if (status === 'completed' || status === 'failed') {
|
} else if (status === 'completed' || status === 'failed') {
|
||||||
mainStore.unregisterBackendIds([payload.id]);
|
mainStore.unregisterBackendIds([payload.id]);
|
||||||
}
|
}
|
||||||
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
if (status === 'completed' || status === 'failed' || status === 'paused') {
|
||||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
listen('tray-action', (event) => {
|
listen('tray-action', (event) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||||
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
import * as ipc from '../ipc';
|
import * as ipc from '../ipc';
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ describe('useDownloadStore', () => {
|
|||||||
pendingAddRequestContexts: {},
|
pendingAddRequestContexts: {},
|
||||||
pendingAddRequestVersion: 0,
|
pendingAddRequestVersion: 0,
|
||||||
});
|
});
|
||||||
|
useDownloadProgressStore.setState({ progressMap: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
it('invalidates in-flight Add-modal handoffs when the modal is toggled', () => {
|
||||||
@@ -869,6 +871,27 @@ describe('useDownloadStore', () => {
|
|||||||
.toEqual(['active']);
|
.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 () => {
|
it('asks the backend to preserve resumable assets during replacement removal', async () => {
|
||||||
useDownloadStore.setState({
|
useDownloadStore.setState({
|
||||||
downloads: [
|
downloads: [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
|
|||||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||||
import type { Queue } from '../bindings/Queue';
|
import type { Queue } from '../bindings/Queue';
|
||||||
import { useSettingsStore } from './useSettingsStore';
|
import { useSettingsStore } from './useSettingsStore';
|
||||||
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||||
import {
|
import {
|
||||||
resolveCategoryDestination
|
resolveCategoryDestination
|
||||||
@@ -778,6 +779,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
|
Array.from(state.backendRegisteredIds).filter(registeredId => registeredId !== id)
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
useDownloadProgressStore.getState().clearDownloadProgress(id);
|
||||||
info(`Download ${id} removed`);
|
info(`Download ${id} removed`);
|
||||||
syncSystemIntegrations();
|
syncSystemIntegrations();
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user