diff options
| author | 2026-01-15 18:29:58 +0800 | |
|---|---|---|
| committer | 2026-01-15 18:29:58 +0800 | |
| commit | 7a6933edd9092a25d1f24d1e17ae9cd665816460 (patch) | |
| tree | ee56174eb604e76b9f6d771f36b6d6e35e1bbacb | |
| parent | ff358c3456435998e14f7337f4542deeaeb59bad (diff) | |
| download | DropOut-7a6933edd9092a25d1f24d1e17ae9cd665816460.tar.gz DropOut-7a6933edd9092a25d1f24d1e17ae9cd665816460.zip | |
feat: Add functionality to list installed game versions in the application, enhancing version management and user experience in BottomBar
| -rw-r--r-- | src-tauri/src/main.rs | 89 | ||||
| -rw-r--r-- | ui/src/components/BottomBar.svelte | 148 |
2 files changed, 202 insertions, 35 deletions
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 24f3ce3..080ee93 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1296,6 +1296,94 @@ async fn list_installed_fabric_versions(window: Window) -> Result<Vec<String>, S .map_err(|e| e.to_string()) } +/// Installed version info +#[derive(serde::Serialize)] +struct InstalledVersion { + id: String, + #[serde(rename = "type")] + version_type: String, // "release", "snapshot", "fabric", "forge" +} + +/// List all installed versions from the data directory +#[tauri::command] +async fn list_installed_versions(window: Window) -> Result<Vec<InstalledVersion>, String> { + let app_handle = window.app_handle(); + let game_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data dir: {}", e))?; + + let versions_dir = game_dir.join("versions"); + let mut installed = Vec::new(); + + if !versions_dir.exists() { + return Ok(installed); + } + + let mut entries = tokio::fs::read_dir(&versions_dir) + .await + .map_err(|e| e.to_string())?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? { + let name = entry.file_name().to_string_lossy().to_string(); + let version_dir = entry.path(); + + // Check if the version has a valid JSON file + let json_path = version_dir.join(format!("{}.json", name)); + if !json_path.exists() { + continue; + } + + // Check if client.jar exists (for vanilla versions) + let jar_path = version_dir.join(format!("{}.jar", name)); + let has_jar = jar_path.exists(); + + // Determine version type + let version_type = if name.starts_with("fabric-loader-") { + // Fabric versions don't need their own jar, they inherit from vanilla + "fabric".to_string() + } else if name.contains("-forge-") { + "forge".to_string() + } else if has_jar { + // Read the JSON to determine if it's release or snapshot + if let Ok(content) = tokio::fs::read_to_string(&json_path).await { + if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) { + json.get("type") + .and_then(|t| t.as_str()) + .unwrap_or("release") + .to_string() + } else { + "release".to_string() + } + } else { + "release".to_string() + } + } else { + // JSON exists but no jar - skip incomplete installations + continue; + }; + + installed.push(InstalledVersion { + id: name, + version_type, + }); + } + + // Sort: modded first, then by version id descending + installed.sort_by(|a, b| { + let a_modded = a.version_type == "fabric" || a.version_type == "forge"; + let b_modded = b.version_type == "fabric" || b.version_type == "forge"; + + match (a_modded, b_modded) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => b.id.cmp(&a.id), // Descending order + } + }); + + Ok(installed) +} + /// Check if Fabric is installed for a specific version #[tauri::command] async fn is_fabric_installed( @@ -1531,6 +1619,7 @@ fn main() { get_versions, check_version_installed, install_version, + list_installed_versions, login_offline, get_active_account, logout, diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte index 9dcb9ac..04d53de 100644 --- a/ui/src/components/BottomBar.svelte +++ b/ui/src/components/BottomBar.svelte @@ -1,23 +1,68 @@ <script lang="ts"> + import { invoke } from "@tauri-apps/api/core"; + import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { authState } from "../stores/auth.svelte"; import { gameState } from "../stores/game.svelte"; import { uiState } from "../stores/ui.svelte"; - import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte'; + import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte'; + + interface InstalledVersion { + id: string; + type: string; + } let isVersionDropdownOpen = $state(false); let dropdownRef: HTMLDivElement; + let installedVersions = $state<InstalledVersion[]>([]); + let isLoadingVersions = $state(true); + let downloadCompleteUnlisten: UnlistenFn | null = null; + + // Load installed versions on mount + $effect(() => { + loadInstalledVersions(); + setupDownloadListener(); + return () => { + if (downloadCompleteUnlisten) { + downloadCompleteUnlisten(); + } + }; + }); + + async function setupDownloadListener() { + // Refresh list when a download completes + downloadCompleteUnlisten = await listen("download-complete", () => { + loadInstalledVersions(); + }); + } + + async function loadInstalledVersions() { + isLoadingVersions = true; + try { + installedVersions = await invoke<InstalledVersion[]>("list_installed_versions"); + // If no version is selected but we have installed versions, select the first one + if (!gameState.selectedVersion && installedVersions.length > 0) { + gameState.selectedVersion = installedVersions[0].id; + } + } catch (e) { + console.error("Failed to load installed versions:", e); + } finally { + isLoadingVersions = false; + } + } let versionOptions = $derived( - gameState.versions.length === 0 + isLoadingVersions ? [{ id: "loading", type: "loading", label: "Loading..." }] - : gameState.versions.map(v => ({ - ...v, - label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` - })) + : installedVersions.length === 0 + ? [{ id: "empty", type: "empty", label: "No versions installed" }] + : installedVersions.map(v => ({ + ...v, + label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}` + })) ); function selectVersion(id: string) { - if (id !== "loading") { + if (id !== "loading" && id !== "empty") { gameState.selectedVersion = id; isVersionDropdownOpen = false; } @@ -35,6 +80,15 @@ return () => document.removeEventListener('click', handleClickOutside); } }); + + function getVersionTypeColor(type: string) { + switch (type) { + case 'fabric': return 'text-indigo-400'; + case 'forge': return 'text-orange-400'; + case 'snapshot': return 'text-amber-400'; + default: return 'text-emerald-400'; + } + } </script> <div @@ -105,47 +159,70 @@ <div class="flex flex-col items-end mr-2"> <!-- Custom Version Dropdown --> <div class="relative" bind:this={dropdownRef}> - <button - type="button" - onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} - class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left - dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md - text-sm font-mono dark:text-white text-gray-900 - dark:hover:border-zinc-600 hover:border-zinc-400 - focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 - transition-colors cursor-pointer outline-none" - > - <span class="truncate"> - {#if gameState.versions.length === 0} - Loading... - {:else} - {gameState.selectedVersion || "Select version"} - {/if} - </span> - <ChevronDown - size={14} - class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" - /> - </button> + <div class="flex items-center gap-2"> + <button + type="button" + onclick={() => loadInstalledVersions()} + class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md + dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black + dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors" + title="Refresh installed versions" + > + <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} /> + </button> + <button + type="button" + onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen} + disabled={installedVersions.length === 0 && !isLoadingVersions} + class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left + dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md + text-sm font-mono dark:text-white text-gray-900 + dark:hover:border-zinc-600 hover:border-zinc-400 + focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 + transition-colors cursor-pointer outline-none + disabled:opacity-50 disabled:cursor-not-allowed" + > + <span class="truncate"> + {#if isLoadingVersions} + Loading... + {:else if installedVersions.length === 0} + No versions installed + {:else} + {gameState.selectedVersion || "Select version"} + {/if} + </span> + <ChevronDown + size={14} + class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}" + /> + </button> + </div> - {#if isVersionDropdownOpen} + {#if isVersionDropdownOpen && installedVersions.length > 0} <div class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl - max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1" + max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0" > {#each versionOptions as version} <button type="button" onclick={() => selectVersion(version.id)} - disabled={version.id === "loading"} + disabled={version.id === "loading" || version.id === "empty"} class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left transition-colors outline-none {version.id === gameState.selectedVersion ? 'bg-indigo-600 text-white' : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'} - {version.id === 'loading' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" + {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}" > - <span class="truncate">{version.label}</span> + <span class="truncate flex items-center gap-2"> + {version.id} + {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'} + <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}"> + {version.type} + </span> + {/if} + </span> {#if version.id === gameState.selectedVersion} <Check size={14} class="shrink-0 ml-2" /> {/if} @@ -158,7 +235,8 @@ <button onclick={() => gameState.startGame()} - class="bg-emerald-600 hover:bg-emerald-500 text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase" + disabled={installedVersions.length === 0 || !gameState.selectedVersion} + class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase" > <Play size={24} fill="currentColor" /> <span>Launch</span> |