diff --git a/.changes/config.toml b/.changes/config.toml index dbce913..8a7034d 100644 --- a/.changes/config.toml +++ b/.changes/config.toml @@ -20,6 +20,11 @@ resolver = "rust" version-mode = { pre-release.tag = "alpha" } assets = ["artifacts/**/*"] +[packages.dropout-macros] +path = "crates/macros" +resolver = "rust" +version-mode = { pre-release.tag = "alpha" } + [resolver.rust.pre-check] url = "https://crates.io/api/v1/crates/{{ package.name }}/{{ package.version }}" diff --git a/.changes/dropout-macros.md b/.changes/dropout-macros.md new file mode 100644 index 0000000..faa4e3e --- /dev/null +++ b/.changes/dropout-macros.md @@ -0,0 +1,5 @@ +--- +dropout-macros: "patch:feat" +--- + +Add `dropout-macros` crate to generate tauri api wrappers automatically. diff --git a/.changes/ts-bindings.md b/.changes/ts-bindings.md new file mode 100644 index 0000000..01be1b1 --- /dev/null +++ b/.changes/ts-bindings.md @@ -0,0 +1,5 @@ +--- +dropout: "patch:feat" +--- + +Add `ts-rs` for generating TypeScript bindings. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1713952..8d5e056 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: rev: v6.0.0 hooks: - id: check-json - exclude: ^packages/ui/tsconfig.*\.json$ + exclude: ^packages/ui(-new)?/tsconfig.*\.json$ - id: check-toml - id: check-yaml - id: check-case-conflict diff --git a/Cargo.toml b/Cargo.toml index 1a8ac7a..dedc4f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["src-tauri"] +members = ["src-tauri", "crates/*"] resolver = "3" [profile.dev] diff --git a/biome.json b/biome.json index eb43210..79b8928 100644 --- a/biome.json +++ b/biome.json @@ -31,5 +31,10 @@ "organizeImports": "on" } } + }, + "css": { + "parser": { + "tailwindDirectives": true + } } } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml new file mode 100644 index 0000000..cd5b4a6 --- /dev/null +++ b/crates/macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dropout-macros" +version = "0.1.0" +edition = "2021" +description = "Proc-macro crate providing #[dropout::api] for generating Tauri TypeScript bindings" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full"] } +heck = "0.4" diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs new file mode 100644 index 0000000..1b26bf2 --- /dev/null +++ b/crates/macros/src/lib.rs @@ -0,0 +1,386 @@ +use proc_macro::TokenStream; +use quote::quote; +use std::collections::BTreeSet; +use syn::{ + parse::Parse, parse::ParseStream, parse_macro_input, punctuated::Punctuated, token::Comma, + Expr, FnArg, Ident, ItemFn, Lit, MetaNameValue, Pat, PathArguments, ReturnType, Type, +}; + +fn get_lit_str_value(nv: &MetaNameValue) -> Option { + // In syn v2 MetaNameValue.value is an Expr (usually Expr::Lit). Extract string literal if present. + match &nv.value { + Expr::Lit(expr_lit) => { + if let Lit::Str(s) = &expr_lit.lit { + Some(s.value()) + } else { + None + } + } + _ => None, + } +} + +fn is_state_or_window(ty: &Type) -> bool { + // Unwrap reference + let mut t = ty; + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Window" || ident == "State" { + return true; + } + } + } + false +} + +fn extract_ident_from_type(ty: &Type) -> Option { + // Peel references, arrays, etc. Only handle Path types + let mut t = ty; + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + // Handle Option, Result, etc. + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Option" { + // extract generic arg (use helper) + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + return extract_ident_from_type(inner); + } + } else { + // For multi-segment like core::java::JavaDownloadInfo we return last segment ident + return Some(ident); + } + } + } + None +} + +fn first_type_arg_from_pathargs(pa: &PathArguments) -> Option<&Type> { + // Given PathArguments (e.g. from a PathSegment), return the first GenericArgument::Type if present. + if let PathArguments::AngleBracketed(ab) = pa { + for arg in ab.args.iter() { + if let syn::GenericArgument::Type(ty) = arg { + return Some(ty); + } + } + } + None +} + +fn rust_type_to_ts(ty: &Type) -> (String, bool) { + // returns (ts_type, is_struct_like) + // is_struct_like signals that this type probably needs import from `import_from` + // Simple mapping: String -> string, primitives -> number, bool -> boolean, others -> ident + let mut t = ty; + // Unwrap references + if let Type::Reference(r) = t { + t = &*r.elem; + } + + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + // handle Option + if ident == "Option" { + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + let (inner_ts, inner_struct) = rust_type_to_ts(inner); + // make optional, represent as type | null + return (format!("{} | null", inner_ts), inner_struct); + } + } + return match ident.as_str() { + "String" => ("string".to_string(), false), + "str" => ("string".to_string(), false), + "bool" => ("boolean".to_string(), false), + "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" + | "usize" | "isize" | "f32" | "f64" => ("number".to_string(), false), + "Vec" => { + // Vec -> T[] + if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) { + let (inner_ts, inner_struct) = rust_type_to_ts(inner); + return (format!("{}[]", inner_ts), inner_struct); + } + ("any[]".to_string(), false) + } + other => { + // treat as struct/complex type + (other.to_string(), true) + } + }; + } + } + ("any".to_string(), false) +} + +fn get_return_ts(ty: &ReturnType) -> (String, BTreeSet) { + // returns (promise_ts_type, set_of_structs_to_import) + let mut imports = BTreeSet::new(); + match ty { + ReturnType::Default => ("Promise".to_string(), imports), + ReturnType::Type(_, boxed) => { + // look for Result commonly + let t = &**boxed; + if let Type::Path(p) = t { + if let Some(seg) = p.path.segments.last() { + let ident = seg.ident.to_string(); + if ident == "Result" { + if let Some(ok_ty) = first_type_arg_from_pathargs(&seg.arguments) { + let (ts, is_struct) = rust_type_to_ts(ok_ty); + if is_struct { + if let Some(name) = extract_ident_from_type(ok_ty) { + imports.insert(name); + } + } + return (format!("Promise<{}>", ts), imports); + } + } else { + // not Result - map directly + let (ts, is_struct) = rust_type_to_ts(t); + if is_struct { + if let Some(name) = extract_ident_from_type(t) { + imports.insert(name); + } + } + return (format!("Promise<{}>", ts), imports); + } + } + } + // fallback + ("Promise".to_string(), imports) + } + } +} + +fn snake_to_camel(s: &str) -> String { + let mut parts = s.split('_'); + let mut out = String::new(); + if let Some(first) = parts.next() { + out.push_str(&first.to_ascii_lowercase()); + } + for p in parts { + if p.is_empty() { + continue; + } + let mut chs = p.chars(); + if let Some(c) = chs.next() { + out.push_str(&c.to_ascii_uppercase().to_string()); + out.push_str(&chs.as_str().to_ascii_lowercase()); + } + } + out +} + +#[proc_macro_attribute] +pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { + // Parse inputs as a punctuated list of MetaNameValue (e.g. export_to = "...", import_from = "...") + // `MetaList` implements `Parse` so we can parse the raw attribute token stream reliably + struct MetaList(Punctuated); + impl Parse for MetaList { + fn parse(input: ParseStream) -> syn::Result { + Ok(MetaList(Punctuated::parse_terminated(input)?)) + } + } + let metas = parse_macro_input!(attr as MetaList).0; + let input_fn = parse_macro_input!(item as ItemFn); + + // Extract attribute args: export_to, import_from + let mut export_to: Option = None; + let mut import_from: Option = None; + + for nv in metas.iter() { + if let Some(ident) = nv.path.get_ident() { + let name = ident.to_string(); + if name == "export_to" { + if let Some(v) = get_lit_str_value(nv) { + export_to = Some(v); + } + } else if name == "import_from" { + if let Some(v) = get_lit_str_value(nv) { + import_from = Some(v); + } + } + } + } + + // Analyze function + let fn_name_ident: Ident = input_fn.sig.ident.clone(); + let fn_name = fn_name_ident.to_string(); + let ts_fn_name = snake_to_camel(&fn_name); + + // Collect parameters (ignore State/Window) + let mut param_names: Vec = Vec::new(); + let mut param_defs: Vec = Vec::new(); + let mut import_types: BTreeSet = BTreeSet::new(); + + for input in input_fn.sig.inputs.iter() { + match input { + FnArg::Receiver(_) => { + // skip self + } + FnArg::Typed(pt) => { + // Get parameter identifier + let pat = &*pt.pat; + let param_ident = if let Pat::Ident(pi) = pat { + Some(pi.ident.to_string()) + } else { + // ignore complex patterns + continue; + }; + + // Check if type should be ignored (State, Window) + if is_state_or_window(&*pt.ty) { + continue; + } + + // Map type + let (ts_type, is_struct) = rust_type_to_ts(&*pt.ty); + if is_struct { + if let Some(name) = extract_ident_from_type(&*pt.ty) { + import_types.insert(name); + } + } + + if let Some(pn) = param_ident { + // Convert param name to camelCase - keep existing but ensure camelCase for TS + // We'll convert snake_case param names to camelCase + let ts_param_name = snake_to_camel(&pn); + param_names.push(ts_param_name.clone()); + param_defs.push(format!("{}: {}", ts_param_name, ts_type)); + } + } + } + } + + // Return type + let (return_ts_promise, return_imports) = get_return_ts(&input_fn.sig.output); + for r in return_imports { + import_types.insert(r); + } + + // Build TypeScript code string + let mut ts_lines: Vec = Vec::new(); + + ts_lines.push(r#"import { invoke } from "@tauri-apps/api/core""#.to_string()); + + if !import_types.is_empty() { + if let Some(import_from_str) = import_from.clone() { + let types_joined = import_types.iter().cloned().collect::>().join(", "); + ts_lines.push(format!( + "import {{ {} }} from \"{}\"", + types_joined, import_from_str + )); + } else { + // If no import_from provided, still import types from local path? We'll skip if not provided. + } + } + + // function signature + let params_sig = param_defs.join(", "); + let params_pass = if param_names.is_empty() { + "".to_string() + } else { + // Build object like { majorVersion, imageType } + format!("{}", param_names.join(", ")) + }; + + // Determine return generic for invoke: need the raw type (not Promise<...>) + let invoke_generic = + if return_ts_promise.starts_with("Promise<") && return_ts_promise.ends_with('>') { + &return_ts_promise["Promise<".len()..return_ts_promise.len() - 1] + } else { + "any" + }; + + let invoke_line = if param_names.is_empty() { + format!(" return invoke<{}>(\"{}\")", invoke_generic, fn_name) + } else { + format!( + " return invoke<{}>(\"{}\", {{ {} }})", + invoke_generic, fn_name, params_pass + ) + }; + + ts_lines.push(format!( + "export async function {}({}): {} {{", + ts_fn_name, params_sig, return_ts_promise + )); + ts_lines.push(invoke_line); + ts_lines.push("}".to_string()); + + let ts_contents = ts_lines.join("\n") + "\n"; + + // Prepare test function name + let test_fn_name = Ident::new( + &format!("tauri_export_bindings_{}", fn_name), + fn_name_ident.span(), + ); + + // Generate code for test function that writes the TS string to file + let export_to_literal = match export_to { + Some(ref s) => s.clone(), + None => String::new(), + }; + + // Build tokens + let original_fn = &input_fn; + let ts_string_literal = ts_contents.clone(); + + let write_stmt = if export_to_literal.is_empty() { + // No-op: don't write + // quote! { + // // No export_to provided; skipping file write. + // } + panic!("No export_to provided") + } else { + // We'll append to the file to avoid overwriting existing bindings from other macros. + // Use create(true).append(true) + let path = export_to_literal.clone(); + let ts_lit = syn::LitStr::new(&ts_string_literal, proc_macro2::Span::call_site()); + quote! { + { + // Ensure parent directories exist if possible (best-effort) + let path = std::path::Path::new(#path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Append generated bindings to file + match OpenOptions::new().create(true).append(true).open(path) { + Ok(mut f) => { + let _ = f.write_all(#ts_lit.as_bytes()); + println!("Successfully wrote to {}", path.display()); + } + Err(e) => { + eprintln!("dropout::api binding write failed: {}", e); + } + } + } + } + }; + + let gen = quote! { + #original_fn + + #[cfg(test)] + mod __dropout_export_tests { + use super::*; + use std::fs::OpenOptions; + use std::io::Write; + + #[test] + fn #test_fn_name() { + // Generated TypeScript bindings for function: #fn_name + #write_stmt + } + } + }; + + gen.into() +} diff --git a/package.json b/package.json index 4d1093b..800b455 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "description": "Dropout, the next-generation Minecraft game launcher", "scripts": { + "generate": "cargo test export_bindings && biome check packages/ui-new/src/types/bindings --fix", "bump-tauri": "tsx scripts/bump-tauri.ts", "prepare": "prek install" }, diff --git a/packages/ui-new/.gitignore b/packages/ui-new/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/ui-new/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/ui-new/components.json b/packages/ui-new/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/packages/ui-new/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/packages/ui-new/index.html b/packages/ui-new/index.html new file mode 100644 index 0000000..5191e6f --- /dev/null +++ b/packages/ui-new/index.html @@ -0,0 +1,13 @@ + + + + + + + Dropout Launcher + + +
+ + + diff --git a/packages/ui-new/package.json b/packages/ui-new/package.json new file mode 100644 index 0000000..706c12b --- /dev/null +++ b/packages/ui-new/package.json @@ -0,0 +1,50 @@ +{ + "name": "@dropout/ui-new", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "biome check .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@tauri-apps/api": "^2.9.1", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", + "@tauri-apps/plugin-shell": "^2.3.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "marked": "^17.0.1", + "next-themes": "^0.4.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router": "^7.12.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zustand": "^5.0.10" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@^7" + } +} diff --git a/packages/ui-new/public/icon.svg b/packages/ui-new/public/icon.svg new file mode 100644 index 0000000..0baf00f --- /dev/null +++ b/packages/ui-new/public/icon.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui-new/src/components/bottom-bar.tsx b/packages/ui-new/src/components/bottom-bar.tsx new file mode 100644 index 0000000..a0c2c00 --- /dev/null +++ b/packages/ui-new/src/components/bottom-bar.tsx @@ -0,0 +1,269 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { Check, ChevronDown, Play, Terminal, User } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuthStore } from "@/stores/auth-store"; +import { useGameStore } from "@/stores/game-store"; +import { useInstancesStore } from "@/stores/instances-store"; +import { useUIStore } from "@/stores/ui-store"; + +interface InstalledVersion { + id: string; + type: string; +} + +export function BottomBar() { + const authStore = useAuthStore(); + const gameStore = useGameStore(); + const instancesStore = useInstancesStore(); + const uiStore = useUIStore(); + + const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false); + const [installedVersions, setInstalledVersions] = useState< + InstalledVersion[] + >([]); + const [isLoadingVersions, setIsLoadingVersions] = useState(true); + + const dropdownRef = useRef(null); + + const loadInstalledVersions = useCallback(async () => { + if (!instancesStore.activeInstanceId) { + setInstalledVersions([]); + setIsLoadingVersions(false); + return; + } + + setIsLoadingVersions(true); + try { + const versions = await invoke( + "list_installed_versions", + { instanceId: instancesStore.activeInstanceId }, + ); + + const installed = versions || []; + setInstalledVersions(installed); + + // If no version is selected but we have installed versions, select the first one + if (!gameStore.selectedVersion && installed.length > 0) { + gameStore.setSelectedVersion(installed[0].id); + } + } catch (error) { + console.error("Failed to load installed versions:", error); + } finally { + setIsLoadingVersions(false); + } + }, [ + instancesStore.activeInstanceId, + gameStore.selectedVersion, + gameStore.setSelectedVersion, + ]); + + useEffect(() => { + loadInstalledVersions(); + + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsVersionDropdownOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + // Listen for backend events that should refresh installed versions. + let unlistenDownload: UnlistenFn | null = null; + let unlistenVersionDeleted: UnlistenFn | null = null; + + (async () => { + try { + unlistenDownload = await listen("download-complete", () => { + loadInstalledVersions(); + }); + } catch (err) { + // best-effort: do not break UI if listening fails + // eslint-disable-next-line no-console + console.warn("Failed to attach download-complete listener:", err); + } + + try { + unlistenVersionDeleted = await listen("version-deleted", () => { + loadInstalledVersions(); + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn("Failed to attach version-deleted listener:", err); + } + })(); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + try { + if (unlistenDownload) unlistenDownload(); + } catch { + // ignore + } + try { + if (unlistenVersionDeleted) unlistenVersionDeleted(); + } catch { + // ignore + } + }; + }, [loadInstalledVersions]); + + const selectVersion = (id: string) => { + if (id !== "loading" && id !== "empty") { + gameStore.setSelectedVersion(id); + setIsVersionDropdownOpen(false); + } + }; + + const handleStartGame = async () => { + await gameStore.startGame( + authStore.currentAccount, + authStore.openLoginModal, + instancesStore.activeInstanceId, + uiStore.setView, + ); + }; + + const getVersionTypeColor = (type: string) => { + switch (type) { + case "release": + return "bg-emerald-500"; + case "snapshot": + return "bg-amber-500"; + case "old_beta": + return "bg-rose-500"; + case "old_alpha": + return "bg-violet-500"; + default: + return "bg-gray-500"; + } + }; + + const versionOptions = isLoadingVersions + ? [{ id: "loading", type: "loading", label: "Loading..." }] + : installedVersions.length === 0 + ? [{ id: "empty", type: "empty", label: "No versions installed" }] + : installedVersions.map((v) => ({ + ...v, + label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`, + })); + + return ( +
+
+
+ {/* Left: Instance Info */} +
+
+ + Active Instance + + + {instancesStore.activeInstance?.name || "No instance selected"} + +
+ + {/* Version Selector */} +
+ + + {/* Dropdown */} + {isVersionDropdownOpen && ( +
+
+ {versionOptions.map((option) => ( + + ))} +
+
+ )} +
+
+ + {/* Right: Action Buttons */} +
+ {/* Console Toggle */} + + + {/* User Login/Info */} + + + {/* Start Game */} + +
+
+
+
+ ); +} diff --git a/packages/ui-new/src/components/download-monitor.tsx b/packages/ui-new/src/components/download-monitor.tsx new file mode 100644 index 0000000..d67e173 --- /dev/null +++ b/packages/ui-new/src/components/download-monitor.tsx @@ -0,0 +1,61 @@ +import { X } from "lucide-react"; +import { useState } from "react"; + +export function DownloadMonitor() { + const [isVisible, setIsVisible] = useState(true); + + if (!isVisible) return null; + + return ( +
+ {/* Header */} +
+
+
+ Downloads +
+ +
+ + {/* Content */} +
+
+ {/* Download Item */} +
+
+ Minecraft 1.20.4 + 65% +
+
+
+
+
+ 142 MB / 218 MB + 2.1 MB/s • 36s remaining +
+
+ + {/* Download Item */} +
+
+ Java 17 + 100% +
+
+
+
+
Completed
+
+
+
+
+ ); +} diff --git a/packages/ui-new/src/components/game-console.tsx b/packages/ui-new/src/components/game-console.tsx new file mode 100644 index 0000000..6980c8c --- /dev/null +++ b/packages/ui-new/src/components/game-console.tsx @@ -0,0 +1,290 @@ +import { Copy, Download, Filter, Search, Trash2, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useLogsStore } from "@/stores/logs-store"; +import { useUIStore } from "@/stores/ui-store"; + +export function GameConsole() { + const uiStore = useUIStore(); + const logsStore = useLogsStore(); + + const [searchTerm, setSearchTerm] = useState(""); + const [selectedLevels, setSelectedLevels] = useState>( + new Set(["info", "warn", "error", "debug", "fatal"]), + ); + const [autoScroll, setAutoScroll] = useState(true); + const consoleEndRef = useRef(null); + const logsContainerRef = useRef(null); + + const levelColors: Record = { + info: "text-blue-400", + warn: "text-amber-400", + error: "text-red-400", + debug: "text-purple-400", + fatal: "text-rose-400", + }; + + const levelBgColors: Record = { + info: "bg-blue-400/10", + warn: "bg-amber-400/10", + error: "bg-red-400/10", + debug: "bg-purple-400/10", + fatal: "bg-rose-400/10", + }; + + // Filter logs based on search term and selected levels + const filteredLogs = logsStore.logs.filter((log) => { + const matchesSearch = + searchTerm === "" || + log.message.toLowerCase().includes(searchTerm.toLowerCase()) || + log.source.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesLevel = selectedLevels.has(log.level); + + return matchesSearch && matchesLevel; + }); + + // Auto-scroll to bottom when new logs arrive or autoScroll is enabled + useEffect(() => { + if (autoScroll && consoleEndRef.current && filteredLogs.length > 0) { + consoleEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [filteredLogs, autoScroll]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl/Cmd + K to focus search + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + // Focus search input + const searchInput = document.querySelector( + 'input[type="text"]', + ) as HTMLInputElement; + if (searchInput) searchInput.focus(); + } + // Escape to close console + if (e.key === "Escape") { + uiStore.toggleConsole(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [uiStore.toggleConsole]); + + const toggleLevel = (level: string) => { + const newLevels = new Set(selectedLevels); + if (newLevels.has(level)) { + newLevels.delete(level); + } else { + newLevels.add(level); + } + setSelectedLevels(newLevels); + }; + + const handleCopyAll = () => { + const logsText = logsStore.exportLogs(filteredLogs); + navigator.clipboard.writeText(logsText); + }; + + const handleExport = () => { + const logsText = logsStore.exportLogs(filteredLogs); + const blob = new Blob([logsText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `dropout_logs_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleClear = () => { + logsStore.clear(); + }; + + return ( + <> + {/* Header */} +
+
+

Game Console

+
+ Logs: + + {filteredLogs.length} + + / + + {logsStore.logs.length} + +
+
+ +
+ + {/* Toolbar */} +
+ {/* Search */} +
+ + setSearchTerm(e.target.value)} + placeholder="Search logs..." + className="w-full pl-10 pr-4 py-2 bg-[#3E3E42] border border-zinc-600 rounded text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + /> + {searchTerm && ( + + )} +
+ + {/* Level Filters */} +
+ {Object.entries(levelColors).map(([level, colorClass]) => ( + + ))} +
+ + {/* Actions */} +
+ + + +
+ + {/* Auto-scroll Toggle */} +
+ +
+
+ + {/* Logs Container */} +
+ {filteredLogs.length === 0 ? ( +
+
+ +

No logs match the current filters

+
+
+ ) : ( +
+ {filteredLogs.map((log) => ( +
+
+
+ {log.level.toUpperCase()} +
+
+ {log.timestamp} +
+
+ [{log.source}] +
+
{log.message}
+
+
+ ))} +
+
+ )} +
+ + {/* Footer */} +
+
+
+ Total: + {logsStore.logs.length} + | Filtered: + {filteredLogs.length} +
+
+ + Ctrl+K + + to search +
+
+
+ Updated: + + {new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + +
+
+ + ); +} diff --git a/packages/ui-new/src/components/instance-creation-modal.tsx b/packages/ui-new/src/components/instance-creation-modal.tsx new file mode 100644 index 0000000..bdc1a6f --- /dev/null +++ b/packages/ui-new/src/components/instance-creation-modal.tsx @@ -0,0 +1,566 @@ +import { invoke } from "@tauri-apps/api/core"; +import { Loader2, Search } from "lucide-react"; +import { useCallback, useEffect, useMemo, 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 { ScrollArea } from "@/components/ui/scroll-area"; +import { useGameStore } from "@/stores/game-store"; +import { useInstancesStore } from "@/stores/instances-store"; +import type { Version } from "@/types/bindings/manifest"; +import type { FabricLoaderEntry } from "../types/bindings/fabric"; +import type { ForgeVersion as ForgeVersionEntry } from "../types/bindings/forge"; +import type { Instance } from "../types/bindings/instance"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * InstanceCreationModal + * 3-step wizard: + * 1) Name + * 2) Select base Minecraft version + * 3) Optional: choose mod loader (vanilla/fabric/forge) and loader version + * + * Behavior: + * - On Create: invoke("create_instance", { name }) + * - If a base version selected: invoke("install_version", { instanceId, versionId }) + * - If Fabric selected: invoke("install_fabric", { instanceId, gameVersion, loaderVersion }) + * - If Forge selected: invoke("install_forge", { instanceId, gameVersion, forgeVersion }) + * - Reload instances via instancesStore.loadInstances() + */ +export function InstanceCreationModal({ open, onOpenChange }: Props) { + const gameStore = useGameStore(); + const instancesStore = useInstancesStore(); + + // Steps: 1 = name, 2 = version, 3 = mod loader + const [step, setStep] = useState(1); + + // Step 1 + const [instanceName, setInstanceName] = useState(""); + + // Step 2 + const [versionSearch, setVersionSearch] = useState(""); + const [versionFilter, setVersionFilter] = useState< + "all" | "release" | "snapshot" + >("release"); + const [selectedVersionUI, setSelectedVersionUI] = useState( + null, + ); + + // Step 3 + const [modLoaderType, setModLoaderType] = useState< + "vanilla" | "fabric" | "forge" + >("vanilla"); + const [fabricLoaders, setFabricLoaders] = useState([]); + const [forgeVersions, setForgeVersions] = useState([]); + const [selectedFabricLoader, setSelectedFabricLoader] = useState(""); + const [selectedForgeLoader, setSelectedForgeLoader] = useState(""); + const [loadingLoaders, setLoadingLoaders] = useState(false); + + const loadModLoaders = useCallback(async () => { + if (!selectedVersionUI) return; + setLoadingLoaders(true); + setFabricLoaders([]); + setForgeVersions([]); + try { + if (modLoaderType === "fabric") { + const loaders = await invoke( + "get_fabric_loaders_for_version", + { + gameVersion: selectedVersionUI.id, + }, + ); + setFabricLoaders(loaders || []); + if (loaders && loaders.length > 0) { + setSelectedFabricLoader(loaders[0].loader.version); + } else { + setSelectedFabricLoader(""); + } + } else if (modLoaderType === "forge") { + const versions = await invoke( + "get_forge_versions_for_game", + { + gameVersion: selectedVersionUI.id, + }, + ); + setForgeVersions(versions || []); + if (versions && versions.length > 0) { + // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here. + setSelectedForgeLoader(versions[0].version); + } else { + setSelectedForgeLoader(""); + } + } + } catch (e) { + console.error("Failed to load mod loaders:", e); + toast.error("Failed to fetch mod loader versions"); + } finally { + setLoadingLoaders(false); + } + }, [modLoaderType, selectedVersionUI]); + + // When entering step 3 and a base version exists, fetch loaders if needed + useEffect(() => { + if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) { + loadModLoaders(); + } + }, [step, modLoaderType, selectedVersionUI, loadModLoaders]); + + // Creating state + const [creating, setCreating] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + // Derived filtered versions + const filteredVersions = useMemo(() => { + const all = gameStore.versions || []; + let list = all.slice(); + if (versionFilter !== "all") { + list = list.filter((v) => v.type === versionFilter); + } + if (versionSearch.trim()) { + const q = versionSearch.trim().toLowerCase().replace(/。/g, "."); + list = list.filter((v) => v.id.toLowerCase().includes(q)); + } + return list; + }, [gameStore.versions, versionFilter, versionSearch]); + + // Reset when opened/closed + useEffect(() => { + if (open) { + // ensure versions are loaded + gameStore.loadVersions(); + setStep(1); + setInstanceName(""); + setVersionSearch(""); + setVersionFilter("release"); + setSelectedVersionUI(null); + setModLoaderType("vanilla"); + setFabricLoaders([]); + setForgeVersions([]); + setSelectedFabricLoader(""); + setSelectedForgeLoader(""); + setErrorMessage(""); + setCreating(false); + } + }, [open, gameStore.loadVersions]); + + function validateStep1(): boolean { + if (!instanceName.trim()) { + setErrorMessage("Please enter an instance name"); + return false; + } + setErrorMessage(""); + return true; + } + + function validateStep2(): boolean { + if (!selectedVersionUI) { + setErrorMessage("Please select a Minecraft version"); + return false; + } + setErrorMessage(""); + return true; + } + + async function handleNext() { + setErrorMessage(""); + if (step === 1) { + if (!validateStep1()) return; + setStep(2); + } else if (step === 2) { + if (!validateStep2()) return; + setStep(3); + } + } + + function handleBack() { + setErrorMessage(""); + setStep((s) => Math.max(1, s - 1)); + } + + async function handleCreate() { + if (!validateStep1() || !validateStep2()) return; + setCreating(true); + setErrorMessage(""); + + try { + // Step 1: create instance + const instance = await invoke("create_instance", { + name: instanceName.trim(), + }); + + // If selectedVersion provided, install it + if (selectedVersionUI) { + try { + await invoke("install_version", { + instanceId: instance.id, + versionId: selectedVersionUI.id, + }); + } catch (err) { + console.error("Failed to install base version:", err); + // continue - instance created but version install failed + toast.error( + `Failed to install version ${selectedVersionUI.id}: ${String(err)}`, + ); + } + } + + // If mod loader selected, install it + if (modLoaderType === "fabric" && selectedFabricLoader) { + try { + await invoke("install_fabric", { + instanceId: instance.id, + gameVersion: selectedVersionUI?.id ?? "", + loaderVersion: selectedFabricLoader, + }); + } catch (err) { + console.error("Failed to install Fabric:", err); + toast.error(`Failed to install Fabric: ${String(err)}`); + } + } else if (modLoaderType === "forge" && selectedForgeLoader) { + try { + await invoke("install_forge", { + instanceId: instance.id, + gameVersion: selectedVersionUI?.id ?? "", + installerVersion: selectedForgeLoader, + }); + } catch (err) { + console.error("Failed to install Forge:", err); + toast.error(`Failed to install Forge: ${String(err)}`); + } + } + + // Refresh instances list + await instancesStore.loadInstances(); + + toast.success("Instance created successfully"); + onOpenChange(false); + } catch (e) { + console.error("Failed to create instance:", e); + setErrorMessage(String(e)); + toast.error(`Failed to create instance: ${e}`); + } finally { + setCreating(false); + } + } + + // UI pieces + const StepIndicator = () => ( +
+
= 1 ? "bg-indigo-500" : "bg-zinc-700"}`} + /> +
= 2 ? "bg-indigo-500" : "bg-zinc-700"}`} + /> +
= 3 ? "bg-indigo-500" : "bg-zinc-700"}`} + /> +
+ ); + + return ( + + + + Create New Instance + + Multi-step wizard — create an instance and optionally install a + version or mod loader. + + + +
+
+ +
+ + {/* Step 1 - Name */} + {step === 1 && ( +
+
+ + setInstanceName(e.target.value)} + disabled={creating} + /> +
+

+ Give your instance a memorable name. +

+
+ )} + + {/* Step 2 - Version selection */} + {step === 2 && ( +
+
+
+ + setVersionSearch(e.target.value)} + placeholder="Search versions..." + className="pl-9" + /> +
+ +
+ + + +
+
+ + +
+ {gameStore.versions.length === 0 ? ( +
+ + Loading versions... +
+ ) : filteredVersions.length === 0 ? ( +
+ No matching versions found +
+ ) : ( + filteredVersions.map((v) => { + const isSelected = selectedVersionUI?.id === v.id; + return ( + + ); + }) + )} +
+
+
+ )} + + {/* Step 3 - Mod loader */} + {step === 3 && ( +
+
+
Mod Loader Type
+
+ + + +
+
+ + {modLoaderType === "fabric" && ( +
+ {loadingLoaders ? ( +
+ + Loading Fabric versions... +
+ ) : fabricLoaders.length > 0 ? ( +
+ +
+ ) : ( +

+ No Fabric loaders available for this version +

+ )} +
+ )} + + {modLoaderType === "forge" && ( +
+ {loadingLoaders ? ( +
+ + Loading Forge versions... +
+ ) : forgeVersions.length > 0 ? ( +
+ +
+ ) : ( +

+ No Forge versions available for this version +

+ )} +
+ )} +
+ )} + + {errorMessage && ( +
{errorMessage}
+ )} +
+ + +
+
+ +
+ +
+ {step > 1 && ( + + )} + + {step < 3 ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +export default InstanceCreationModal; diff --git a/packages/ui-new/src/components/instance-editor-modal.tsx b/packages/ui-new/src/components/instance-editor-modal.tsx new file mode 100644 index 0000000..012e62c --- /dev/null +++ b/packages/ui-new/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 "@/stores/instances-store"; +import { useSettingsStore } from "@/stores/settings-store"; +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 { settings } = 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)) ?? + settings.minMemory ?? + 512, + ); + setEditMemoryMax( + (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ?? + settings.maxMemory ?? + 2048, + ); + setEditJavaArgs(instance.jvmArgsOverride ?? ""); + setFileList([]); + setSelectedFileFolder("mods"); + } + }, [open, instance, settings.minMemory, settings.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("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.updateInstance(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 / Math.pow(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} + /> +
+ +
+ +