aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/InstanceCreationModal.svelte485
-rw-r--r--ui/src/components/InstanceEditorModal.svelte439
-rw-r--r--ui/src/components/InstancesView.svelte110
-rw-r--r--ui/src/components/SettingsView.svelte146
-rw-r--r--ui/src/components/VersionsView.svelte6
5 files changed, 1094 insertions, 92 deletions
diff --git a/ui/src/components/InstanceCreationModal.svelte b/ui/src/components/InstanceCreationModal.svelte
new file mode 100644
index 0000000..c54cb98
--- /dev/null
+++ b/ui/src/components/InstanceCreationModal.svelte
@@ -0,0 +1,485 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { X, ChevronLeft, ChevronRight, Loader2, Search } from "lucide-svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { gameState } from "../stores/game.svelte";
+ import type { Version, Instance, FabricLoaderEntry, ForgeVersion } from "../types";
+
+ interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ }
+
+ let { isOpen, onClose }: Props = $props();
+
+ // Wizard steps: 1 = Name, 2 = Version, 3 = Mod Loader
+ let currentStep = $state(1);
+ let instanceName = $state("");
+ let selectedVersion = $state<Version | null>(null);
+ let modLoaderType = $state<"vanilla" | "fabric" | "forge">("vanilla");
+ let selectedFabricLoader = $state("");
+ let selectedForgeLoader = $state("");
+ let creating = $state(false);
+ let errorMessage = $state("");
+
+ // Mod loader lists
+ let fabricLoaders = $state<FabricLoaderEntry[]>([]);
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let loadingLoaders = $state(false);
+
+ // Version list filtering
+ let versionSearch = $state("");
+ let versionFilter = $state<"all" | "release" | "snapshot">("release");
+
+ // Filtered versions
+ let filteredVersions = $derived(() => {
+ let versions = gameState.versions || [];
+
+ // Filter by type
+ if (versionFilter !== "all") {
+ versions = versions.filter((v) => v.type === versionFilter);
+ }
+
+ // Search filter
+ if (versionSearch) {
+ versions = versions.filter((v) =>
+ v.id.toLowerCase().includes(versionSearch.toLowerCase())
+ );
+ }
+
+ return versions;
+ });
+
+ // Fetch mod loaders when entering step 3
+ async function loadModLoaders() {
+ if (!selectedVersion) return;
+
+ loadingLoaders = true;
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion: selectedVersion.id,
+ });
+ fabricLoaders = loaders;
+ if (loaders.length > 0) {
+ selectedFabricLoader = loaders[0].loader.version;
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion: selectedVersion.id,
+ });
+ forgeVersions = versions;
+ if (versions.length > 0) {
+ selectedForgeLoader = versions[0].version;
+ }
+ }
+ } catch (err) {
+ errorMessage = `Failed to load ${modLoaderType} versions: ${err}`;
+ } finally {
+ loadingLoaders = false;
+ }
+ }
+
+ // Watch for mod loader type changes and load loaders
+ $effect(() => {
+ if (currentStep === 3 && modLoaderType !== "vanilla") {
+ loadModLoaders();
+ }
+ });
+
+ // Reset modal state
+ function resetModal() {
+ currentStep = 1;
+ instanceName = "";
+ selectedVersion = null;
+ modLoaderType = "vanilla";
+ selectedFabricLoader = "";
+ selectedForgeLoader = "";
+ creating = false;
+ errorMessage = "";
+ versionSearch = "";
+ versionFilter = "release";
+ }
+
+ function handleClose() {
+ if (!creating) {
+ resetModal();
+ onClose();
+ }
+ }
+
+ function goToStep(step: number) {
+ errorMessage = "";
+ currentStep = step;
+ }
+
+ function validateStep1() {
+ if (!instanceName.trim()) {
+ errorMessage = "Please enter an instance name";
+ return false;
+ }
+ return true;
+ }
+
+ function validateStep2() {
+ if (!selectedVersion) {
+ errorMessage = "Please select a Minecraft version";
+ return false;
+ }
+ return true;
+ }
+
+ async function handleNext() {
+ errorMessage = "";
+
+ if (currentStep === 1) {
+ if (validateStep1()) {
+ goToStep(2);
+ }
+ } else if (currentStep === 2) {
+ if (validateStep2()) {
+ goToStep(3);
+ }
+ }
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+
+ creating = true;
+ errorMessage = "";
+
+ try {
+ // Step 1: Create instance
+ const instance: Instance = await invoke("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // Step 2: Install vanilla version
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: selectedVersion!.id,
+ });
+
+ // Step 3: Install mod loader if selected
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersion!.id,
+ loaderVersion: selectedFabricLoader,
+ });
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersion!.id,
+ forgeVersion: selectedForgeLoader,
+ });
+ } else {
+ // Update instance with vanilla version_id
+ await invoke("update_instance", {
+ instance: { ...instance, version_id: selectedVersion!.id },
+ });
+ }
+
+ // Reload instances
+ await instancesState.loadInstances();
+
+ // Success! Close modal
+ resetModal();
+ onClose();
+ } catch (error) {
+ errorMessage = String(error);
+ creating = false;
+ }
+ }
+</script>
+
+{#if isOpen}
+ <div
+ class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
+ role="dialog"
+ aria-modal="true"
+ >
+ <div
+ class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"
+ >
+ <!-- Header -->
+ <div
+ class="flex items-center justify-between p-6 border-b border-zinc-700"
+ >
+ <div>
+ <h2 class="text-xl font-bold text-white">Create New Instance</h2>
+ <p class="text-sm text-zinc-400 mt-1">
+ Step {currentStep} of 3
+ </p>
+ </div>
+ <button
+ onclick={handleClose}
+ disabled={creating}
+ class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ <!-- Progress indicator -->
+ <div class="flex gap-2 px-6 pt-4">
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 1
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 2
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ <div
+ class="flex-1 h-1 rounded-full transition-colors {currentStep >= 3
+ ? 'bg-indigo-500'
+ : 'bg-zinc-700'}"
+ ></div>
+ </div>
+
+ <!-- Content -->
+ <div class="flex-1 overflow-y-auto p-6">
+ {#if currentStep === 1}
+ <!-- Step 1: Name -->
+ <div class="space-y-4">
+ <div>
+ <label
+ for="instance-name"
+ class="block text-sm font-medium text-white/90 mb-2"
+ >Instance Name</label
+ >
+ <input
+ id="instance-name"
+ type="text"
+ bind:value={instanceName}
+ placeholder="My Minecraft Instance"
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={creating}
+ />
+ </div>
+ <p class="text-xs text-zinc-400">
+ Give your instance a memorable name
+ </p>
+ </div>
+ {:else if currentStep === 2}
+ <!-- Step 2: Version Selection -->
+ <div class="space-y-4">
+ <div class="flex gap-4">
+ <div class="flex-1 relative">
+ <Search
+ size={16}
+ class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
+ />
+ <input
+ type="text"
+ bind:value={versionSearch}
+ placeholder="Search versions..."
+ class="w-full pl-10 pr-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ />
+ </div>
+ <div class="flex gap-2">
+ {#each [
+ { value: "all", label: "All" },
+ { value: "release", label: "Release" },
+ { value: "snapshot", label: "Snapshot" },
+ ] as filter}
+ <button
+ onclick={() => {
+ versionFilter = filter.value as "all" | "release" | "snapshot";
+ }}
+ class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {versionFilter ===
+ filter.value
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {filter.label}
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <div class="max-h-96 overflow-y-auto space-y-2">
+ {#each filteredVersions() as version}
+ <button
+ onclick={() => (selectedVersion = version)}
+ class="w-full p-3 rounded-lg border transition-colors text-left {selectedVersion?.id ===
+ version.id
+ ? 'bg-indigo-600/20 border-indigo-500 text-white'
+ : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-600'}"
+ >
+ <div class="flex items-center justify-between">
+ <span class="font-medium">{version.id}</span>
+ <span
+ class="text-xs px-2 py-1 rounded-full {version.type ===
+ 'release'
+ ? 'bg-green-500/20 text-green-400'
+ : 'bg-yellow-500/20 text-yellow-400'}"
+ >
+ {version.type}
+ </span>
+ </div>
+ </button>
+ {/each}
+
+ {#if filteredVersions().length === 0}
+ <div class="text-center py-8 text-zinc-500">
+ No versions found
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else if currentStep === 3}
+ <!-- Step 3: Mod Loader -->
+ <div class="space-y-4">
+ <div>
+ <div class="text-sm font-medium text-white/90 mb-3">
+ Mod Loader Type
+ </div>
+ <div class="flex gap-3">
+ {#each [
+ { value: "vanilla", label: "Vanilla" },
+ { value: "fabric", label: "Fabric" },
+ { value: "forge", label: "Forge" },
+ ] as loader}
+ <button
+ onclick={() => {
+ modLoaderType = loader.value as "vanilla" | "fabric" | "forge";
+ }}
+ class="flex-1 px-4 py-3 rounded-lg text-sm font-medium transition-colors {modLoaderType ===
+ loader.value
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {loader.label}
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ {#if modLoaderType === "fabric"}
+ <div>
+ <label for="fabric-loader" class="block text-sm font-medium text-white/90 mb-2">
+ Fabric Loader Version
+ </label>
+ {#if loadingLoaders}
+ <div class="flex items-center gap-2 text-zinc-400">
+ <Loader2 size={16} class="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ {:else if fabricLoaders.length > 0}
+ <select
+ id="fabric-loader"
+ bind:value={selectedFabricLoader}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ >
+ {#each fabricLoaders as loader}
+ <option value={loader.loader.version}>
+ {loader.loader.version} {loader.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ {/each}
+ </select>
+ {:else}
+ <p class="text-sm text-red-400">No Fabric loaders available for this version</p>
+ {/if}
+ </div>
+ {:else if modLoaderType === "forge"}
+ <div>
+ <label for="forge-version" class="block text-sm font-medium text-white/90 mb-2">
+ Forge Version
+ </label>
+ {#if loadingLoaders}
+ <div class="flex items-center gap-2 text-zinc-400">
+ <Loader2 size={16} class="animate-spin" />
+ Loading Forge versions...
+ </div>
+ {:else if forgeVersions.length > 0}
+ <select
+ id="forge-version"
+ bind:value={selectedForgeLoader}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ >
+ {#each forgeVersions as version}
+ <option value={version.version}>
+ {version.version}
+ </option>
+ {/each}
+ </select>
+ {:else}
+ <p class="text-sm text-red-400">No Forge versions available for this version</p>
+ {/if}
+ </div>
+ {:else if modLoaderType === "vanilla"}
+ <p class="text-sm text-zinc-400">
+ Create a vanilla Minecraft instance without any mod loaders
+ </p>
+ {/if}
+ </div>
+ {/if}
+
+ {#if errorMessage}
+ <div
+ class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm"
+ >
+ {errorMessage}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div
+ class="flex items-center justify-between gap-3 p-6 border-t border-zinc-700"
+ >
+ <button
+ onclick={() => goToStep(currentStep - 1)}
+ disabled={currentStep === 1 || creating}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
+ >
+ <ChevronLeft size={16} />
+ Back
+ </button>
+
+ <div class="flex gap-3">
+ <button
+ onclick={handleClose}
+ disabled={creating}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+
+ {#if currentStep < 3}
+ <button
+ onclick={handleNext}
+ disabled={creating}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ Next
+ <ChevronRight size={16} />
+ </button>
+ {:else}
+ <button
+ onclick={handleCreate}
+ disabled={creating ||
+ !instanceName.trim() ||
+ !selectedVersion ||
+ (modLoaderType === "fabric" && !selectedFabricLoader) ||
+ (modLoaderType === "forge" && !selectedForgeLoader)}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ {#if creating}
+ <Loader2 size={16} class="animate-spin" />
+ Creating...
+ {:else}
+ Create Instance
+ {/if}
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/ui/src/components/InstanceEditorModal.svelte b/ui/src/components/InstanceEditorModal.svelte
new file mode 100644
index 0000000..0856d93
--- /dev/null
+++ b/ui/src/components/InstanceEditorModal.svelte
@@ -0,0 +1,439 @@
+<script lang="ts">
+ import { invoke } from "@tauri-apps/api/core";
+ import { X, Save, Loader2, Trash2, FolderOpen } from "lucide-svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { gameState } from "../stores/game.svelte";
+ import { settingsState } from "../stores/settings.svelte";
+ import type { Instance, FileInfo, FabricLoaderEntry, ForgeVersion } from "../types";
+ import ModLoaderSelector from "./ModLoaderSelector.svelte";
+
+ interface Props {
+ isOpen: boolean;
+ instance: Instance | null;
+ onClose: () => void;
+ }
+
+ let { isOpen, instance, onClose }: Props = $props();
+
+ // Tabs: "info" | "version" | "files" | "settings"
+ let activeTab = $state<"info" | "version" | "files" | "settings">("info");
+ let saving = $state(false);
+ let errorMessage = $state("");
+
+ // Info tab state
+ let editName = $state("");
+ let editNotes = $state("");
+
+ // Version tab state
+ let fabricLoaders = $state<FabricLoaderEntry[]>([]);
+ let forgeVersions = $state<ForgeVersion[]>([]);
+ let loadingVersions = $state(false);
+
+ // Files tab state
+ let selectedFileFolder = $state<"mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots">("mods");
+ let fileList = $state<FileInfo[]>([]);
+ let loadingFiles = $state(false);
+ let deletingPath = $state<string | null>(null);
+
+ // Settings tab state
+ let editMemoryMin = $state(0);
+ let editMemoryMax = $state(0);
+ let editJavaArgs = $state("");
+
+ // Initialize form when instance changes
+ $effect(() => {
+ if (isOpen && instance) {
+ editName = instance.name;
+ editNotes = instance.notes || "";
+ editMemoryMin = instance.memory_override?.min || settingsState.settings.min_memory || 512;
+ editMemoryMax = instance.memory_override?.max || settingsState.settings.max_memory || 2048;
+ editJavaArgs = instance.jvm_args_override || "";
+ errorMessage = "";
+ }
+ });
+
+ // Load files when switching to files tab
+ $effect(() => {
+ if (isOpen && instance && activeTab === "files") {
+ loadFileList();
+ }
+ });
+
+ // Load file list for selected folder
+ async function loadFileList() {
+ if (!instance) return;
+
+ loadingFiles = true;
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder: selectedFileFolder,
+ });
+ fileList = files;
+ } catch (err) {
+ errorMessage = `Failed to load files: ${err}`;
+ fileList = [];
+ } finally {
+ loadingFiles = false;
+ }
+ }
+
+ // Change selected folder and reload
+ async function changeFolder(folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots") {
+ selectedFileFolder = folder;
+ await loadFileList();
+ }
+
+ // Delete a file or directory
+ async function deleteFile(filePath: string) {
+ if (!confirm(`Are you sure you want to delete "${filePath.split("/").pop()}"?`)) {
+ return;
+ }
+
+ deletingPath = filePath;
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // Reload file list
+ await loadFileList();
+ } catch (err) {
+ errorMessage = `Failed to delete file: ${err}`;
+ } finally {
+ deletingPath = null;
+ }
+ }
+
+ // Open file in system explorer
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ errorMessage = `Failed to open file explorer: ${err}`;
+ }
+ }
+
+ // Save instance changes
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ errorMessage = "Instance name cannot be empty";
+ return;
+ }
+
+ saving = true;
+ errorMessage = "";
+
+ try {
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ notes: editNotes.trim() || undefined,
+ memory_override: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvm_args_override: editJavaArgs.trim() || undefined,
+ };
+
+ await instancesState.updateInstance(updatedInstance);
+ onClose();
+ } catch (err) {
+ errorMessage = `Failed to save instance: ${err}`;
+ } finally {
+ saving = false;
+ }
+ }
+
+ function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
+ }
+
+ function formatDate(timestamp: number): string {
+ return new Date(timestamp * 1000).toLocaleDateString();
+ }
+</script>
+
+{#if isOpen && instance}
+ <div
+ class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
+ role="dialog"
+ aria-modal="true"
+ >
+ <div
+ class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
+ >
+ <!-- Header -->
+ <div class="flex items-center justify-between p-6 border-b border-zinc-700">
+ <div>
+ <h2 class="text-xl font-bold text-white">Edit Instance</h2>
+ <p class="text-sm text-zinc-400 mt-1">{instance.name}</p>
+ </div>
+ <button
+ onclick={onClose}
+ disabled={saving}
+ class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ <!-- Tab Navigation -->
+ <div class="flex gap-1 px-6 pt-4 border-b border-zinc-700">
+ {#each [
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ] as tab}
+ <button
+ onclick={() => (activeTab = tab.id as any)}
+ class="px-4 py-2 text-sm font-medium transition-colors rounded-t-lg {activeTab === tab.id
+ ? 'bg-indigo-600 text-white'
+ : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
+ >
+ {tab.label}
+ </button>
+ {/each}
+ </div>
+
+ <!-- Content Area -->
+ <div class="flex-1 overflow-y-auto p-6">
+ {#if activeTab === "info"}
+ <!-- Info Tab -->
+ <div class="space-y-4">
+ <div>
+ <label for="instance-name" class="block text-sm font-medium text-white/90 mb-2">
+ Instance Name
+ </label>
+ <input
+ id="instance-name"
+ type="text"
+ bind:value={editName}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label for="instance-notes" class="block text-sm font-medium text-white/90 mb-2">
+ Notes
+ </label>
+ <textarea
+ id="instance-notes"
+ bind:value={editNotes}
+ rows="4"
+ placeholder="Add notes about this instance..."
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
+ disabled={saving}
+ ></textarea>
+ </div>
+
+ <div class="grid grid-cols-2 gap-4 text-sm">
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Created</p>
+ <p class="text-white font-medium">{formatDate(instance.created_at)}</p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Last Played</p>
+ <p class="text-white font-medium">
+ {instance.last_played ? formatDate(instance.last_played) : "Never"}
+ </p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Game Directory</p>
+ <p class="text-white font-medium text-xs truncate" title={instance.game_dir}>
+ {instance.game_dir.split("/").pop()}
+ </p>
+ </div>
+ <div class="p-3 bg-zinc-800 rounded-lg">
+ <p class="text-zinc-400">Current Version</p>
+ <p class="text-white font-medium">{instance.version_id || "None"}</p>
+ </div>
+ </div>
+ </div>
+ {:else if activeTab === "version"}
+ <!-- Version Tab -->
+ <div class="space-y-4">
+ {#if instance.version_id}
+ <div class="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p class="text-sm text-indigo-400">
+ Currently playing: <span class="font-medium">{instance.version_id}</span>
+ {#if instance.mod_loader}
+ with <span class="capitalize">{instance.mod_loader}</span>
+ {instance.mod_loader_version && `${instance.mod_loader_version}`}
+ {/if}
+ </p>
+ </div>
+ {/if}
+
+ <div>
+ <h3 class="text-sm font-medium text-white/90 mb-4">Change Version or Mod Loader</h3>
+ <ModLoaderSelector
+ selectedGameVersion={instance.version_id || ""}
+ onInstall={(versionId) => {
+ // Version installed, update instance version_id
+ instance.version_id = versionId;
+ saveChanges();
+ }}
+ />
+ </div>
+ </div>
+ {:else if activeTab === "files"}
+ <!-- Files Tab -->
+ <div class="space-y-4">
+ <div class="flex gap-2 flex-wrap">
+ {#each ["mods", "resourcepacks", "shaderpacks", "saves", "screenshots"] as folder}
+ <button
+ onclick={() => changeFolder(folder as any)}
+ class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors {selectedFileFolder ===
+ folder
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"}"
+ >
+ {folder}
+ </button>
+ {/each}
+ </div>
+
+ {#if loadingFiles}
+ <div class="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 size={16} class="animate-spin" />
+ Loading files...
+ </div>
+ {:else if fileList.length === 0}
+ <div class="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ {:else}
+ <div class="space-y-2">
+ {#each fileList as file}
+ <div
+ class="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div class="flex-1 min-w-0">
+ <p class="font-medium text-white truncate">{file.name}</p>
+ <p class="text-xs text-zinc-400">
+ {file.is_directory ? "Folder" : formatFileSize(file.size)}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div class="flex gap-2 ml-4">
+ <button
+ onclick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ class="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <FolderOpen size={16} />
+ </button>
+ <button
+ onclick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ class="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {#if deletingPath === file.path}
+ <Loader2 size={16} class="animate-spin" />
+ {:else}
+ <Trash2 size={16} />
+ {/if}
+ </button>
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {:else if activeTab === "settings"}
+ <!-- Settings Tab -->
+ <div class="space-y-4">
+ <div>
+ <label for="min-memory" class="block text-sm font-medium text-white/90 mb-2">
+ Minimum Memory (MB)
+ </label>
+ <input
+ id="min-memory"
+ type="number"
+ bind:value={editMemoryMin}
+ min="256"
+ max={editMemoryMax}
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ <p class="text-xs text-zinc-400 mt-1">
+ Default: {settingsState.settings.min_memory}MB
+ </p>
+ </div>
+
+ <div>
+ <label for="max-memory" class="block text-sm font-medium text-white/90 mb-2">
+ Maximum Memory (MB)
+ </label>
+ <input
+ id="max-memory"
+ type="number"
+ bind:value={editMemoryMax}
+ min={editMemoryMin}
+ max="16384"
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
+ disabled={saving}
+ />
+ <p class="text-xs text-zinc-400 mt-1">
+ Default: {settingsState.settings.max_memory}MB
+ </p>
+ </div>
+
+ <div>
+ <label for="java-args" class="block text-sm font-medium text-white/90 mb-2">
+ JVM Arguments (Advanced)
+ </label>
+ <textarea
+ id="java-args"
+ bind:value={editJavaArgs}
+ rows="4"
+ placeholder="-XX:+UnlockExperimentalVMOptions -XX:G1NewCollectionPercentage=20..."
+ class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-sm resize-none"
+ disabled={saving}
+ ></textarea>
+ <p class="text-xs text-zinc-400 mt-1">
+ Leave empty to use global Java arguments
+ </p>
+ </div>
+ </div>
+ {/if}
+
+ {#if errorMessage}
+ <div class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
+ {errorMessage}
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div class="flex items-center justify-end gap-3 p-6 border-t border-zinc-700">
+ <button
+ onclick={onClose}
+ disabled={saving}
+ class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={saveChanges}
+ disabled={saving || !editName.trim()}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
+ >
+ {#if saving}
+ <Loader2 size={16} class="animate-spin" />
+ Saving...
+ {:else}
+ <Save size={16} />
+ Save Changes
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/ui/src/components/InstancesView.svelte b/ui/src/components/InstancesView.svelte
index a4881e6..5334f9e 100644
--- a/ui/src/components/InstancesView.svelte
+++ b/ui/src/components/InstancesView.svelte
@@ -3,12 +3,15 @@
import { instancesState } from "../stores/instances.svelte";
import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte";
import type { Instance } from "../types";
+ import InstanceCreationModal from "./InstanceCreationModal.svelte";
+ import InstanceEditorModal from "./InstanceEditorModal.svelte";
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showDeleteConfirm = $state(false);
let showDuplicateModal = $state(false);
let selectedInstance: Instance | null = $state(null);
+ let editingInstance: Instance | null = $state(null);
let newInstanceName = $state("");
let duplicateName = $state("");
@@ -17,14 +20,11 @@
});
function handleCreate() {
- newInstanceName = "";
showCreateModal = true;
}
function handleEdit(instance: Instance) {
- selectedInstance = instance;
- newInstanceName = instance.name;
- showEditModal = true;
+ editingInstance = instance;
}
function handleDelete(instance: Instance) {
@@ -38,24 +38,6 @@
showDuplicateModal = true;
}
- async function confirmCreate() {
- if (!newInstanceName.trim()) return;
- await instancesState.createInstance(newInstanceName.trim());
- showCreateModal = false;
- newInstanceName = "";
- }
-
- async function confirmEdit() {
- if (!selectedInstance || !newInstanceName.trim()) return;
- await instancesState.updateInstance({
- ...selectedInstance,
- name: newInstanceName.trim(),
- });
- showEditModal = false;
- selectedInstance = null;
- newInstanceName = "";
- }
-
async function confirmDelete() {
if (!selectedInstance) return;
await instancesState.deleteInstance(selectedInstance.id);
@@ -111,10 +93,13 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each instancesState.instances as instance (instance.id)}
<div
+ role="button"
+ tabindex="0"
class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id
? 'border-blue-500'
: 'border-transparent'}"
onclick={() => instancesState.setActiveInstance(instance.id)}
+ onkeydown={(e) => e.key === "Enter" && instancesState.setActiveInstance(instance.id)}
>
{#if instancesState.activeInstanceId === instance.id}
<div class="absolute top-2 right-2">
@@ -128,6 +113,7 @@
</h3>
<div class="flex gap-1">
<button
+ type="button"
onclick={(e) => {
e.stopPropagation();
handleEdit(instance);
@@ -138,6 +124,7 @@
<Edit2 size={16} class="text-gray-600 dark:text-gray-400" />
</button>
<button
+ type="button"
onclick={(e) => {
e.stopPropagation();
handleDuplicate(instance);
@@ -148,6 +135,7 @@
<Copy size={16} class="text-gray-600 dark:text-gray-400" />
</button>
<button
+ type="button"
onclick={(e) => {
e.stopPropagation();
handleDelete(instance);
@@ -195,75 +183,16 @@
</div>
<!-- Create Modal -->
-{#if showCreateModal}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create Instance</h2>
- <input
- type="text"
- bind:value={newInstanceName}
- placeholder="Instance name"
- class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
- onkeydown={(e) => e.key === "Enter" && confirmCreate()}
- autofocus
- />
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showCreateModal = false;
- newInstanceName = "";
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmCreate}
- disabled={!newInstanceName.trim()}
- class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
- >
- Create
- </button>
- </div>
- </div>
- </div>
-{/if}
+<InstanceCreationModal isOpen={showCreateModal} onClose={() => (showCreateModal = false)} />
-<!-- Edit Modal -->
-{#if showEditModal && selectedInstance}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Edit Instance</h2>
- <input
- type="text"
- bind:value={newInstanceName}
- placeholder="Instance name"
- class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
- onkeydown={(e) => e.key === "Enter" && confirmEdit()}
- autofocus
- />
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showEditModal = false;
- selectedInstance = null;
- newInstanceName = "";
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmEdit}
- disabled={!newInstanceName.trim()}
- class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
- >
- Save
- </button>
- </div>
- </div>
- </div>
-{/if}
+<!-- Instance Editor Modal -->
+<InstanceEditorModal
+ isOpen={editingInstance !== null}
+ instance={editingInstance}
+ onClose={() => {
+ editingInstance = null;
+ }}
+/>
<!-- Delete Confirmation -->
{#if showDeleteConfirm && selectedInstance}
@@ -305,7 +234,6 @@
placeholder="New instance name"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
onkeydown={(e) => e.key === "Enter" && confirmDuplicate()}
- autofocus
/>
<div class="flex gap-2 justify-end">
<button
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 4de18b3..0020506 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -123,6 +123,34 @@
settingsState.settings.custom_background_path = undefined;
settingsState.saveSettings();
}
+
+ let migrating = $state(false);
+ async function runMigrationToSharedCaches() {
+ if (migrating) return;
+ migrating = true;
+ try {
+ const { invoke } = await import("@tauri-apps/api/core");
+ const result = await invoke<{
+ moved_files: number;
+ hardlinks: number;
+ copies: number;
+ saved_mb: number;
+ }>("migrate_shared_caches");
+
+ // Reload settings to reflect changes
+ await settingsState.loadSettings();
+
+ // Show success message
+ const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`;
+ console.log(msg);
+ alert(msg);
+ } catch (e) {
+ console.error("Migration failed:", e);
+ alert(`Migration failed: ${e}`);
+ } finally {
+ migrating = false;
+ }
+ }
</script>
<div class="h-full flex flex-col p-6 overflow-hidden">
@@ -398,6 +426,124 @@
</div>
</div>
+ <!-- Storage & Caches -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Storage & Version Caches</h3>
+ <div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="shared-caches-label">Use Shared Caches</h4>
+ <p class="text-xs text-white/40 mt-1">Store versions/libraries/assets in a global cache shared by all instances.</p>
+ </div>
+ <button
+ aria-labelledby="shared-caches-label"
+ onclick={() => { settingsState.settings.use_shared_caches = !settingsState.settings.use_shared_caches; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.use_shared_caches ? 'bg-indigo-500' : 'bg-white/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.use_shared_caches ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="legacy-storage-label">Keep Legacy Per-Instance Storage</h4>
+ <p class="text-xs text-white/40 mt-1">Do not migrate existing instance caches; keep current layout.</p>
+ </div>
+ <button
+ aria-labelledby="legacy-storage-label"
+ onclick={() => { settingsState.settings.keep_legacy_per_instance_storage = !settingsState.settings.keep_legacy_per_instance_storage; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.keep_legacy_per_instance_storage ? 'bg-indigo-500' : 'bg-white/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.keep_legacy_per_instance_storage ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between pt-2 border-t border-white/10">
+ <div>
+ <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
+ <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
+ </div>
+ <button
+ onclick={runMigrationToSharedCaches}
+ disabled={migrating}
+ class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {migrating ? "Migrating..." : "Migrate Now"}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Feature Flags -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Feature Flags (Launcher Arguments)</h3>
+ <div class="space-y-4">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="demo-user-label">Demo User</h4>
+ <p class="text-xs text-white/40 mt-1">Enable demo-related arguments when rules require them.</p>
+ </div>
+ <button
+ aria-labelledby="demo-user-label"
+ onclick={() => { settingsState.settings.feature_flags.demo_user = !settingsState.settings.feature_flags.demo_user; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.demo_user ? 'bg-indigo-500' : 'bg-white/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.demo_user ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="quick-play-label">Quick Play</h4>
+ <p class="text-xs text-white/40 mt-1">Enable quick play singleplayer/multiplayer arguments.</p>
+ </div>
+ <button
+ aria-labelledby="quick-play-label"
+ onclick={() => { settingsState.settings.feature_flags.quick_play_enabled = !settingsState.settings.feature_flags.quick_play_enabled; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_enabled ? 'bg-indigo-500' : 'bg-white/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.feature_flags.quick_play_enabled}
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-2 border-l-2 border-white/10">
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">Singleplayer World Path</label>
+ <input
+ type="text"
+ bind:value={settingsState.settings.feature_flags.quick_play_path}
+ placeholder="/path/to/saves/MyWorld"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ </div>
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium text-white/90" id="qp-singleplayer-label">Prefer Singleplayer</h4>
+ <p class="text-xs text-white/40 mt-1">If enabled, use singleplayer quick play path.</p>
+ </div>
+ <button
+ aria-labelledby="qp-singleplayer-label"
+ onclick={() => { settingsState.settings.feature_flags.quick_play_singleplayer = !settingsState.settings.feature_flags.quick_play_singleplayer; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_singleplayer ? 'bg-indigo-500' : 'bg-white/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_singleplayer ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">Multiplayer Server Address</label>
+ <input
+ type="text"
+ bind:value={settingsState.settings.feature_flags.quick_play_multiplayer_server}
+ placeholder="example.org:25565"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
<!-- Debug / Logs -->
<div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
<h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index d4d36d5..f1474d9 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -217,7 +217,10 @@
if (!versionToDelete) return;
try {
- await invoke("delete_version", { versionId: versionToDelete });
+ await invoke("delete_version", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: versionToDelete
+ });
// Clear selection if deleted version was selected
if (gameState.selectedVersion === versionToDelete) {
gameState.selectedVersion = "";
@@ -253,6 +256,7 @@
isLoadingMetadata = true;
try {
const metadata = await invoke<VersionMetadata>("get_version_metadata", {
+ instanceId: instancesState.activeInstanceId,
versionId,
});
selectedVersionMetadata = metadata;