From 5f93323760fb46f06d391996f17e87b7405769d4 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sat, 21 Mar 2026 00:09:52 +0800 Subject: 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) --- packages/ui/src/components/download-monitor.tsx | 186 ++++++++++++++----- .../ui/src/components/instance-creation-modal.tsx | 200 ++++++++++++++++----- packages/ui/src/stores/download-store.ts | 194 ++++++++++++++++++++ 3 files changed, 489 insertions(+), 91 deletions(-) create mode 100644 packages/ui/src/stores/download-store.ts diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx index f3902d9..6916ca5 100644 --- a/packages/ui/src/components/download-monitor.tsx +++ b/packages/ui/src/components/download-monitor.tsx @@ -1,62 +1,160 @@ -import { X } from "lucide-react"; -import { useState } from "react"; +import { CheckCircle, Download, Loader2, Package, XCircle } from "lucide-react"; +import { useDownloadStore } from "@/stores/download-store"; -export function DownloadMonitor() { - const [isVisible, setIsVisible] = useState(true); +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / 1024 ** i; + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; +} + +function shortenFileName(path: string): string { + const parts = path.replace(/\\/g, "/").split("/"); + return parts[parts.length - 1] || path; +} + +/** + * Inline progress display for use inside dialogs or pages. + * Reads from the global download store. + */ +export function DownloadProgress() { + const { + phase, + totalFiles, + completedFiles, + currentFile, + currentFileStatus, + currentFileDownloaded, + currentFileTotal, + totalDownloadedBytes, + errorMessage, + phaseLabel, + } = useDownloadStore(); + + if (phase === "idle") return null; - if (!isVisible) return null; + const overallPercent = + totalFiles > 0 ? Math.round((completedFiles / totalFiles) * 100) : 0; + + const filePercent = + currentFileTotal > 0 + ? Math.round((currentFileDownloaded / currentFileTotal) * 100) + : 0; return ( -
- {/* Header */} -
-
-
- Downloads -
- +
+ {/* Phase header */} +
+ {phase === "preparing" && ( + + )} + {phase === "downloading" && ( + + )} + {phase === "finalizing" && ( + + )} + {phase === "installing-mod-loader" && ( + + )} + {phase === "completed" && ( + + )} + {phase === "error" && ( + + )} + {phaseLabel}
- {/* Content */} -
-
- {/* Download Item */} + {/* Preparing phase — no file counts yet */} + {phase === "preparing" && ( +
+ + Resolving version and assets... +
+ )} + + {/* Overall progress */} + {phase === "downloading" && totalFiles > 0 && ( +
+ {/* Overall bar */}
-
- Minecraft 1.20.4 - 65% +
+ + Overall: {completedFiles} / {totalFiles} files + + + {overallPercent}% +
-
+
+ className="h-full bg-indigo-500 rounded-full transition-all duration-300" + style={{ width: `${overallPercent}%` }} + />
-
- 142 MB / 218 MB - 2.1 MB/s • 36s remaining +
+ {formatBytes(totalDownloadedBytes)} downloaded
- {/* Download Item */} -
-
- Java 17 - 100% -
-
-
-
-
Completed
+ {/* Current file — always reserve space to avoid layout shifts */} +
+ {currentFile && currentFileStatus !== "Finished" && ( +
+
+ + {shortenFileName(currentFile)} + + + {currentFileStatus === "Downloading" + ? `${filePercent}%` + : currentFileStatus} + +
+ {currentFileStatus === "Downloading" && + currentFileTotal > 0 && ( +
+
+
+ )} +
+ )}
-
+ )} + + {/* Finalizing phase — downloads done, waiting for install to finish */} + {phase === "finalizing" && ( +
+ + Verifying installation... +
+ )} + + {/* Mod loader install phase */} + {phase === "installing-mod-loader" && ( +
+ + {phaseLabel} +
+ )} + + {/* Error */} + {phase === "error" && errorMessage && ( +
{errorMessage}
+ )} + + {/* Completed */} + {phase === "completed" && ( +
+ Successfully installed {totalFiles > 0 ? `${totalFiles} files` : ""} +
+ )}
); } diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx index 7c46d0f..5b50513 100644 --- a/packages/ui/src/components/instance-creation-modal.tsx +++ b/packages/ui/src/components/instance-creation-modal.tsx @@ -8,6 +8,7 @@ import { installForge, installVersion, } from "@/client"; +import { DownloadProgress } from "@/components/download-monitor"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -20,6 +21,7 @@ import { import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useInstanceStore } from "@/models/instance"; +import { useDownloadStore } from "@/stores/download-store"; import { useGameStore } from "@/stores/game-store"; import type { FabricLoaderEntry, @@ -35,8 +37,9 @@ interface Props { export function InstanceCreationModal({ open, onOpenChange }: Props) { const gameStore = useGameStore(); const instancesStore = useInstanceStore(); + const downloadStore = useDownloadStore(); - // Steps: 1 = name, 2 = version, 3 = mod loader + // Steps: 1 = name, 2 = version, 3 = mod loader, 4 = installing const [step, setStep] = useState(1); // Step 1 @@ -61,6 +64,9 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { const [selectedForgeLoader, setSelectedForgeLoader] = useState(""); const [loadingLoaders, setLoadingLoaders] = useState(false); + // Step 4 - installing + const [installFinished, setInstallFinished] = useState(false); + const loadModLoaders = useCallback(async () => { if (!selectedVersionUI) return; setLoadingLoaders(true); @@ -79,7 +85,6 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { const versions = await getForgeVersionsForGame(selectedVersionUI.id); setForgeVersions(versions || []); if (versions && versions.length > 0) { - // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here. setSelectedForgeLoader(versions[0].version); } else { setSelectedForgeLoader(""); @@ -118,6 +123,18 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { return list; }, [gameStore.versions, versionFilter, versionSearch]); + // Initialize download store event listeners when modal opens + useEffect(() => { + if (open) { + downloadStore.init(); + } + return () => { + if (!open) { + downloadStore.cleanup(); + } + }; + }, [open, downloadStore.init, downloadStore.cleanup]); + // Reset when opened/closed useEffect(() => { if (open) { @@ -135,8 +152,10 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { setSelectedForgeLoader(""); setErrorMessage(""); setCreating(false); + setInstallFinished(false); + downloadStore.reset(); } - }, [open, gameStore.loadVersions]); + }, [open, gameStore.loadVersions, downloadStore.reset]); function validateStep1(): boolean { if (!instanceName.trim()) { @@ -176,6 +195,11 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { if (!validateStep1() || !validateStep2()) return; setCreating(true); setErrorMessage(""); + setInstallFinished(false); + + // Move to step 4 (installing) + setStep(4); + downloadStore.reset(); try { // Step 1: create instance @@ -183,83 +207,127 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { // If selectedVersion provided, install it if (selectedVersionUI && instance) { + // Show preparing phase while Rust resolves version JSON, fetches + // asset index, and builds the download task list. + downloadStore.setPhase( + "preparing", + `Preparing Minecraft ${selectedVersionUI.id}...`, + ); try { - await installVersion(instance?.id, selectedVersionUI.id); + await installVersion(instance.id, selectedVersionUI.id); } catch (err) { console.error("Failed to install base version:", err); - // continue - instance created but version install failed toast.error( `Failed to install version ${selectedVersionUI.id}: ${String(err)}`, ); + downloadStore.setError(`Failed to install version: ${String(err)}`); + setInstallFinished(true); + setCreating(false); + return; } } // If mod loader selected, install it if (modLoaderType === "fabric" && selectedFabricLoader && instance) { + downloadStore.setPhase( + "installing-mod-loader", + `Installing Fabric ${selectedFabricLoader}...`, + ); try { await installFabric( - instance?.id, + instance.id, selectedVersionUI?.id ?? "", selectedFabricLoader, ); } catch (err) { console.error("Failed to install Fabric:", err); toast.error(`Failed to install Fabric: ${String(err)}`); + downloadStore.setError(`Failed to install Fabric: ${String(err)}`); + setInstallFinished(true); + setCreating(false); + return; } } else if (modLoaderType === "forge" && selectedForgeLoader && instance) { + downloadStore.setPhase( + "installing-mod-loader", + `Installing Forge ${selectedForgeLoader}...`, + ); try { await installForge( - instance?.id, + instance.id, selectedVersionUI?.id ?? "", selectedForgeLoader, ); } catch (err) { console.error("Failed to install Forge:", err); toast.error(`Failed to install Forge: ${String(err)}`); + downloadStore.setError(`Failed to install Forge: ${String(err)}`); + setInstallFinished(true); + setCreating(false); + return; } } // Refresh instances list await instancesStore.refresh(); + downloadStore.setPhase("completed", "Installation complete"); + setInstallFinished(true); toast.success("Instance created successfully"); - onOpenChange(false); } catch (e) { console.error("Failed to create instance:", e); setErrorMessage(String(e)); + downloadStore.setError(String(e)); toast.error(`Failed to create instance: ${e}`); + setInstallFinished(true); } finally { setCreating(false); } } // UI pieces + const stepKeys = ["name", "version", "loader", "install"] as const; const StepIndicator = () => (
-
= 1 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> -
= 2 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> -
= 3 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> + {stepKeys.map((key, i) => ( +
= i + 1 ? "bg-indigo-500" : "bg-zinc-700"}`} + /> + ))}
); + const stepTitles: Record = { + 1: "Create New Instance", + 2: "Select Version", + 3: "Mod Loader", + 4: "Installing", + }; + + const stepDescriptions: Record = { + 1: "Give your instance a name to get started.", + 2: "Choose a Minecraft version to install.", + 3: "Optionally select a mod loader.", + 4: "Downloading and installing game files...", + }; + return ( - + { + // Prevent closing during active installation + if (step === 4 && !installFinished) return; + onOpenChange(newOpen); + }} + > - Create New Instance - - Multi-step wizard — create an instance and optionally install a - version or mod loader. - + {stepTitles[step]} + {stepDescriptions[step]} -
+
@@ -463,7 +531,6 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { className="w-full px-3 py-2 rounded border bg-transparent" > {forgeVersions.map((f) => ( - // binding ForgeVersion uses `version` as the identifier @@ -480,7 +547,39 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
)} - {errorMessage && ( + {/* Step 4 - Installing with progress */} + {step === 4 && ( +
+ {/* Summary of what's being installed */} +
+
Instance
+
{instanceName}
+
+ Version +
+
{selectedVersionUI?.id}
+ {modLoaderType !== "vanilla" && ( + <> +
+ Mod Loader +
+
+ {modLoaderType === "fabric" + ? `Fabric ${selectedFabricLoader}` + : `Forge ${selectedForgeLoader}`} +
+ + )} +
+ + {/* Download progress */} +
+ +
+
+ )} + + {errorMessage && step !== 4 && (
{errorMessage}
)}
@@ -488,21 +587,29 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) {
- + {step === 4 ? ( + + ) : ( + + )}
- {step > 1 && ( + {step > 1 && step < 4 && ( - ) : ( + ) : step === 3 ? ( + ) : ( + installFinished && ( + + ) )}
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; + 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 | null = null; +let pendingProgress: ProgressEvent | null = null; +const PROGRESS_INTERVAL_MS = 50; // ~20 fps + +export const useDownloadStore = create((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("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( + "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", + }); + }, +})); -- cgit v1.2.3-70-g09d2