diff options
| author | 2026-02-25 01:32:51 +0800 | |
|---|---|---|
| committer | 2026-02-25 01:32:51 +0800 | |
| commit | 66668d85d603c5841d755a6023aa1925559fc6d4 (patch) | |
| tree | 485464148c76b0021efb55b7d2afd1c3004ceee0 /packages/ui/src/components/instance-editor-modal.tsx | |
| parent | a6773bd092db654360c599ca6b0108ea0e456e8c (diff) | |
| download | DropOut-66668d85d603c5841d755a6023aa1925559fc6d4.tar.gz DropOut-66668d85d603c5841d755a6023aa1925559fc6d4.zip | |
chore(workspace): replace legacy codes
Diffstat (limited to 'packages/ui/src/components/instance-editor-modal.tsx')
| -rw-r--r-- | packages/ui/src/components/instance-editor-modal.tsx | 548 |
1 files changed, 548 insertions, 0 deletions
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx new file mode 100644 index 0000000..f880c20 --- /dev/null +++ b/packages/ui/src/components/instance-editor-modal.tsx @@ -0,0 +1,548 @@ +import { invoke } from "@tauri-apps/api/core"; +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 { toNumber } from "@/lib/tsrs-utils"; +import { useInstancesStore } from "@/models/instances"; +import { useSettingsStore } from "@/models/settings"; +import type { FileInfo } from "../types/bindings/core"; +import type { Instance } from "../types/bindings/instance"; + +type Props = { + open: boolean; + instance: Instance | null; + onOpenChange: (open: boolean) => void; +}; + +export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { + const instancesStore = useInstancesStore(); + 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<FileInfo[]>([]); + const [loadingFiles, setLoadingFiles] = useState(false); + const [deletingPath, setDeletingPath] = useState<string | null>(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<number>(0); + const [editMemoryMax, setEditMemoryMax] = useState<number>(0); + const [editJavaArgs, setEditJavaArgs] = useState<string>(""); + + // 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 invoke<FileInfo[]>("list_instance_directory", { + instanceId: 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 invoke("delete_instance_file", { path: 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 invoke("open_file_explorer", { path: 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="w-full max-w-4xl max-h-[90vh] overflow-hidden"> + <DialogHeader> + <div className="flex items-center justify-between gap-4"> + <div> + <DialogTitle>Edit Instance</DialogTitle> + <DialogDescription>{instance?.name ?? ""}</DialogDescription> + </div> + <div className="flex items-center gap-2"> + <button + type="button" + onClick={() => onOpenChange(false)} + disabled={saving} + className="p-2 rounded hover:bg-zinc-800 text-zinc-400" + aria-label="Close" + > + <X /> + </button> + </div> + </div> + </DialogHeader> + + {/* Tab Navigation */} + <div className="flex gap-1 px-6 pt-2 border-b border-zinc-700"> + {[ + { id: "info", label: "Info" }, + { id: "version", label: "Version" }, + { id: "files", label: "Files" }, + { id: "settings", label: "Settings" }, + ].map((tab) => ( + <button + type="button" + key={tab.id} + onClick={() => + setActiveTab( + tab.id as "info" | "version" | "files" | "settings", + ) + } + className={`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> + ))} + </div> + + {/* Content */} + <div className="p-6 overflow-y-auto max-h-[60vh]"> + {activeTab === "info" && ( + <div className="space-y-4"> + <div> + <label + htmlFor="instance-name-edit" + className="block text-sm font-medium mb-2" + > + Instance Name + </label> + <Input + id="instance-name-edit" + value={editName} + onChange={(e) => setEditName(e.target.value)} + disabled={saving} + /> + </div> + + <div> + <label + htmlFor="instance-notes-edit" + className="block text-sm font-medium mb-2" + > + Notes + </label> + <Textarea + id="instance-notes-edit" + value={editNotes} + onChange={(e) => setEditNotes(e.target.value)} + rows={4} + disabled={saving} + /> + </div> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="p-3 bg-zinc-800 rounded-lg"> + <p className="text-zinc-400">Created</p> + <p className="text-white font-medium"> + {instance?.createdAt ? formatDate(instance.createdAt) : "-"} + </p> + </div> + <div className="p-3 bg-zinc-800 rounded-lg"> + <p className="text-zinc-400">Last Played</p> + <p className="text-white font-medium"> + {instance?.lastPlayed + ? formatDate(instance.lastPlayed) + : "Never"} + </p> + </div> + <div className="p-3 bg-zinc-800 rounded-lg"> + <p className="text-zinc-400">Game Directory</p> + <p + className="text-white font-medium text-xs truncate" + title={instance?.gameDir ?? ""} + > + {instance?.gameDir + ? String(instance.gameDir).split("/").pop() + : ""} + </p> + </div> + <div className="p-3 bg-zinc-800 rounded-lg"> + <p className="text-zinc-400">Current Version</p> + <p className="text-white font-medium"> + {instance?.versionId ?? "None"} + </p> + </div> + </div> + </div> + )} + + {activeTab === "version" && ( + <div className="space-y-4"> + {instance?.versionId ? ( + <div className="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg"> + <p className="text-sm text-indigo-400"> + Currently playing:{" "} + <span className="font-medium">{instance.versionId}</span> + {instance.modLoader && ( + <> + {" "} + with{" "} + <span className="capitalize">{instance.modLoader}</span> + {instance.modLoaderVersion + ? ` ${instance.modLoaderVersion}` + : ""} + </> + )} + </p> + </div> + ) : ( + <div className="text-sm text-zinc-400"> + No version selected for this instance + </div> + )} + + <div> + <p className="text-sm font-medium mb-2"> + Change Version / Mod Loader + </p> + <p className="text-xs text-zinc-400"> + Use the Versions page to install new game versions or mod + loaders, then set them here. + </p> + </div> + </div> + )} + + {activeTab === "files" && ( + <div className="space-y-4"> + <div className="flex gap-2 flex-wrap"> + {( + [ + "mods", + "resourcepacks", + "shaderpacks", + "saves", + "screenshots", + ] as const + ).map((folder) => ( + <button + type="button" + key={folder} + onClick={() => changeFolder(folder)} + className={`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> + ))} + </div> + + {loadingFiles ? ( + <div className="flex items-center gap-2 text-zinc-400 py-8 justify-center"> + <Loader2 className="animate-spin" /> + Loading files... + </div> + ) : fileList.length === 0 ? ( + <div className="text-center py-8 text-zinc-500"> + No files in this folder + </div> + ) : ( + <div className="space-y-2"> + {fileList.map((file) => ( + <div + key={file.path} + className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors" + > + <div className="flex-1 min-w-0"> + <p className="font-medium text-white truncate"> + {file.name} + </p> + <p className="text-xs text-zinc-400"> + {file.isDirectory + ? "Folder" + : formatFileSize(file.size)}{" "} + • {formatDate(file.modified)} + </p> + </div> + <div className="flex gap-2 ml-4"> + <button + type="button" + onClick={() => openInExplorer(file.path)} + title="Open in explorer" + className="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors" + > + <Folder /> + </button> + <button + type="button" + onClick={() => deleteFile(file.path)} + disabled={deletingPath === file.path} + title="Delete" + className="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50" + > + {deletingPath === file.path ? ( + <Loader2 className="animate-spin" /> + ) : ( + <Trash2 /> + )} + </button> + </div> + </div> + ))} + </div> + )} + </div> + )} + + {activeTab === "settings" && ( + <div className="space-y-4"> + <div> + <label + htmlFor="min-memory-edit" + className="block text-sm font-medium mb-2" + > + Minimum Memory (MB) + </label> + <Input + id="min-memory-edit" + type="number" + value={String(editMemoryMin)} + onChange={(e) => setEditMemoryMin(Number(e.target.value))} + disabled={saving} + /> + <p className="text-xs text-zinc-400 mt-1"> + Default: {config?.minMemory} MB + </p> + </div> + + <div> + <label + htmlFor="max-memory-edit" + className="block text-sm font-medium mb-2" + > + Maximum Memory (MB) + </label> + <Input + id="max-memory-edit" + type="number" + value={String(editMemoryMax)} + onChange={(e) => setEditMemoryMax(Number(e.target.value))} + disabled={saving} + /> + <p className="text-xs text-zinc-400 mt-1"> + Default: {config?.maxMemory} MB + </p> + </div> + + <div> + <label + htmlFor="jvm-args-edit" + className="block text-sm font-medium mb-2" + > + JVM Arguments (Advanced) + </label> + <Textarea + id="jvm-args-edit" + value={editJavaArgs} + onChange={(e) => setEditJavaArgs(e.target.value)} + rows={4} + disabled={saving} + /> + </div> + </div> + )} + </div> + + {errorMessage && ( + <div className="px-6 text-sm text-red-400">{errorMessage}</div> + )} + + <DialogFooter> + <div className="flex items-center justify-between w-full"> + <div /> + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => { + onOpenChange(false); + }} + > + Cancel + </Button> + <Button onClick={saveChanges} disabled={saving}> + {saving ? ( + <Loader2 className="animate-spin mr-2" /> + ) : ( + <Save className="mr-2" /> + )} + Save + </Button> + </div> + </div> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +export default InstanceEditorModal; |