diff options
| -rw-r--r-- | packages/ui/src/components/download-monitor.tsx | 186 | ||||
| -rw-r--r-- | packages/ui/src/components/instance-creation-modal.tsx | 200 | ||||
| -rw-r--r-- | packages/ui/src/stores/download-store.ts | 194 |
3 files changed, 489 insertions, 91 deletions
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 ( - <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden"> - {/* Header */} - <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div> - <span className="text-sm font-medium text-white">Downloads</span> - </div> - <button - type="button" - onClick={() => setIsVisible(false)} - className="text-zinc-400 hover:text-white transition-colors p-1" - > - <X size={16} /> - </button> + <div className="space-y-4 min-w-0 overflow-hidden tabular-nums"> + {/* Phase header */} + <div className="flex items-center gap-2 min-w-0"> + {phase === "preparing" && ( + <Loader2 className="h-4 w-4 shrink-0 text-indigo-400 animate-spin" /> + )} + {phase === "downloading" && ( + <Download className="h-4 w-4 shrink-0 text-indigo-400 animate-pulse" /> + )} + {phase === "finalizing" && ( + <Loader2 className="h-4 w-4 shrink-0 text-indigo-400 animate-spin" /> + )} + {phase === "installing-mod-loader" && ( + <Package className="h-4 w-4 shrink-0 text-indigo-400 animate-pulse" /> + )} + {phase === "completed" && ( + <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" /> + )} + {phase === "error" && ( + <XCircle className="h-4 w-4 shrink-0 text-red-400" /> + )} + <span className="text-sm font-medium truncate">{phaseLabel}</span> </div> - {/* Content */} - <div className="p-4"> - <div className="space-y-3"> - {/* Download Item */} + {/* Preparing phase — no file counts yet */} + {phase === "preparing" && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> + <span className="truncate">Resolving version and assets...</span> + </div> + )} + + {/* Overall progress */} + {phase === "downloading" && totalFiles > 0 && ( + <div className="space-y-2 min-w-0"> + {/* Overall bar */} <div className="space-y-1"> - <div className="flex justify-between text-xs"> - <span className="text-zinc-300">Minecraft 1.20.4</span> - <span className="text-zinc-400">65%</span> + <div className="flex justify-between text-xs text-muted-foreground"> + <span> + Overall: {completedFiles} / {totalFiles} files + </span> + <span className="shrink-0 ml-2 w-10 text-right"> + {overallPercent}% + </span> </div> - <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden"> + <div className="h-2 bg-secondary rounded-full overflow-hidden"> <div - className="h-full bg-emerald-500 rounded-full transition-all duration-300" - style={{ width: "65%" }} - ></div> + className="h-full bg-indigo-500 rounded-full transition-all duration-300" + style={{ width: `${overallPercent}%` }} + /> </div> - <div className="flex justify-between text-[10px] text-zinc-500"> - <span>142 MB / 218 MB</span> - <span>2.1 MB/s • 36s remaining</span> + <div className="text-xs text-muted-foreground"> + {formatBytes(totalDownloadedBytes)} downloaded </div> </div> - {/* Download Item */} - <div className="space-y-1"> - <div className="flex justify-between text-xs"> - <span className="text-zinc-300">Java 17</span> - <span className="text-zinc-400">100%</span> - </div> - <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden"> - <div className="h-full bg-emerald-500 rounded-full"></div> - </div> - <div className="text-[10px] text-emerald-400">Completed</div> + {/* Current file — always reserve space to avoid layout shifts */} + <div className="min-h-[2.5rem] border-t border-border pt-2 min-w-0"> + {currentFile && currentFileStatus !== "Finished" && ( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-xs"> + <span className="text-muted-foreground truncate w-0 grow"> + {shortenFileName(currentFile)} + </span> + <span className="text-muted-foreground shrink-0 w-16 text-right"> + {currentFileStatus === "Downloading" + ? `${filePercent}%` + : currentFileStatus} + </span> + </div> + {currentFileStatus === "Downloading" && + currentFileTotal > 0 && ( + <div className="h-1 bg-secondary rounded-full overflow-hidden"> + <div + className="h-full bg-indigo-400/60 rounded-full transition-all duration-150" + style={{ width: `${filePercent}%` }} + /> + </div> + )} + </div> + )} </div> </div> - </div> + )} + + {/* Finalizing phase — downloads done, waiting for install to finish */} + {phase === "finalizing" && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> + <span className="truncate">Verifying installation...</span> + </div> + )} + + {/* Mod loader install phase */} + {phase === "installing-mod-loader" && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0"> + <Loader2 className="h-3 w-3 shrink-0 animate-spin" /> + <span className="truncate">{phaseLabel}</span> + </div> + )} + + {/* Error */} + {phase === "error" && errorMessage && ( + <div className="text-sm text-red-400 break-words">{errorMessage}</div> + )} + + {/* Completed */} + {phase === "completed" && ( + <div className="text-sm text-emerald-400"> + Successfully installed {totalFiles > 0 ? `${totalFiles} files` : ""} + </div> + )} </div> ); } 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<number>(1); // Step 1 @@ -61,6 +64,9 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>(""); 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 = () => ( <div className="flex gap-2 w-full"> - <div - className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> - <div - className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> - <div - className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`} - /> + {stepKeys.map((key, i) => ( + <div + key={key} + className={`flex-1 h-1 rounded ${step >= i + 1 ? "bg-indigo-500" : "bg-zinc-700"}`} + /> + ))} </div> ); + const stepTitles: Record<number, string> = { + 1: "Create New Instance", + 2: "Select Version", + 3: "Mod Loader", + 4: "Installing", + }; + + const stepDescriptions: Record<number, string> = { + 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 ( - <Dialog open={open} onOpenChange={onOpenChange}> + <Dialog + open={open} + onOpenChange={(newOpen) => { + // Prevent closing during active installation + if (step === 4 && !installFinished) return; + onOpenChange(newOpen); + }} + > <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden"> <DialogHeader> - <DialogTitle>Create New Instance</DialogTitle> - <DialogDescription> - Multi-step wizard — create an instance and optionally install a - version or mod loader. - </DialogDescription> + <DialogTitle>{stepTitles[step]}</DialogTitle> + <DialogDescription>{stepDescriptions[step]}</DialogDescription> </DialogHeader> - <div className="px-6"> + <div className="px-6 overflow-hidden"> <div className="pt-4 pb-6"> <StepIndicator /> </div> @@ -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 <option key={f.version} value={f.version}> {f.version} </option> @@ -480,7 +547,39 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { </div> )} - {errorMessage && ( + {/* Step 4 - Installing with progress */} + {step === 4 && ( + <div className="space-y-4 py-2"> + {/* Summary of what's being installed */} + <div className="rounded-lg border border-border bg-card/50 p-3 space-y-1"> + <div className="text-xs text-muted-foreground">Instance</div> + <div className="text-sm font-medium">{instanceName}</div> + <div className="text-xs text-muted-foreground mt-2"> + Version + </div> + <div className="text-sm font-mono">{selectedVersionUI?.id}</div> + {modLoaderType !== "vanilla" && ( + <> + <div className="text-xs text-muted-foreground mt-2"> + Mod Loader + </div> + <div className="text-sm"> + {modLoaderType === "fabric" + ? `Fabric ${selectedFabricLoader}` + : `Forge ${selectedForgeLoader}`} + </div> + </> + )} + </div> + + {/* Download progress */} + <div className="rounded-lg border border-border bg-card/50 p-4"> + <DownloadProgress /> + </div> + </div> + )} + + {errorMessage && step !== 4 && ( <div className="text-sm text-red-400 mt-3">{errorMessage}</div> )} </div> @@ -488,21 +587,29 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { <DialogFooter> <div className="w-full flex justify-between items-center"> <div> - <Button - type="button" - variant="ghost" - onClick={() => { - // cancel - onOpenChange(false); - }} - disabled={creating} - > - Cancel - </Button> + {step === 4 ? ( + <Button + type="button" + variant="ghost" + onClick={() => onOpenChange(false)} + disabled={!installFinished} + > + {installFinished ? "Close" : "Installing..."} + </Button> + ) : ( + <Button + type="button" + variant="ghost" + onClick={() => onOpenChange(false)} + disabled={creating} + > + Cancel + </Button> + )} </div> <div className="flex gap-2"> - {step > 1 && ( + {step > 1 && step < 4 && ( <Button type="button" variant="outline" @@ -517,21 +624,20 @@ export function InstanceCreationModal({ open, onOpenChange }: Props) { <Button type="button" onClick={handleNext} disabled={creating}> Next </Button> - ) : ( + ) : step === 3 ? ( <Button type="button" onClick={handleCreate} disabled={creating} > - {creating ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - Creating... - </> - ) : ( - "Create" - )} + Create </Button> + ) : ( + installFinished && ( + <Button type="button" onClick={() => onOpenChange(false)}> + Done + </Button> + ) )} </div> </div> 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", + }); + }, +})); |