diff options
| author | 2026-03-21 00:09:52 +0800 | |
|---|---|---|
| committer | 2026-03-21 00:34:09 +0800 | |
| commit | 5f93323760fb46f06d391996f17e87b7405769d4 (patch) | |
| tree | 09f41d648c9baa31390cd41115cb2810fd04a139 /packages/ui/src/stores | |
| parent | e356ab996ad3ef4ad5fb9c90d4a4b4e61ff3342d (diff) | |
| download | DropOut-5f93323760fb46f06d391996f17e87b7405769d4.tar.gz DropOut-5f93323760fb46f06d391996f17e87b7405769d4.zip | |
feat(ui): add real-time download progress to instance creation flow
- Add download-store with throttled event handling for download-start/progress/complete
- Rewrite download-monitor with phase-aware progress display (preparing/downloading/finalizing/installing-mod-loader/completed/error)
- Add Step 4 (Installing) to instance creation modal with live progress bar
- Fix dialog width jittering caused by long filenames (overflow-hidden + w-0 grow)
Diffstat (limited to 'packages/ui/src/stores')
| -rw-r--r-- | packages/ui/src/stores/download-store.ts | 194 |
1 files changed, 194 insertions, 0 deletions
diff --git a/packages/ui/src/stores/download-store.ts b/packages/ui/src/stores/download-store.ts new file mode 100644 index 0000000..a33d79d --- /dev/null +++ b/packages/ui/src/stores/download-store.ts @@ -0,0 +1,194 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { create } from "zustand"; +import type { ProgressEvent } from "@/types"; + +export type DownloadPhase = + | "idle" + | "preparing" + | "downloading" + | "finalizing" + | "installing-mod-loader" + | "completed" + | "error"; + +export interface DownloadState { + /** Whether a download session is active */ + phase: DownloadPhase; + + /** Total number of files to download */ + totalFiles: number; + /** Number of files completed */ + completedFiles: number; + + /** Current file being downloaded */ + currentFile: string; + /** Current file status */ + currentFileStatus: string; + + /** Bytes downloaded for current file */ + currentFileDownloaded: number; + /** Total bytes for current file */ + currentFileTotal: number; + + /** Total bytes downloaded across all files */ + totalDownloadedBytes: number; + + /** Error message if any */ + errorMessage: string | null; + + /** Phase label for display (e.g. "Installing Fabric...") */ + phaseLabel: string; + + // Actions + init: () => Promise<void>; + cleanup: () => void; + reset: () => void; + setPhase: (phase: DownloadPhase, label?: string) => void; + setError: (message: string) => void; +} + +let unlisteners: UnlistenFn[] = []; +let initialized = false; + +// Throttle progress updates to avoid excessive re-renders. +// We buffer the latest event and flush on a timer. +let progressTimer: ReturnType<typeof setTimeout> | null = null; +let pendingProgress: ProgressEvent | null = null; +const PROGRESS_INTERVAL_MS = 50; // ~20 fps + +export const useDownloadStore = create<DownloadState>((set, get) => ({ + phase: "idle", + totalFiles: 0, + completedFiles: 0, + currentFile: "", + currentFileStatus: "", + currentFileDownloaded: 0, + currentFileTotal: 0, + totalDownloadedBytes: 0, + errorMessage: null, + phaseLabel: "", + + init: async () => { + if (initialized) return; + initialized = true; + + const flushProgress = () => { + const p = pendingProgress; + if (!p) return; + pendingProgress = null; + set({ + currentFile: p.file, + currentFileStatus: p.status, + currentFileDownloaded: Number(p.downloaded), + currentFileTotal: Number(p.total), + completedFiles: p.completedFiles, + totalFiles: p.totalFiles, + totalDownloadedBytes: Number(p.totalDownloadedBytes), + }); + }; + + const unlistenStart = await listen<number>("download-start", (e) => { + set({ + phase: "downloading", + totalFiles: e.payload, + completedFiles: 0, + currentFile: "", + currentFileStatus: "", + currentFileDownloaded: 0, + currentFileTotal: 0, + totalDownloadedBytes: 0, + errorMessage: null, + phaseLabel: "Downloading files...", + }); + }); + + const unlistenProgress = await listen<ProgressEvent>( + "download-progress", + (e) => { + pendingProgress = e.payload; + if (!progressTimer) { + progressTimer = setTimeout(() => { + progressTimer = null; + flushProgress(); + }, PROGRESS_INTERVAL_MS); + } + }, + ); + + const unlistenComplete = await listen("download-complete", () => { + // Flush any pending progress before transitioning + if (progressTimer) { + clearTimeout(progressTimer); + progressTimer = null; + } + if (pendingProgress) { + const p = pendingProgress; + pendingProgress = null; + set({ + currentFile: p.file, + currentFileStatus: p.status, + currentFileDownloaded: Number(p.downloaded), + currentFileTotal: Number(p.total), + completedFiles: p.completedFiles, + totalFiles: p.totalFiles, + totalDownloadedBytes: Number(p.totalDownloadedBytes), + }); + } + + const { phase } = get(); + // Downloads finished; move to finalizing while we wait for the + // install command to return and the caller to set the next phase. + if (phase === "downloading") { + set({ + phase: "finalizing", + phaseLabel: "Finalizing installation...", + }); + } + }); + + unlisteners = [unlistenStart, unlistenProgress, unlistenComplete]; + }, + + cleanup: () => { + if (progressTimer) { + clearTimeout(progressTimer); + progressTimer = null; + } + pendingProgress = null; + for (const unlisten of unlisteners) { + unlisten(); + } + unlisteners = []; + initialized = false; + }, + + reset: () => { + set({ + phase: "idle", + totalFiles: 0, + completedFiles: 0, + currentFile: "", + currentFileStatus: "", + currentFileDownloaded: 0, + currentFileTotal: 0, + totalDownloadedBytes: 0, + errorMessage: null, + phaseLabel: "", + }); + }, + + setPhase: (phase, label) => { + set({ + phase, + phaseLabel: label ?? "", + }); + }, + + setError: (message) => { + set({ + phase: "error", + errorMessage: message, + phaseLabel: "Error", + }); + }, +})); |