import { toNumber } from "es-toolkit/compat"; import { Folder, Loader2, Save, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; import type { FileInfo } from "../types/bindings/core"; import type { Instance } from "../types/bindings/instance"; import { deleteInstanceFile, listInstanceDirectory, openFileExplorer } from "@/client"; type Props = { open: boolean; instance: Instance | null; onOpenChange: (open: boolean) => void; }; export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { const instancesStore = useInstanceStore(); const { config } = useSettingsStore(); const [activeTab, setActiveTab] = useState< "info" | "version" | "files" | "settings" >("info"); const [saving, setSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(""); // Info tab fields const [editName, setEditName] = useState(""); const [editNotes, setEditNotes] = useState(""); // Files tab state const [selectedFileFolder, setSelectedFileFolder] = useState< "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots" >("mods"); const [fileList, setFileList] = useState([]); const [loadingFiles, setLoadingFiles] = useState(false); const [deletingPath, setDeletingPath] = useState(null); // Version tab state (placeholder - the Svelte implementation used a ModLoaderSelector component) // React versions-view/instance-creation handle mod loader installs; here we show basic current info. // Settings tab fields const [editMemoryMin, setEditMemoryMin] = useState(0); const [editMemoryMax, setEditMemoryMax] = useState(0); const [editJavaArgs, setEditJavaArgs] = useState(""); // initialize when open & instance changes useEffect(() => { if (open && instance) { setActiveTab("info"); setSaving(false); setErrorMessage(""); setEditName(instance.name || ""); setEditNotes(instance.notes ?? ""); setEditMemoryMin( (instance.memoryOverride && toNumber(instance.memoryOverride.min)) ?? config?.minMemory ?? 512, ); setEditMemoryMax( (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ?? config?.maxMemory ?? 2048, ); setEditJavaArgs(instance.jvmArgsOverride ?? ""); setFileList([]); setSelectedFileFolder("mods"); } }, [open, instance, config?.minMemory, config?.maxMemory]); // load files when switching to files tab const loadFileList = useCallback( async ( folder: | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots", ) => { if (!instance) return; setLoadingFiles(true); try { const files = await listInstanceDirectory(instance.id, folder); setFileList(files); } catch (err) { console.error("Failed to load files:", err); toast.error("Failed to load files: " + String(err)); setFileList([]); } finally { setLoadingFiles(false); } }, [instance], ); useEffect(() => { if (open && instance && activeTab === "files") { // explicitly pass the selected folder so loadFileList doesn't rely on stale closures loadFileList(selectedFileFolder); } }, [activeTab, open, instance, selectedFileFolder, loadFileList]); async function changeFolder( folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots", ) { setSelectedFileFolder(folder); // reload the list for the newly selected folder if (open && instance) await loadFileList(folder); } async function deleteFile(filePath: string) { if ( !confirm( `Are you sure you want to delete "${filePath.split("/").pop()}"?`, ) ) { return; } setDeletingPath(filePath); try { await deleteInstanceFile(filePath); // refresh the currently selected folder await loadFileList(selectedFileFolder); toast.success("Deleted"); } catch (err) { console.error("Failed to delete file:", err); toast.error("Failed to delete file: " + String(err)); } finally { setDeletingPath(null); } } async function openInExplorer(filePath: string) { try { await openFileExplorer(filePath); } catch (err) { console.error("Failed to open in explorer:", err); toast.error("Failed to open file explorer: " + String(err)); } } async function saveChanges() { if (!instance) return; if (!editName.trim()) { setErrorMessage("Instance name cannot be empty"); return; } setSaving(true); setErrorMessage(""); try { // Build updated instance shape compatible with backend const updatedInstance: Instance = { ...instance, name: editName.trim(), // some bindings may use camelCase; set optional string fields to null when empty notes: editNotes.trim() ? editNotes.trim() : null, memoryOverride: { min: editMemoryMin, max: editMemoryMax, }, jvmArgsOverride: editJavaArgs.trim() ? editJavaArgs.trim() : null, }; await instancesStore.update(updatedInstance as Instance); toast.success("Instance saved"); onOpenChange(false); } catch (err) { console.error("Failed to save instance:", err); setErrorMessage(String(err)); toast.error("Failed to save instance: " + String(err)); } finally { setSaving(false); } } function formatFileSize(bytesBig: FileInfo["size"]): string { const bytes = Number(bytesBig ?? 0); if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`; } function formatDate( tsBig?: | FileInfo["modified"] | Instance["createdAt"] | Instance["lastPlayed"], ) { if (tsBig === undefined || tsBig === null) return ""; const n = toNumber(tsBig); // tsrs bindings often use seconds for createdAt/lastPlayed; if value looks like seconds use *1000 const maybeMs = n > 1e12 ? n : n * 1000; return new Date(maybeMs).toLocaleDateString(); } return (
Edit Instance {instance?.name ?? ""}
{/* Tab Navigation */}
{[ { id: "info", label: "Info" }, { id: "version", label: "Version" }, { id: "files", label: "Files" }, { id: "settings", label: "Settings" }, ].map((tab) => ( ))}
{/* Content */}
{activeTab === "info" && (
setEditName(e.target.value)} disabled={saving} />