aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-01-15 18:29:58 +0800
committerHsiangNianian <i@jyunko.cn>2026-01-15 18:29:58 +0800
commit7a6933edd9092a25d1f24d1e17ae9cd665816460 (patch)
treeee56174eb604e76b9f6d771f36b6d6e35e1bbacb
parentff358c3456435998e14f7337f4542deeaeb59bad (diff)
downloadDropOut-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.rs89
-rw-r--r--ui/src/components/BottomBar.svelte148
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>