mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 08:59:05 +00:00
perf(frontend): architect transient react progress state with zustand
This commit is contained in:
+2
-15
@@ -8,6 +8,7 @@ import { PropertiesModal } from "./components/PropertiesModal";
|
||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||
import { initDownloadListener } from './store/downloadStore';
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
import SchedulerView from "./components/SchedulerView";
|
||||
@@ -204,20 +205,7 @@ function App() {
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenProgress = listen('download-progress', (event: any) => {
|
||||
const { id, fraction, speed, eta, size } = event.payload;
|
||||
const state = useDownloadStore.getState();
|
||||
const current = state.downloads.find(d => d.id === id);
|
||||
|
||||
const updates: any = { fraction, speed, eta };
|
||||
if (size) updates.size = size;
|
||||
|
||||
if (current && current.status === 'queued') {
|
||||
updates.status = 'downloading';
|
||||
}
|
||||
|
||||
updateDownload(id, updates);
|
||||
});
|
||||
initDownloadListener();
|
||||
|
||||
const unlistenComplete = listen('download-complete', (event) => {
|
||||
updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' });
|
||||
@@ -255,7 +243,6 @@ function App() {
|
||||
|
||||
return () => {
|
||||
invoke('set_extension_frontend_ready', { ready: false }).catch(() => {});
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
unlistenFailed.then(f => f());
|
||||
unlistenExtension.then(f => f());
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { Play, Pause, MoreVertical } from 'lucide-react';
|
||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||
|
||||
interface DownloadItemProps {
|
||||
downloadId: string;
|
||||
index: number;
|
||||
tableGridTemplate: string;
|
||||
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
|
||||
handlePause: (id: string) => void;
|
||||
handleResume: (item: DownloadItemType) => void;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
downloadId,
|
||||
index,
|
||||
tableGridTemplate,
|
||||
setContextMenu,
|
||||
handlePause,
|
||||
handleResume,
|
||||
getCategoryIcon,
|
||||
}) => {
|
||||
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const statusTextRef = useRef<HTMLSpanElement>(null);
|
||||
const speedTextRef = useRef<HTMLSpanElement>(null);
|
||||
const etaTextRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// We only need transient updates while it's actively downloading
|
||||
if (!download || download.status !== 'downloading') return;
|
||||
|
||||
const unsubscribe = useDownloadProgressStore.subscribe((state) => {
|
||||
const progress = state.progressMap[downloadId];
|
||||
if (!progress) return;
|
||||
|
||||
if (progressBarRef.current) {
|
||||
progressBarRef.current.style.width = `${progress.fraction * 100}%`;
|
||||
}
|
||||
if (statusTextRef.current) {
|
||||
statusTextRef.current.innerText = `${(progress.fraction * 100).toFixed(0)}%`;
|
||||
}
|
||||
if (speedTextRef.current) {
|
||||
speedTextRef.current.innerText = progress.speed;
|
||||
}
|
||||
if (etaTextRef.current) {
|
||||
etaTextRef.current.innerText = progress.eta;
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [downloadId, download?.status]);
|
||||
|
||||
if (!download) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||
}}
|
||||
>
|
||||
<div className="download-file-cell">
|
||||
<span className="shrink-0 text-text-muted">
|
||||
{getCategoryIcon(download.category)}
|
||||
</span>
|
||||
<span className="download-file-name">
|
||||
{download.fileName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">
|
||||
{download.size && download.size !== '-' ? download.size : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-status-cell">
|
||||
{download.status === 'completed' ? (
|
||||
<span className="download-status download-status-completed">Completed</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className={`download-progress-fill ${download.status === 'paused' ? 'paused' : ''}`}
|
||||
style={{ width: `${(download.fraction || 0) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
ref={statusTextRef}
|
||||
className={`download-status ${download.status === 'paused' ? 'download-status-paused' : download.status === 'failed' ? 'download-status-failed' : download.status === 'downloading' ? 'download-status-downloading' : ''}`}
|
||||
>
|
||||
{download.status === 'downloading'
|
||||
? `${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
: download.status.charAt(0).toUpperCase() + download.status.slice(1)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span ref={speedTextRef} className="tabular-nums">{download.status === 'downloading' ? download.speed : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span ref={etaTextRef} className="tabular-nums">{download.status === 'downloading' ? download.eta : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-right">
|
||||
<span className="truncate group-hover:hidden tabular-nums ml-auto">
|
||||
{download.dateAdded ? new Date(download.dateAdded).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
|
||||
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
|
||||
{download.status === 'downloading' && (
|
||||
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title="Pause">
|
||||
<Pause size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{download.status === 'paused' && (
|
||||
<button onClick={() => handleResume(download)} className="app-icon-button h-7 w-7" title="Resume">
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
|
||||
}}
|
||||
className="app-icon-button h-7 w-7"
|
||||
title="Options"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { SidebarFilter } from './Sidebar';
|
||||
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, MoreVertical, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
|
||||
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
|
||||
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
|
||||
@@ -210,87 +211,16 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
|
||||
<div className="download-table-body">
|
||||
<div className="h-full overflow-auto flex flex-col">
|
||||
{filteredDownloads.map((d, index) => (
|
||||
<div
|
||||
<DownloadItemComponent
|
||||
key={d.id}
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
||||
}}
|
||||
>
|
||||
<div className="download-file-cell">
|
||||
<span className="shrink-0 text-text-muted">
|
||||
{getCategoryIcon(d.category)}
|
||||
</span>
|
||||
<span className="download-file-name">
|
||||
{d.fileName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">
|
||||
{d.size && d.size !== '-' ? d.size : 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-status-cell">
|
||||
{d.status === 'completed' ? (
|
||||
<span className="download-status download-status-completed">Completed</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
className={`download-progress-fill ${d.status === 'paused' ? 'paused' : ''}`}
|
||||
style={{ width: `${(d.fraction || 0) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`download-status ${d.status === 'paused' ? 'download-status-paused' : d.status === 'failed' ? 'download-status-failed' : d.status === 'downloading' ? 'download-status-downloading' : ''}`}>
|
||||
{d.status === 'downloading'
|
||||
? `${((d.fraction || 0) * 100).toFixed(0)}%`
|
||||
: d.status.charAt(0).toUpperCase() + d.status.slice(1)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">{d.status === 'downloading' ? d.speed : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="tabular-nums">{d.status === 'downloading' ? d.eta : '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-right">
|
||||
<span className="truncate group-hover:hidden tabular-nums ml-auto">
|
||||
{d.dateAdded ? new Date(d.dateAdded).toLocaleDateString() : '-'}
|
||||
</span>
|
||||
|
||||
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
|
||||
{d.status === 'downloading' && (
|
||||
<button onClick={() => handlePause(d.id)} className="app-icon-button h-7 w-7" title="Pause">
|
||||
<Pause size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
{d.status === 'paused' && (
|
||||
<button onClick={() => handleResume(d)} className="app-icon-button h-7 w-7" title="Resume">
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
|
||||
}}
|
||||
className="app-icon-button h-7 w-7"
|
||||
title="Options"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
downloadId={d.id}
|
||||
index={index}
|
||||
tableGridTemplate={tableGridTemplate}
|
||||
setContextMenu={setContextMenu}
|
||||
handlePause={handlePause}
|
||||
handleResume={handleResume}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => {
|
||||
const globalIndex = filteredDownloads.length + i;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
|
||||
interface DownloadProgressState {
|
||||
progressMap: Record<string, DownloadProgressEvent>;
|
||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||
}
|
||||
|
||||
import { useDownloadStore } from './useDownloadStore';
|
||||
|
||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||
progressMap: {},
|
||||
updateDownloadProgress: (id, payload) =>
|
||||
set((state) => ({
|
||||
progressMap: {
|
||||
...state.progressMap,
|
||||
[id]: payload,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
let unlistenProgress: UnlistenFn | null = null;
|
||||
|
||||
export async function initDownloadListener() {
|
||||
if (unlistenProgress) return;
|
||||
unlistenProgress = await listen<DownloadProgressEvent>('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
||||
|
||||
const mainStore = useDownloadStore.getState();
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current && current.status === 'queued') {
|
||||
const updates: any = { status: 'downloading' };
|
||||
if (payload.size && current.size !== payload.size) updates.size = payload.size;
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
} else if (current && payload.size && current.size !== payload.size) {
|
||||
mainStore.updateDownload(payload.id, { size: payload.size });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user