diff --git a/README.md b/README.md index cbd927fa..77ead8c2 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,63 @@ it does everything you'd expect from a launcher. Browse Modrinth and CurseForge modpacks or import Modrinth, CurseForge, and MultiMC/Prism archives (including GTNH packs in that format). Direct imports accept a local archive, Modrinth URL, or Modrinth project slug. CurseForge requires a build-time [API key](#curseforge-api). Multiple Microsoft accounts and offline accounts are supported. +### exporting modpacks + +Press `e` on an instance to open the TUI exporter; its file scan starts immediately. +Unchanged files reuse hashes from the content index; writing the archive verifies them again. +Use `j`/`k` or the arrow keys to select a row, `h`/`l` to cycle formats, and `Enter` +to edit metadata. The format row shows the archive extension; MultiMC/Prism also +shows a **Bundled** badge. + +Author defaults to the system username. Metadata unsupported by the chosen +format stays disabled, so changing formats does not resize the form. + +Once scanning finishes, open **Included files** to browse the game folder. Default pack files start +checked; worlds, logs and personal settings start unchecked. Use `h`/`l` to move +between folders and `Space` to select or deselect a file or a whole folder. `h` +stops at the root; only `Esc` returns to the form. The selection summary appears +beside the files, or above them in narrow terminals. It separates download links, +ordinary file copies (such as configs and shaders), and unmatched content. Links +are downloaded on import; file copies are stored inside the archive. Unmatched +files and their containing folders use yellow names, even when unchecked. +Press `c` to allow copying those files after checking their redistribution licences. Press `Enter` for +the final export summary, then `Enter` again to write the archive. Existing files +require one additional overwrite confirmation. The popup closes when writing +starts; progress appears in Overview and errors appear as toasts, without a +completion popup. `Esc` goes back without exporting. Symlinks, disabled files and +unsafe paths are not selectable. + +The CLI supports the same formats: + +```sh +rmcl export "My Instance" --format curseforge --output pack.zip +rmcl export "My Instance" --format mrpack --output pack.mrpack +rmcl export "My Instance" --format mmc --output prism-pack.zip +``` + +`curseforge` is the default. MultiMC/Prism exports bundle selected files and work +offline. CurseForge and Modrinth exports identify content through their APIs and +reference exact matching files. Unmatched content requires explicit approval: +`--bundle-unmatched` in the CLI or the checkbox in the TUI file browser. Only bundle +files you have permission to redistribute. Author metadata is written for +CurseForge; the other formats do not have an equivalent author field. +Modrinth exports target clients; server compatibility is not inferred from an +installed client instance. + +Exports include enabled mods, configs, scripts, resource packs, shaders and the +top-level datapacks folder. Worlds, logs and personal settings are excluded by +default. Add files or folders with repeatable `--include` arguments, for example +`--include options.txt --include saves/MyWorld`. Symlinks and non-portable paths +are rejected. The CLI refuses existing output files; the TUI replaces them only +after confirmation and successful archive creation. Output must be outside the game folder. + +For a standalone folder, use `--directory` instead of an instance name: + +```sh +rmcl export --directory ./minecraft --minecraft 1.21.1 --loader fabric \ + --loader-version 0.16.14 --format mrpack --output pack.mrpack +``` + --- ## navigation @@ -53,6 +110,11 @@ Use `Ctrl` + arrow keys to move between the Instances, Content, Accounts, Settings, and Overview panels. In Content, use plain `Left` / `Right` or `h` / `l` to switch tabs, and `Tab` to switch between installed content and discovery. +Content can be installed, updated, removed, or enabled/disabled while a game is +running. Mods normally apply on the next launch; packs and configs may reload +live, and the operating system can still reject changes to open files. Instance +deletion, renaming, world deletion/renaming, runtime changes, and whole-modpack replacement require stopping it. + ## authentication rmcl uses its own Microsoft client ID for Minecraft account authentication. diff --git a/src/cli/export.rs b/src/cli/export.rs index 6bd9c078..20137ce2 100644 --- a/src/cli/export.rs +++ b/src/cli/export.rs @@ -1,11 +1,20 @@ use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command}; use crate::instance::InstanceManager; -use crate::instance::export::{ExportOptions, export_curseforge}; +use crate::instance::export::{ + ExportOptions, PackFormat, prepare_export, prepare_instance_export, validate_output, + write_export, +}; pub fn command() -> Command { Command::new("export") - .about("Export a CurseForge-compatible modpack ZIP") + .about("Export a CurseForge, Modrinth, or MultiMC/Prism modpack") + .arg( + Arg::new("format") + .long("format") + .value_parser(["curseforge", "mrpack", "mmc"]) + .default_value("curseforge"), + ) .arg(Arg::new("instance").help("Instance name")) .arg( Arg::new("directory") @@ -48,12 +57,12 @@ pub fn command() -> Command { Arg::new("bundle-unmatched") .long("bundle-unmatched") .action(ArgAction::SetTrue) - .help("Bundle custom/unmatched mods in overrides"), + .help("Allow bundling custom/unmatched content (check redistribution permissions)"), ) } pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box> { - let mut lock = None; + let mut instance_manager = None; let (root, name, game_version, loader, loader_version) = if let Some(directory) = matches.get_one::("directory") { @@ -83,13 +92,11 @@ pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box Result<(), Box PackFormat::Mrpack, + "mmc" => PackFormat::Mmc, + _ => PackFormat::CurseForge, + }, name: matches.get_one::("name").cloned().unwrap_or(name), version: super::utils::required_arg(matches, "version")?.to_owned(), author: super::utils::required_arg(matches, "author")?.to_owned(), @@ -113,14 +125,45 @@ pub async fn handle_export(matches: &ArgMatches) -> Result<(), Box>() + .join("\n") + ); + } println!("Exported '{}' to {}", options.name, output.display()); + println!( + "{} referenced files, {} bundled files", + report.referenced_count, report.bundled_count + ); Ok(()) } diff --git a/src/cli/tests/arguments.rs b/src/cli/tests/arguments.rs index 8e854d4c..62522cea 100644 --- a/src/cli/tests/arguments.rs +++ b/src/cli/tests/arguments.rs @@ -17,3 +17,29 @@ fn parses_instance_list_subcommand() { "instance list should resolve to the list subcommand" ); } + +#[test] +fn exports_accept_each_format_and_preserve_curseforge_default() { + for format in [None, Some("curseforge"), Some("mrpack"), Some("mmc")] { + let mut args = vec!["rmcl", "export", "Example", "--output", "pack.zip"]; + if let Some(format) = format { + args.extend(["--format", format]); + } + let matches = build_command().try_get_matches_from(args).unwrap(); + assert_eq!( + matches + .subcommand_matches("export") + .unwrap() + .get_one::("format") + .map(String::as_str), + Some(format.unwrap_or("curseforge")) + ); + } + assert!( + build_command() + .try_get_matches_from([ + "rmcl", "export", "Example", "--output", "pack.zip", "--format", "unknown" + ]) + .is_err() + ); +} diff --git a/src/instance/content/local.rs b/src/instance/content/local.rs index 89265568..0cc30deb 100644 --- a/src/instance/content/local.rs +++ b/src/instance/content/local.rs @@ -172,6 +172,15 @@ pub(crate) fn relative_path(minecraft_dir: &Path, path: &Path) -> io::Result io::Result { + if relative.starts_with("saves") && relative.components().count() <= 2 { + ContentLock::acquire_stopped(manifest_path) + } else { + ContentLock::acquire(manifest_path) + } + .map_err(io::Error::other) +} + pub(crate) fn validate_record(minecraft_dir: &Path, record: &ContentFileRecord) -> io::Result<()> { let path = minecraft_dir.join(&record.relative_path); relative_path(minecraft_dir, &path)?; @@ -282,13 +291,13 @@ pub(crate) fn toggle(entry: &ContentEntry) -> io::Result> { } else { minecraft_dir.join(".rmcl-content.json") }; - let lock = ContentLock::acquire(&manifest_path).map_err(io::Error::other)?; + let relative = relative_path(minecraft_dir, &entry.path)?; + let lock = mutation_lock(&manifest_path, &relative)?; let mut manifest = if instance_minecraft.is_some() { lock.load().map_err(io::Error::other)? } else { ContentManifest::default() }; - let relative = relative_path(minecraft_dir, &entry.path)?; let new_relative = relative_path(minecraft_dir, &new_path)?; require_absent(&new_path)?; if manifest.record(&new_relative).is_some() { @@ -386,10 +395,10 @@ pub(crate) fn remove( expected: &[ContentFileRecord], orphan_only: bool, ) -> io::Result<(ContentManifest, Vec)> { - let lock = ContentLock::acquire(manifest_path).map_err(io::Error::other)?; + let relative = relative_path(minecraft_dir, path)?; + let lock = mutation_lock(manifest_path, &relative)?; let mut manifest = lock.load().map_err(io::Error::other)?; let previous = manifest.clone(); - let relative = relative_path(minecraft_dir, path)?; if manifest .files .iter() diff --git a/src/instance/content/manifest.rs b/src/instance/content/manifest.rs index 7bff5530..30f460e8 100644 --- a/src/instance/content/manifest.rs +++ b/src/instance/content/manifest.rs @@ -20,6 +20,17 @@ pub(crate) struct ContentLock { impl ContentLock { pub(crate) fn acquire(path: &Path) -> Result { + Self::acquire_with_instance_guard(path, false) + } + + pub(crate) fn acquire_stopped(path: &Path) -> Result { + Self::acquire_with_instance_guard(path, true) + } + + fn acquire_with_instance_guard( + path: &Path, + require_stopped: bool, + ) -> Result { let parent = path.parent().ok_or_else(|| { ManifestError::InvalidPath(format!("{} has no parent", path.display())) })?; @@ -43,17 +54,37 @@ impl ContentLock { .ok_or_else(|| { ManifestError::InvalidPath(format!("{} has no instance name", path.display())) })?; - Some(crate::instance::runtime::lock_instance(root, name).map_err( - |error| match error { - crate::instance::manager::InstanceError::InstanceRunning(_) => { - ManifestError::Busy(path.to_owned()) - } - error => ManifestError::Io(std::io::Error::other(error)), - }, - )?) + let lock = if require_stopped { + crate::instance::runtime::lock_instance(root, name) + } else { + crate::instance::runtime::lock_content(root, name) + }; + Some(lock.map_err(|error| match error { + crate::instance::manager::InstanceError::InstanceRunning(_) => { + ManifestError::Busy(path.to_owned()) + } + error => ManifestError::Io(std::io::Error::other(error)), + })?) } else { None }; + Self::acquire_file(path, instance_lock) + } + + pub(crate) fn acquire_with_held_instance_lock( + path: &Path, + _guard: &crate::instance::runtime::InstanceLock, + ) -> Result { + Self::acquire_file(path, None) + } + + fn acquire_file( + path: &Path, + instance_lock: Option, + ) -> Result { + let parent = path.parent().ok_or_else(|| { + ManifestError::InvalidPath(format!("{} has no parent", path.display())) + })?; std::fs::create_dir_all(parent)?; let file = OpenOptions::new() .read(true) @@ -468,6 +499,14 @@ pub enum ManifestError { } pub fn fingerprint(path: &Path) -> Result { + fingerprint_with_check(path, || Ok(())) +} + +pub(crate) fn fingerprint_with_check( + path: &Path, + check: impl Fn() -> std::io::Result<()>, +) -> Result { + check()?; let metadata = std::fs::metadata(path)?; let modified_ns = metadata .modified() @@ -481,6 +520,7 @@ pub fn fingerprint(path: &Path) -> Result { let mut curseforge_len = 0u32; let mut buffer = [0u8; 64 * 1024]; loop { + check()?; let read = file.read(&mut buffer)?; if read == 0 { break; @@ -500,7 +540,7 @@ pub fn fingerprint(path: &Path) -> Result { ("sha512".to_owned(), format!("{:x}", sha512.finalize())), ( "curseforge".to_owned(), - curseforge_fingerprint(&mut file, curseforge_len)?.to_string(), + curseforge_fingerprint(&mut file, curseforge_len, &check)?.to_string(), ), ]); Ok(FileFingerprint { @@ -510,13 +550,18 @@ pub fn fingerprint(path: &Path) -> Result { }) } -fn curseforge_fingerprint(reader: &mut impl Read, length: u32) -> std::io::Result { +fn curseforge_fingerprint( + reader: &mut impl Read, + length: u32, + check: &impl Fn() -> std::io::Result<()>, +) -> std::io::Result { const M: u32 = 0x5bd1e995; let mut hash = 1 ^ length; let mut block = [0u8; 4]; let mut block_len = 0; let mut buffer = [0u8; 64 * 1024]; loop { + check()?; let read = reader.read(&mut buffer)?; if read == 0 { break; diff --git a/src/instance/export.rs b/src/instance/export.rs index e0573243..461d3d2c 100644 --- a/src/instance/export.rs +++ b/src/instance/export.rs @@ -1,14 +1,28 @@ -use std::collections::{BTreeMap, BTreeSet}; +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +mod curseforge; +mod mmc; +mod mrpack; + +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::sync::Arc; -use super::ModLoader; -use super::content::manifest::fingerprint; -use super::import::curseforge::{Manifest, ManifestFile, ManifestLoader, Minecraft}; +use super::content::local::current_fingerprint; +use super::content::manifest::{ + ContentLock, ContentManifest, FileFingerprint, fingerprint_metadata, fingerprint_with_check, +}; +pub use super::pack::PackFormat; +use super::{InstanceConfig, InstanceManager, ModLoader}; +use crate::feedback::progress::{ProgressTask, ProgressTaskHandle}; -type ExportResult = Result>; +pub type ExportResult = Result>; +#[derive(Debug, Clone)] pub struct ExportOptions { + pub format: PackFormat, pub name: String, pub version: String, pub author: String, @@ -19,6 +33,315 @@ pub struct ExportOptions { pub bundle_unmatched: bool, } +impl ExportOptions { + pub fn for_instance(instance: &InstanceConfig) -> Self { + Self { + format: PackFormat::CurseForge, + name: instance.name.clone(), + version: "1.0.0".to_owned(), + author: String::new(), + game_version: instance.game_version.clone(), + loader: instance.loader, + loader_version: instance.loader_version.clone(), + include: Vec::new(), + bundle_unmatched: false, + } + } +} + +pub struct ExportPlan { + pub options: ExportOptions, + pub unmatched: Vec, + root: PathBuf, + files: BTreeMap, + archive: ArchiveContents, + manifest_path: Option, + _lock: Option>, + explicit_selection: bool, +} + +impl ExportPlan { + pub fn file_count(&self) -> usize { + self.files.len() + } + + pub fn referenced_count(&self) -> usize { + self.archive.referenced.len() + } + + pub fn referenced_paths(&self) -> &BTreeSet { + &self.archive.referenced + } +} + +pub struct ExportScan { + config: InstanceConfig, + root: PathBuf, + files: BTreeMap, + manifest_path: PathBuf, + pub defaults: BTreeSet, + pub skipped: Vec, + lock: Arc, +} + +pub enum ExportReferences { + CurseForge(BTreeMap), + Mrpack(HashMap), + Bundled, +} + +impl ExportReferences { + pub fn empty(format: PackFormat) -> Self { + match format { + PackFormat::CurseForge => Self::CurseForge(BTreeMap::new()), + PackFormat::Mrpack => Self::Mrpack(HashMap::new()), + PackFormat::Mmc => Self::Bundled, + } + } +} + +impl ExportScan { + pub fn paths(&self) -> impl Iterator { + self.files.keys() + } + + pub fn needs_references(&self, format: PackFormat, selected: &BTreeSet) -> bool { + selected + .iter() + .any(|path| reference_candidate(path, format)) + } + + pub fn unmatched( + &self, + format: PackFormat, + selected: &BTreeSet, + referenced: &BTreeSet, + ) -> Vec { + selected + .iter() + .filter(|path| { + format != PackFormat::Mmc + && content_path(path, format) + && !referenced.contains(*path) + }) + .cloned() + .collect() + } + + pub fn plan( + &self, + options: &ExportOptions, + selected: &BTreeSet, + references: &ExportReferences, + ) -> ExportResult { + let mut options = options.clone(); + options.game_version = self.config.game_version.clone(); + options.loader = self.config.loader; + options.loader_version = self.config.loader_version.clone(); + validate_options(&options)?; + let files = selected + .iter() + .map(|path| { + self.files + .get(path) + .cloned() + .map(|file| (path.clone(), file)) + .ok_or_else(|| format!("File was not scanned: {}", path.display()).into()) + }) + .collect::>>()?; + let mut plan = ExportPlan { + root: self.root.clone(), + files, + options, + unmatched: Vec::new(), + manifest_path: Some(self.manifest_path.clone()), + _lock: Some(self.lock.clone()), + explicit_selection: true, + archive: ArchiveContents { + manifests: Vec::new(), + referenced: BTreeSet::new(), + prefix: "overrides", + }, + }; + plan.archive = match (plan.options.format, references) { + (PackFormat::CurseForge, ExportReferences::CurseForge(files)) => { + curseforge::manifest(&plan, files)? + } + (PackFormat::Mrpack, ExportReferences::Mrpack(files)) => { + mrpack::manifest(&plan, files)? + } + (PackFormat::Mmc, ExportReferences::Bundled) => mmc::prepare(&plan.options)?, + _ => return Err("Content identification does not match the export format".into()), + }; + plan.unmatched = self.unmatched(plan.options.format, selected, &plan.archive.referenced); + Ok(plan) + } +} + +pub async fn scan_instance_export( + manager: &InstanceManager, + name: &str, +) -> ExportResult { + let lock = Arc::new(super::runtime::lock_instance(&manager.instances_dir, name)?); + let config = manager.load_one(name)?; + let paths = crate::storage::InstancePaths::new(manager.instances_dir.join(name)); + let root = paths.minecraft(); + // Dropping the awaiting future closes this channel, including when the popup aborts it. + let (lifetime, receiver) = tokio::sync::oneshot::channel::<()>(); + let result = tokio::task::spawn_blocking(move || { + let check = || -> std::io::Result<()> { + if lifetime.is_closed() { + return Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "Export scan cancelled", + )); + } + Ok(()) + }; + check()?; + let progress = ProgressTask::start("Scanning export files"); + let root = root.canonicalize()?; + let manifest_path = paths.content_manifest(); + let cached = load_fingerprints(&manifest_path); + let mut files = BTreeMap::new(); + let mut skipped = Vec::new(); + scan_directory( + &root, + Path::new(""), + &mut files, + &mut skipped, + &cached, + &progress, + &check, + )?; + check()?; + let defaults = files + .keys() + .filter(|path| { + DEFAULT_PATHS + .iter() + .any(|directory| path.starts_with(directory)) + }) + .cloned() + .collect(); + Ok(ExportScan { + config, + root, + files, + manifest_path, + defaults, + skipped, + lock, + }) + }) + .await?; + drop(receiver); + result +} + +fn scan_directory( + root: &Path, + directory: &Path, + files: &mut BTreeMap, + skipped: &mut Vec, + cached: &ContentManifest, + progress: &ProgressTask, + check: &impl Fn() -> std::io::Result<()>, +) -> ExportResult<()> { + check()?; + for entry in std::fs::read_dir(root.join(directory))? { + check()?; + let entry = entry?; + let relative = directory.join(entry.file_name()); + let metadata = std::fs::symlink_metadata(entry.path())?; + if !portable_export_path(&relative) + || metadata.file_type().is_symlink() + || !(metadata.is_dir() || metadata.is_file()) + || metadata.is_file() && relative.to_string_lossy().ends_with(".disabled") + { + skipped.push(relative); + continue; + } + if metadata.is_dir() { + scan_directory(root, &relative, files, skipped, cached, progress, check)?; + } else { + progress.set_sub_action(relative.display().to_string()); + let full = root.join(&relative); + let hash = if reference_candidate(&relative, PackFormat::Mrpack) { + export_fingerprint( + &full, + cached.record(&relative).map(|record| &record.fingerprint), + check, + )? + } else { + fingerprint_metadata(&full)? + }; + files.insert(relative, hash); + } + } + Ok(()) +} + +fn load_fingerprints(path: &Path) -> ContentManifest { + ContentManifest::load(path).unwrap_or_else(|error| { + tracing::warn!( + "Could not read cached export fingerprints '{}': {error}", + path.display() + ); + ContentManifest::default() + }) +} + +fn export_fingerprint( + path: &Path, + cached: Option<&FileFingerprint>, + check: impl Fn() -> std::io::Result<()>, +) -> std::io::Result { + check()?; + let metadata = fingerprint_metadata(path)?; + if let Some(cached) = cached + && cached.size == metadata.size + && cached.modified_ns == metadata.modified_ns + && ["sha1", "sha512", "curseforge"] + .iter() + .all(|algorithm| cached.hash(algorithm).is_some()) + { + // Browsing can reuse the index; writing still rehashes every referenced file. + return Ok(cached.clone()); + } + fingerprint_with_check(path, check) +} + +pub async fn resolve_export_files( + scan: &ExportScan, + format: PackFormat, +) -> ExportResult { + let progress = ProgressTask::start(format!("Identifying {} export files", format.label())); + match format { + PackFormat::CurseForge => Ok(ExportReferences::CurseForge( + curseforge::resolve(&scan.files, &progress).await?, + )), + PackFormat::Mrpack => Ok(ExportReferences::Mrpack( + mrpack::resolve(&scan.files, &progress).await?, + )), + PackFormat::Mmc => Ok(ExportReferences::Bundled), + } +} + +#[derive(Debug)] +pub struct ExportReport { + pub output: PathBuf, + pub referenced_count: usize, + pub bundled_count: usize, + pub unmatched: Vec, +} + +struct ArchiveContents { + manifests: Vec<(&'static str, Vec)>, + referenced: BTreeSet, + prefix: &'static str, +} + const DEFAULT_PATHS: &[&str] = &[ "mods", "config", @@ -30,11 +353,15 @@ const DEFAULT_PATHS: &[&str] = &[ "datapacks", ]; -fn collect(root: &Path, relative: &Path, files: &mut BTreeSet) -> ExportResult<()> { - if !relative.to_str().is_some_and(|path| { +fn portable_export_path(relative: &Path) -> bool { + relative.to_str().is_some_and(|path| { super::import::portable_pack_path(path) .is_ok_and(|normalized| cfg!(windows) || normalized == path) - }) { + }) +} + +fn collect(root: &Path, relative: &Path, files: &mut BTreeSet) -> ExportResult<()> { + if !portable_export_path(relative) { return Err(format!("Invalid export path: {}", relative.display()).into()); } let mut ancestor = root.to_path_buf(); @@ -78,163 +405,217 @@ fn selected_files(root: &Path, include: &[PathBuf]) -> ExportResult ExportResult<()> { +fn content_path(path: &Path, format: PackFormat) -> bool { + path.starts_with("mods") + || format == PackFormat::Mrpack + && ["resourcepacks", "shaderpacks", "datapacks"] + .iter() + .any(|directory| path.starts_with(directory)) +} + +fn reference_candidate(path: &Path, format: PackFormat) -> bool { + if format == PackFormat::Mmc || !content_path(path, format) { + return false; + } + let extension = if path.starts_with("mods") { + "jar" + } else { + "zip" + }; + path.parent() + .is_some_and(|parent| parent.components().count() == 1) + && path + .extension() + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) +} + +fn inventory( + root: PathBuf, + options: ExportOptions, + lock: Option>, + progress: &ProgressTaskHandle, +) -> ExportResult { + validate_options(&options)?; + let root = root.canonicalize()?; + if !root.is_dir() { + return Err("Game folder must be a directory".into()); + } + let selected = selected_files(&root, &options.include)?; + let manifest_path = root + .parent() + .map(|parent| crate::storage::InstancePaths::new(parent).content_manifest()); + let cached = manifest_path + .as_deref() + .map(load_fingerprints) + .unwrap_or_default(); + let mut files = BTreeMap::new(); + for (index, path) in selected.iter().enumerate() { + progress.set_sub_action(path.display().to_string()); + progress.set_progress(index as u64, selected.len() as u64); + let full = root.join(path); + let hash = if reference_candidate(path, options.format) { + export_fingerprint( + &full, + cached.record(path).map(|record| &record.fingerprint), + || Ok(()), + )? + } else { + fingerprint_metadata(&full)? + }; + files.insert(path.clone(), hash); + } + Ok(ExportPlan { + options, + root, + files, + unmatched: Vec::new(), + manifest_path, + _lock: lock, + explicit_selection: false, + archive: ArchiveContents { + manifests: Vec::new(), + referenced: BTreeSet::new(), + prefix: "overrides", + }, + }) +} + +fn validate_options(options: &ExportOptions) -> ExportResult<()> { for (label, value) in [ ("Pack name", &options.name), ("Pack version", &options.version), ("Minecraft version", &options.game_version), ] { - if value.trim().is_empty() { - return Err(format!("{label} must not be empty").into()); + if value.trim().is_empty() || value.chars().any(char::is_control) { + return Err(format!("{label} must not be empty or contain control characters").into()); } } if options.loader != ModLoader::Vanilla && options .loader_version .as_ref() - .is_none_or(|version| version.trim().is_empty()) + .is_none_or(|v| v.trim().is_empty()) { return Err("A loader version is required for a modded pack".into()); } - let root = root.canonicalize()?; - if !root.is_dir() { - return Err("Game folder must be a directory".into()); + Ok(()) +} + +pub async fn prepare_export(root: &Path, options: &ExportOptions) -> ExportResult { + prepare(root.to_owned(), options.clone(), None).await +} + +pub async fn prepare_instance_export( + manager: &InstanceManager, + name: &str, + options: &ExportOptions, +) -> ExportResult { + let lock = Arc::new(super::runtime::lock_instance(&manager.instances_dir, name)?); + let config = manager.load_one(name)?; + let mut options = options.clone(); + options.game_version = config.game_version; + options.loader = config.loader; + options.loader_version = config.loader_version; + let root = crate::storage::InstancePaths::new(manager.instances_dir.join(name)).minecraft(); + prepare(root, options, Some(lock)).await +} + +async fn prepare( + root: PathBuf, + options: ExportOptions, + lock: Option>, +) -> ExportResult { + let progress = ProgressTask::start("Scanning export files"); + let handle = progress.handle(); + let mut plan = + tokio::task::spawn_blocking(move || inventory(root, options, lock, &handle)).await??; + progress.set_action(format!("Preparing {} export", plan.options.format.label())); + plan.archive = match plan.options.format { + PackFormat::CurseForge => curseforge::prepare(&plan, &progress).await?, + PackFormat::Mrpack => mrpack::prepare(&plan, &progress).await?, + PackFormat::Mmc => mmc::prepare(&plan.options)?, + }; + if plan.options.format != PackFormat::Mmc { + plan.unmatched = plan + .files + .keys() + .filter(|path| { + content_path(path, plan.options.format) && !plan.archive.referenced.contains(*path) + }) + .cloned() + .collect(); + } + Ok(plan) +} + +pub fn validate_output(root: &Path, output: &Path) -> ExportResult<()> { + validate_output_path(root, output)?; + if output.try_exists()? { + return Err(format!("Output already exists: {}", output.display()).into()); } + Ok(()) +} + +pub fn validate_output_path(root: &Path, output: &Path) -> ExportResult<()> { let parent = output .parent() .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); - if parent.canonicalize()?.starts_with(&root) { - return Err("Export ZIP must be outside the game folder".into()); - } - if output.exists() { - return Err(format!("Output already exists: {}", output.display()).into()); - } - let files = selected_files(&root, &options.include)?; - let mut hashes = BTreeMap::new(); - let mut sha1_hashes = BTreeMap::new(); - for path in &files { - if path.parent() == Some(Path::new("mods")) - && path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("jar")) - { - let hash = fingerprint(&root.join(path))?; - let value = hash - .hash("curseforge") - .ok_or("Missing CurseForge fingerprint")? - .parse::()?; - sha1_hashes.insert( - path.clone(), - hash.hash("sha1") - .ok_or("Missing SHA-1 fingerprint")? - .to_owned(), - ); - hashes.insert(path.clone(), value); - } + if parent.canonicalize()?.starts_with(root.canonicalize()?) { + return Err("Export archive must be outside the game folder".into()); } - let mut matches = BTreeMap::new(); - if !hashes.is_empty() { - let key = - crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?; - let client = crate::net::HttpClient::new(); - let values = hashes - .values() - .copied() - .collect::>() - .into_iter() - .collect::>(); - for chunk in values.chunks(100) { - for (hash, version) in - crate::net::curseforge::resolve_fingerprint_versions(&client, key, chunk).await? - { - matches.insert(hash, version); - } - } - } - let mut resolved = BTreeMap::new(); - for (path, hash) in &hashes { - if let Some(version) = matches.get(hash) - && version.files.iter().any(|artifact| { - artifact - .hashes - .get("sha1") - .is_some_and(|expected| expected.eq_ignore_ascii_case(&sha1_hashes[path])) - }) - { - resolved.insert( - path.clone(), - (version.project_id.parse()?, version.id.parse()?), - ); + match std::fs::symlink_metadata(output) { + Ok(metadata) if !metadata.is_file() => { + return Err(format!("Output is not a regular file: {}", output.display()).into()); } + Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.into()), + _ => {} } - write_archive(&root, output, options, &files, &resolved) + Ok(()) +} + +pub async fn write_export(plan: ExportPlan, output: &Path) -> ExportResult { + write_export_with_overwrite(plan, output, false).await } -fn write_archive( +pub async fn write_export_with_overwrite( + plan: ExportPlan, + output: &Path, + overwrite: bool, +) -> ExportResult { + let output = output.to_owned(); + tokio::task::spawn_blocking(move || write_archive(plan, &output, overwrite)).await? +} + +pub async fn export_pack( root: &Path, output: &Path, options: &ExportOptions, - files: &BTreeSet, - resolved: &BTreeMap, -) -> ExportResult<()> { - let unmatched = files - .iter() - .filter(|path| path.starts_with("mods") && !resolved.contains_key(*path)) - .collect::>(); - if !unmatched.is_empty() { - let names = unmatched - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join("\n"); - if !options.bundle_unmatched { - return Err(format!("Mods without CurseForge matches:\n{names}\nUse --bundle-unmatched to include these files in overrides/mods.").into()); - } - eprintln!("Bundling custom/unmatched mods:\n{names}"); - } - let loaders = match options.loader { - ModLoader::Vanilla => Vec::new(), - loader => vec![ManifestLoader { - id: format!( - "{}-{}", - loader.to_string().to_lowercase(), - options - .loader_version - .as_deref() - .ok_or("Missing loader version")? - ), - primary: true, - }], - }; - let references = resolved - .values() - .copied() - .collect::>() - .into_iter() - .map(|(project_id, file_id)| ManifestFile { - project_id, - file_id, - required: true, - }) - .collect::>(); - let manifest = Manifest { - minecraft: Minecraft { - version: options.game_version.clone(), - mod_loaders: loaders, - }, - manifest_type: "minecraftModpack".to_owned(), - manifest_version: 1, - name: options.name.clone(), - version: options.version.clone(), - author: options.author.clone(), - files: references, - overrides: "overrides".to_owned(), - }; +) -> ExportResult { + validate_output(root, output)?; + write_export(prepare_export(root, options).await?, output).await +} + +fn write_archive(plan: ExportPlan, output: &Path, overwrite: bool) -> ExportResult { + if overwrite { + validate_output_path(&plan.root, output)?; + } else { + validate_output(&plan.root, output)?; + } + if !plan.options.bundle_unmatched && !plan.unmatched.is_empty() { + return Err(format!( + "Content without {} matches requires explicit approval to bundle:\n{}", + plan.options.format.label(), + plan.unmatched + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n") + ) + .into()); + } + let progress = ProgressTask::start("Writing export archive"); + validate_inventory(&plan)?; let parent = output .parent() .filter(|path| !path.as_os_str().is_empty()) @@ -244,23 +625,83 @@ fn write_archive( let mut zip = zip::ZipWriter::new(temporary.as_file_mut()); let zip_options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); - zip.start_file("manifest.json", zip_options)?; - zip.write_all(&serde_json::to_vec_pretty(&manifest)?)?; - for path in files { - if resolved.contains_key(path) { + for (name, bytes) in &plan.archive.manifests { + zip.start_file(*name, zip_options)?; + zip.write_all(bytes)?; + } + for (index, path) in plan.files.keys().enumerate() { + progress.set_progress(index as u64, plan.files.len() as u64); + progress.set_sub_action(path.display().to_string()); + if plan.archive.referenced.contains(path) { continue; } let name = path .to_str() .ok_or("Export paths must be UTF-8")? .replace('\\', "/"); - zip.start_file(format!("overrides/{name}"), zip_options)?; - std::io::copy(&mut std::fs::File::open(root.join(path))?, &mut zip)?; + zip.start_file(format!("{}/{name}", plan.archive.prefix), zip_options)?; + std::io::copy(&mut std::fs::File::open(plan.root.join(path))?, &mut zip)?; } zip.finish()?; } + // Standalone folders can change while the archive is being compressed. + validate_inventory(&plan)?; temporary.as_file().sync_all()?; - temporary.persist_noclobber(output)?; + if overwrite { + validate_output_path(&plan.root, output)?; + temporary.persist(output)?; + } else { + temporary.persist_noclobber(output)?; + } + Ok(ExportReport { + output: output.to_owned(), + referenced_count: plan.referenced_count(), + bundled_count: plan.file_count() - plan.referenced_count(), + unmatched: plan.unmatched, + }) +} + +fn validate_inventory(plan: &ExportPlan) -> ExportResult<()> { + let current = if plan.explicit_selection { + let mut current = BTreeSet::new(); + for path in plan.files.keys() { + collect(&plan.root, path, &mut current)?; + } + current + } else { + selected_files(&plan.root, &plan.options.include)? + }; + if current != plan.files.keys().cloned().collect() { + return Err("Export files changed after preparation; prepare the export again".into()); + } + for (path, expected) in &plan.files { + let actual = current_fingerprint(&plan.root.join(path), expected)?; + if actual != *expected { + if let Some(manifest_path) = &plan.manifest_path + && manifest_path.try_exists()? + { + let lock = match &plan._lock { + Some(guard) => { + ContentLock::acquire_with_held_instance_lock(manifest_path, guard) + } + None => ContentLock::acquire(manifest_path), + }?; + let mut manifest = lock.load()?; + if let Some(record) = manifest + .files + .iter_mut() + .find(|record| record.relative_path == *path && record.fingerprint == *expected) + { + // Force a rescan without discarding ownership or the hashes used for safe mutations. + record.fingerprint.modified_ns = u128::MAX; + lock.save(&manifest)?; + } + } + return Err( + format!("Export file changed after preparation: {}", path.display()).into(), + ); + } + } Ok(()) } diff --git a/src/instance/export/curseforge.rs b/src/instance/export/curseforge.rs new file mode 100644 index 00000000..807bb918 --- /dev/null +++ b/src/instance/export/curseforge.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use super::*; +use crate::instance::import::curseforge::{Manifest, ManifestFile, ManifestLoader, Minecraft}; +use crate::net::modrinth::VersionInfo; + +pub(super) async fn prepare( + plan: &ExportPlan, + progress: &ProgressTask, +) -> ExportResult { + manifest(plan, &resolve(&plan.files, progress).await?) +} + +pub(super) async fn resolve( + files: &BTreeMap, + progress: &ProgressTask, +) -> ExportResult> { + let fingerprints = files + .iter() + .filter(|(path, _)| reference_candidate(path, PackFormat::CurseForge)) + .filter_map(|(_, file)| { + file.hash("curseforge") + .and_then(|value| value.parse::().ok()) + }) + .collect::>() + .into_iter() + .collect::>(); + let mut matches = BTreeMap::new(); + if !fingerprints.is_empty() { + let key = + crate::net::curseforge::api_key().ok_or("CurseForge API key is not configured")?; + let client = crate::net::HttpClient::new(); + for (index, chunk) in fingerprints.chunks(100).enumerate() { + progress.set_sub_action("Identifying CurseForge mods"); + progress.set_progress((index * 100) as u64, fingerprints.len() as u64); + matches.extend( + crate::net::curseforge::resolve_fingerprint_versions(&client, key, chunk).await?, + ); + } + } + Ok(matches) +} + +pub(super) fn manifest( + plan: &ExportPlan, + matches: &BTreeMap, +) -> ExportResult { + let mut referenced = BTreeSet::new(); + let mut ids = BTreeSet::new(); + for (path, hash) in &plan.files { + if !reference_candidate(path, PackFormat::CurseForge) { + continue; + } + let Some(version) = hash + .hash("curseforge") + .and_then(|hash| hash.parse().ok()) + .and_then(|hash| matches.get(&hash)) + else { + continue; + }; + if version.files.iter().any(|file| { + file.hashes.get("sha1").is_some_and(|expected| { + hash.hash("sha1") + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + }) + }) { + ids.insert(( + version.project_id.parse::()?, + version.id.parse::()?, + )); + referenced.insert(path.clone()); + } + } + let options = &plan.options; + let mod_loaders = if options.loader == ModLoader::Vanilla { + Vec::new() + } else { + vec![ManifestLoader { + id: format!( + "{}-{}", + options.loader.to_string().to_lowercase(), + options + .loader_version + .as_deref() + .ok_or("Missing loader version")? + ), + primary: true, + }] + }; + let manifest = Manifest { + minecraft: Minecraft { + version: options.game_version.clone(), + mod_loaders, + }, + manifest_type: "minecraftModpack".to_owned(), + manifest_version: 1, + name: options.name.clone(), + version: options.version.clone(), + author: options.author.clone(), + files: ids + .into_iter() + .map(|(project_id, file_id)| ManifestFile { + project_id, + file_id, + required: true, + }) + .collect(), + overrides: "overrides".to_owned(), + }; + Ok(ArchiveContents { + manifests: vec![("manifest.json", serde_json::to_vec_pretty(&manifest)?)], + referenced, + prefix: "overrides", + }) +} diff --git a/src/instance/export/mmc.rs b/src/instance/export/mmc.rs new file mode 100644 index 00000000..71ff3661 --- /dev/null +++ b/src/instance/export/mmc.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use super::*; + +pub(super) fn prepare(options: &ExportOptions) -> ExportResult { + let mut components = + vec![serde_json::json!({"uid": "net.minecraft", "version": options.game_version})]; + let uid = match options.loader { + ModLoader::Vanilla => None, + ModLoader::Forge => Some("net.minecraftforge"), + ModLoader::Fabric => Some("net.fabricmc.fabric-loader"), + ModLoader::NeoForge => Some("net.neoforged"), + ModLoader::Quilt => Some("org.quiltmc.quilt-loader"), + }; + if let Some(uid) = uid { + components.push(serde_json::json!({"uid": uid, "version": options.loader_version})); + } + let pack = serde_json::json!({"formatVersion": 1, "components": components}); + let name = if options.name.starts_with('@') { + format!("@{}", options.name) + } else { + options.name.clone() + }; + let cfg = format!( + "[General]\nConfigVersion=1.2\nInstanceType=OneSix\nname={}\n", + serde_json::to_string(&name)? + ); + Ok(ArchiveContents { + manifests: vec![ + ("mmc-pack.json", serde_json::to_vec_pretty(&pack)?), + ("instance.cfg", cfg.into_bytes()), + ], + referenced: BTreeSet::new(), + prefix: ".minecraft", + }) +} diff --git a/src/instance/export/mrpack.rs b/src/instance/export/mrpack.rs new file mode 100644 index 00000000..df515d63 --- /dev/null +++ b/src/instance/export/mrpack.rs @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use std::collections::HashMap; + +use super::*; +use crate::instance::import::mrpack::{MrpackFile, MrpackIndex}; +use crate::net::modrinth::VersionInfo; + +pub(super) async fn prepare( + plan: &ExportPlan, + progress: &ProgressTask, +) -> ExportResult { + manifest(plan, &resolve(&plan.files, progress).await?) +} + +pub(super) async fn resolve( + files: &BTreeMap, + progress: &ProgressTask, +) -> ExportResult> { + let hashes = files + .values() + .filter_map(|file| file.hash("sha512").map(str::to_owned)) + .collect::>() + .into_iter() + .collect::>(); + let client = crate::net::HttpClient::new(); + let mut matches = HashMap::new(); + for (index, chunk) in hashes.chunks(100).enumerate() { + progress.set_sub_action("Identifying Modrinth content"); + progress.set_progress((index * 100) as u64, hashes.len() as u64); + matches + .extend(crate::net::modrinth::resolve_version_files(&client, chunk, "sha512").await?); + } + Ok(matches) +} + +pub(super) fn manifest( + plan: &ExportPlan, + matches: &HashMap, +) -> ExportResult { + let mut referenced = BTreeSet::new(); + let mut files = Vec::new(); + for (path, hash) in &plan.files { + let Some(sha512) = hash.hash("sha512") else { + continue; + }; + let Some(version) = matches.get(sha512) else { + continue; + }; + let Some(file) = version.files.iter().find(|file| { + file.hashes + .get("sha512") + .is_some_and(|expected| expected.eq_ignore_ascii_case(sha512)) + && file.hashes.get("sha1").is_some_and(|expected| { + hash.hash("sha1") + .is_some_and(|actual| actual.eq_ignore_ascii_case(expected)) + }) + && file.size == hash.size + && reqwest::Url::parse(&file.url).is_ok_and(|url| { + url.scheme() == "https" && url.host_str() == Some("cdn.modrinth.com") + }) + }) else { + continue; + }; + files.push(MrpackFile { + path: path + .to_str() + .ok_or("Export paths must be UTF-8")? + .replace('\\', "/"), + hashes: ["sha1", "sha512"] + .into_iter() + .map(|algorithm| { + ( + algorithm.to_owned(), + hash.hash(algorithm).unwrap().to_owned(), + ) + }) + .collect(), + // Installed client content does not establish server compatibility. + env: HashMap::from([ + ("client".to_owned(), "required".to_owned()), + ("server".to_owned(), "unsupported".to_owned()), + ]), + downloads: vec![file.url.clone()], + file_size: hash.size, + }); + referenced.insert(path.clone()); + } + let options = &plan.options; + let mut dependencies = HashMap::from([("minecraft".to_owned(), options.game_version.clone())]); + let loader = match options.loader { + ModLoader::Vanilla => None, + ModLoader::Forge => Some("forge"), + ModLoader::Fabric => Some("fabric-loader"), + ModLoader::NeoForge => Some("neoforge"), + ModLoader::Quilt => Some("quilt-loader"), + }; + if let Some(loader) = loader { + dependencies.insert( + loader.to_owned(), + options + .loader_version + .clone() + .ok_or("Missing loader version")?, + ); + } + let index = MrpackIndex { + format_version: 1, + game: "minecraft".to_owned(), + version_id: options.version.clone(), + name: options.name.clone(), + dependencies, + files, + }; + Ok(ArchiveContents { + manifests: vec![("modrinth.index.json", serde_json::to_vec_pretty(&index)?)], + referenced, + prefix: "client-overrides", + }) +} diff --git a/src/instance/import/mmc.rs b/src/instance/import/mmc.rs index e967cc52..bda61aeb 100644 --- a/src/instance/import/mmc.rs +++ b/src/instance/import/mmc.rs @@ -230,7 +230,13 @@ fn instance_name_from_cfg(path: &Path) -> Option { let reader = std::io::BufRead::lines(std::io::BufReader::new(raw.as_slice())); for line in reader.map_while(Result::ok) { if let Some(value) = line.strip_prefix("name=") { - let name = value.trim().to_string(); + let value = value.trim(); + let name = serde_json::from_str::(value).unwrap_or_else(|_| value.to_owned()); + let name = if name.starts_with("@@") { + name[1..].to_owned() + } else { + name + }; if !name.is_empty() { tracing::debug!("Read MultiMC instance name '{}' from instance.cfg", name); return Some(name); diff --git a/src/instance/import/mod.rs b/src/instance/import/mod.rs index c92589d2..b7f1040a 100644 --- a/src/instance/import/mod.rs +++ b/src/instance/import/mod.rs @@ -14,12 +14,7 @@ use archive::{override_files, read_pack_manifest}; use crate::instance::manager::InstanceManager; use crate::instance::models::ModLoader; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PackFormat { - CurseForge, - Mrpack, - Mmc, -} +pub use super::pack::PackFormat; #[derive(Debug, Clone, PartialEq)] pub enum ImportInput { diff --git a/src/instance/import/mrpack.rs b/src/instance/import/mrpack.rs index 681fab0a..2fdd0d9c 100644 --- a/src/instance/import/mrpack.rs +++ b/src/instance/import/mrpack.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::feedback::progress; use crate::instance::content::manifest::{ @@ -16,7 +16,7 @@ use crate::storage::InstancePaths; use super::{ImportSummary, PackFormat}; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct MrpackIndex { #[serde(rename = "formatVersion")] pub format_version: u32, @@ -30,7 +30,7 @@ pub struct MrpackIndex { pub files: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct MrpackFile { pub path: String, #[serde(default)] diff --git a/src/instance/launch/mod.rs b/src/instance/launch/mod.rs index 342ca687..f2d9c311 100644 --- a/src/instance/launch/mod.rs +++ b/src/instance/launch/mod.rs @@ -790,7 +790,7 @@ pub async fn launch( quick_play_world: Option<&str>, ) -> Result<(), LaunchError> { let name = config.name.clone(); - let instance_lock = crate::instance::runtime::lock_instance(instances_dir, &name) + let mut instance_lock = crate::instance::runtime::lock_instance(instances_dir, &name) .map_err(|error| LaunchError::Parse(error.to_string()))?; let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); let mut kill_rx = Some(kill_rx); @@ -921,6 +921,10 @@ pub async fn launch( }; tracing::debug!("[{}] Spawned Minecraft process", name); + if let Err(error) = instance_lock.allow_content_changes() { + finish_config_sync(config_sync_lock, &invocation.working_dir, &name); + return Err(error.into()); + } crate::instance::runtime::set_state(&name, crate::instance::runtime::RunState::Running); let log_file_path = crate::instance::logs::files::create_log_file(instances_dir, &name); diff --git a/src/instance/mod.rs b/src/instance/mod.rs index a63761cc..862d8434 100644 --- a/src/instance/mod.rs +++ b/src/instance/mod.rs @@ -13,6 +13,7 @@ pub mod loader; pub mod logs; pub mod manager; pub mod models; +pub mod pack; mod process; pub mod runtime; pub mod screenshots; diff --git a/src/instance/pack.rs b/src/instance/pack.rs new file mode 100644 index 00000000..155687bf --- /dev/null +++ b/src/instance/pack.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PackFormat { + CurseForge, + Mrpack, + Mmc, +} + +impl PackFormat { + pub const ALL: [Self; 3] = [Self::CurseForge, Self::Mrpack, Self::Mmc]; + + pub fn label(self) -> &'static str { + match self { + Self::CurseForge => "CurseForge", + Self::Mrpack => "Modrinth", + Self::Mmc => "MultiMC/Prism", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Mrpack => "mrpack", + Self::CurseForge | Self::Mmc => "zip", + } + } +} diff --git a/src/instance/runtime.rs b/src/instance/runtime.rs index 29058df6..db8adea7 100644 --- a/src/instance/runtime.rs +++ b/src/instance/runtime.rs @@ -111,14 +111,29 @@ pub fn cleanup_kill_sender(name: &str) { } } -pub(crate) struct InstanceLock(std::fs::File); +pub(crate) struct InstanceLock { + file: std::fs::File, + content: Option, +} + +impl InstanceLock { + pub(crate) fn allow_content_changes(&mut self) -> std::io::Result<()> { + if let Some(file) = &self.content { + file.unlock()?; + } + self.content = None; + Ok(()) + } +} impl Drop for InstanceLock { fn drop(&mut self) { // A concurrent fork can retain a descriptor until exec; closing our copy // alone would leave the instance locked during that interval. - if let Err(error) = self.0.unlock() { - tracing::warn!("Could not release instance lock: {error}"); + for file in self.content.iter().chain(std::iter::once(&self.file)) { + if let Err(error) = file.unlock() { + tracing::warn!("Could not release instance lock: {error}"); + } } } } @@ -127,8 +142,35 @@ pub(crate) fn lock_instance( root: &std::path::Path, name: &str, ) -> Result { + let mut guard = InstanceLock { + file: lock_file(root, name, false)?, + content: None, + }; + guard.content = Some(lock_file(root, name, true)?); + Ok(guard) +} + +pub(crate) fn lock_content( + root: &std::path::Path, + name: &str, +) -> Result { + Ok(InstanceLock { + file: lock_file(root, name, true)?, + content: None, + }) +} + +fn lock_file( + root: &std::path::Path, + name: &str, + content: bool, +) -> Result { crate::instance::manager::validate_name(name)?; - let locks = root.join(".rmcl-locks"); + let locks = root.join(if content { + ".rmcl-locks/content" + } else { + ".rmcl-locks" + }); std::fs::create_dir_all(&locks)?; let file = std::fs::OpenOptions::new() .create(true) @@ -137,7 +179,7 @@ pub(crate) fn lock_instance( .write(true) .open(locks.join(format!("{name}.lock")))?; match file.try_lock() { - Ok(()) => Ok(InstanceLock(file)), + Ok(()) => Ok(file), Err(std::fs::TryLockError::WouldBlock) => Err( crate::instance::manager::InstanceError::InstanceRunning(name.to_owned()), ), diff --git a/src/instance/tests/content/dependencies.rs b/src/instance/tests/content/dependencies.rs index f52cc34e..e778ca77 100644 --- a/src/instance/tests/content/dependencies.rs +++ b/src/instance/tests/content/dependencies.rs @@ -1400,9 +1400,10 @@ async fn installed_incompatible_projects_block_the_plan() { #[tokio::test] async fn dependency_install_commits_files_and_manifest_together() { let temp = tempfile::tempdir().unwrap(); - let minecraft = temp.path().join("minecraft"); + let paths = crate::storage::InstancePaths::new(temp.path().join("Instance")); + let minecraft = paths.minecraft(); let mods = minecraft.join("mods"); - let manifest_path = temp.path().join("manifest.json"); + let manifest_path = paths.content_manifest(); let root_version = version( "root", "root", @@ -1428,11 +1429,19 @@ async fn dependency_install_commits_files_and_manifest_together() { .await .unwrap(); + let mut running = crate::instance::runtime::lock_instance(temp.path(), "Instance").unwrap(); + assert!( + install(®istry, &manifest_path, &minecraft, &plan) + .await + .is_err() + ); + running.allow_content_changes().unwrap(); let installed = install(®istry, &manifest_path, &minecraft, &plan) .await .unwrap(); assert_eq!(installed.root_path, mods.join("root.jar")); + assert!(crate::instance::runtime::lock_instance(temp.path(), "Instance").is_err()); assert!(mods.join("library.jar").exists()); let manifest = ContentManifest::load(&manifest_path).unwrap(); assert_eq!(manifest.files.len(), 2); diff --git a/src/instance/tests/content/local.rs b/src/instance/tests/content/local.rs index 717a330d..0483ad50 100644 --- a/src/instance/tests/content/local.rs +++ b/src/instance/tests/content/local.rs @@ -201,6 +201,31 @@ fn content_mutations_coordinate_with_the_instance_lifetime_guard() { assert_eq!(std::fs::read(path).unwrap(), b"original"); } +#[test] +fn running_instances_allow_content_toggles_and_removal_but_still_prevent_duplicate_launches() { + let temp = tempfile::tempdir().unwrap(); + let (minecraft, manifest_path, record) = managed_mod(temp.path()); + let path = minecraft.join(&record.relative_path); + let mut entry = super::super::mods::scan_one_mod(&path, "example", true); + entry.provider_project = record.resolved_project().cloned(); + let mut running = crate::instance::runtime::lock_instance(temp.path(), "instance").unwrap(); + assert!( + toggle(&entry).is_err(), + "Content must remain protected during launch preparation" + ); + running.allow_content_changes().unwrap(); + let disabled = toggle(&entry).unwrap().unwrap(); + assert_eq!(std::fs::read(&disabled).unwrap(), b"original"); + assert!(!path.exists()); + let expected = ContentManifest::load(&manifest_path).unwrap().files; + let (manifest, _) = remove(&manifest_path, &minecraft, &disabled, &expected, true).unwrap(); + assert!(manifest.files.is_empty()); + assert!(!disabled.exists()); + assert!(crate::instance::runtime::lock_instance(temp.path(), "instance").is_err()); + drop(running); + crate::instance::runtime::lock_instance(temp.path(), "instance").unwrap(); +} + #[test] fn stale_content_actions_reject_new_ownership_and_changed_bytes() { let temp = tempfile::tempdir().unwrap(); @@ -281,6 +306,25 @@ fn deleting_a_world_validates_and_removes_its_descendant_records() { .to_string() .contains("Ownership") ); + let mut running = crate::instance::runtime::lock_instance(temp.path(), "instance").unwrap(); + running.allow_content_changes().unwrap(); + assert!( + remove( + &manifest_path, + &minecraft, + &world, + std::slice::from_ref(&record), + false + ) + .is_err() + ); + let entry = super::super::worlds::scan_one_world(&world, "world", true); + assert!( + toggle(&entry).is_err(), + "Renaming a live save must remain blocked" + ); + assert!(world.exists(), "Deleting a live save must remain blocked"); + drop(running); let (manifest, _) = remove(&manifest_path, &minecraft, &world, &[record], false).unwrap(); assert!(manifest.files.is_empty()); assert!(!world.exists()); diff --git a/src/instance/tests/content/manifest.rs b/src/instance/tests/content/manifest.rs index 08ae4713..a3778efc 100644 --- a/src/instance/tests/content/manifest.rs +++ b/src/instance/tests/content/manifest.rs @@ -21,6 +21,23 @@ fn content_lock_excludes_independent_handles_until_drop() { ContentLock::acquire(&path).unwrap(); } +#[test] +fn content_lock_with_held_instance_guard_still_excludes_other_manifest_handles() { + let temp = tempfile::tempdir().unwrap(); + let guard = crate::instance::runtime::lock_instance(temp.path(), "Example").unwrap(); + let path = temp.path().join("Example/rmcl/content/manifest.json"); + let lock = ContentLock::acquire_with_held_instance_lock(&path, &guard).unwrap(); + assert!(matches!( + ContentLock::acquire_with_held_instance_lock(&path, &guard), + Err(ManifestError::Busy(_)) + )); + drop(lock); + ContentLock::acquire_with_held_instance_lock(&path, &guard).unwrap(); + assert!(ContentLock::acquire(&path).is_err()); + drop(guard); + ContentLock::acquire(&path).unwrap(); +} + #[test] fn dropping_guard_releases_the_content_lock_with_a_duplicated_handle() { let temp = tempfile::tempdir().unwrap(); @@ -191,6 +208,30 @@ fn fingerprint_contains_both_modrinth_hashes() { assert_eq!(fingerprint.hash("sha512").unwrap().len(), 128); } +#[test] +fn fingerprint_checks_cancellation_during_both_hashing_passes() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("example.jar"); + std::fs::write(&path, [b'x'; 64 * 1024 + 1]).unwrap(); + for cancel_at in [3, 6] { + let calls = std::cell::Cell::new(0); + let error = fingerprint_with_check(&path, || { + calls.set(calls.get() + 1); + if calls.get() == cancel_at { + Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )) + } else { + Ok(()) + } + }) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); + assert_eq!(calls.get(), cancel_at); + } +} + #[test] fn renaming_a_record_preserves_its_resolution() { let mut manifest = ContentManifest::default(); diff --git a/src/instance/tests/export.rs b/src/instance/tests/export.rs index 2e0f5edd..a15abf12 100644 --- a/src/instance/tests/export.rs +++ b/src/instance/tests/export.rs @@ -2,6 +2,7 @@ use super::*; fn options() -> ExportOptions { ExportOptions { + format: PackFormat::CurseForge, name: "Exported Pack".to_owned(), version: "1.2.3".to_owned(), author: "Pack Author".to_owned(), @@ -36,7 +37,7 @@ async fn exported_overrides_round_trip_through_curseforge_import() { if loader == ModLoader::Vanilla { options.loader_version = None; } - export_curseforge(&root, &output, &options).await.unwrap(); + export_pack(&root, &output, &options).await.unwrap(); let summary = super::super::import::build_summary(&output).unwrap(); assert_eq!(summary.name, options.name); @@ -87,9 +88,7 @@ async fn export_rejects_nonportable_filenames_before_writing_archive() { let root = game_folder(temp.path()); std::fs::write(root.join("config").join(name), b"invalid").unwrap(); let output = temp.path().join("pack.zip"); - let error = export_curseforge(&root, &output, &options()) - .await - .unwrap_err(); + let error = export_pack(&root, &output, &options()).await.unwrap_err(); assert!( error.to_string().contains("Invalid export path"), "{name}: {error}" @@ -104,9 +103,7 @@ async fn export_preserves_existing_output() { let root = game_folder(temp.path()); let output = temp.path().join("pack.zip"); std::fs::write(&output, b"existing pack").unwrap(); - let error = export_curseforge(&root, &output, &options()) - .await - .unwrap_err(); + let error = export_pack(&root, &output, &options()).await.unwrap_err(); assert!( error.to_string().contains("Output already exists"), "{error}" @@ -114,6 +111,77 @@ async fn export_preserves_existing_output() { assert_eq!(std::fs::read(&output).unwrap(), b"existing pack"); } +#[tokio::test] +async fn approved_overwrites_replace_only_completed_archives() { + for format in PackFormat::ALL { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join(format!("pack.{}", format.extension())); + let mut options = options(); + options.format = format; + std::fs::write(&output, b"existing pack").unwrap(); + validate_output_path(&root, &output).unwrap(); + let plan = prepare_export(&root, &options).await.unwrap(); + std::fs::write(root.join("options.txt"), b"changed after preparation").unwrap(); + assert!( + write_export_with_overwrite(plan, &output, true) + .await + .is_err() + ); + assert_eq!(std::fs::read(&output).unwrap(), b"existing pack"); + let plan = prepare_export(&root, &options).await.unwrap(); + write_export_with_overwrite(plan, &output, true) + .await + .unwrap(); + assert_eq!( + super::super::import::build_summary(&output).unwrap().format, + format + ); + } +} + +#[tokio::test] +async fn overwrite_rejects_non_files_and_paths_inside_the_game() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join("directory.zip"); + std::fs::create_dir(&output).unwrap(); + let plan = prepare_export(&root, &options()).await.unwrap(); + assert!( + write_export_with_overwrite(plan, &output, true) + .await + .is_err() + ); + assert!(output.is_dir()); + let inside = root.join("pack.zip"); + std::fs::write(&inside, b"keep").unwrap(); + let plan = prepare_export(&root, &options()).await.unwrap(); + assert!( + write_export_with_overwrite(plan, &inside, true) + .await + .is_err() + ); + assert_eq!(std::fs::read(inside).unwrap(), b"keep"); +} + +#[cfg(unix)] +#[tokio::test] +async fn overwrite_rejects_symlinks_without_modifying_the_target() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let target = temp.path().join("keep.zip"); + let output = temp.path().join("link.zip"); + std::fs::write(&target, b"keep").unwrap(); + std::os::unix::fs::symlink(&target, &output).unwrap(); + let plan = prepare_export(&root, &options()).await.unwrap(); + assert!( + write_export_with_overwrite(plan, &output, true) + .await + .is_err() + ); + assert_eq!(std::fs::read(target).unwrap(), b"keep"); +} + #[tokio::test] async fn export_rejects_nonportable_include_paths() { let temp = tempfile::tempdir().unwrap(); @@ -122,9 +190,7 @@ async fn export_rejects_nonportable_include_paths() { for path in ["../outside", "config//nested", "config/./nested"] { let mut options = options(); options.include = vec![PathBuf::from(path)]; - let error = export_curseforge(&root, &output, &options) - .await - .unwrap_err(); + let error = export_pack(&root, &output, &options).await.unwrap_err(); assert!( error.to_string().contains("Invalid export path"), "{path}: {error}" @@ -133,15 +199,700 @@ async fn export_rejects_nonportable_include_paths() { } } -#[test] -fn archive_writer_preserves_output_created_after_preflight() { +#[tokio::test] +async fn archive_writer_preserves_output_created_after_preflight() { let temp = tempfile::tempdir().unwrap(); let root = game_folder(temp.path()); let output = temp.path().join("pack.zip"); assert!(!output.exists()); let options = options(); - let files = selected_files(&root, &options.include).unwrap(); + let plan = prepare_export(&root, &options).await.unwrap(); std::fs::write(&output, b"another export").unwrap(); - assert!(write_archive(&root, &output, &options, &files, &BTreeMap::new()).is_err()); + assert!(write_export(plan, &output).await.is_err()); assert_eq!(std::fs::read(&output).unwrap(), b"another export"); } + +#[tokio::test] +async fn all_formats_round_trip_metadata_and_bundled_files() { + for format in PackFormat::ALL { + for loader in [ + ModLoader::Vanilla, + ModLoader::Forge, + ModLoader::Fabric, + ModLoader::NeoForge, + ModLoader::Quilt, + ] { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let output = temp.path().join(format!("pack.{}", format.extension())); + let mut options = options(); + options.format = format; + options.loader = loader; + options.loader_version = (loader != ModLoader::Vanilla).then(|| "1.2.3".to_owned()); + let report = export_pack(&root, &output, &options).await.unwrap(); + assert_eq!(report.bundled_count, 2); + assert_eq!(report.referenced_count, 0); + let summary = super::super::import::build_summary(&output).unwrap(); + assert_eq!(summary.format, format); + assert_eq!(summary.name, options.name); + assert_eq!(summary.loader, loader); + assert_eq!(summary.loader_version, options.loader_version); + assert_eq!(summary.game_version, options.game_version); + let manager = + InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let config = test_config(&options); + match format { + PackFormat::CurseForge => { + super::super::import::curseforge::execute_import(&summary, &manager, &config) + .await + } + PackFormat::Mrpack => { + super::super::import::mrpack::execute_import(&summary, &manager, &config).await + } + PackFormat::Mmc => { + super::super::import::mmc::execute_import(&summary, &manager, &config).await + } + } + .unwrap(); + let imported = + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)) + .minecraft(); + for path in ["config/nested/settings.json", "options.txt"] { + assert_eq!( + std::fs::read(imported.join(path)).unwrap(), + std::fs::read(root.join(path)).unwrap() + ); + } + } + } +} + +fn test_config(options: &ExportOptions) -> InstanceConfig { + serde_json::from_value(serde_json::json!({ + "name": options.name, "game_version": options.game_version, "loader": options.loader, + "loader_version": options.loader_version, "created": "2026-01-01T00:00:00Z" + })) + .unwrap() +} + +#[tokio::test] +async fn multimc_bundles_mods_without_provider_access_and_excludes_personal_files() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + for (path, data) in [ + ("mods/custom.jar", "custom"), + ("mods/old.jar.disabled", "disabled"), + ("logs/latest.log", "private"), + ("saves/World/level.dat", "world"), + ] { + std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap(); + std::fs::write(root.join(path), data).unwrap(); + } + let mut options = options(); + options.format = PackFormat::Mmc; + let output = temp.path().join("pack.zip"); + let report = export_pack(&root, &output, &options).await.unwrap(); + assert_eq!(report.bundled_count, 3); + let mut archive = zip::ZipArchive::new(std::fs::File::open(output).unwrap()).unwrap(); + assert!(archive.by_name(".minecraft/mods/custom.jar").is_ok()); + for path in [ + ".minecraft/mods/old.jar.disabled", + ".minecraft/logs/latest.log", + ".minecraft/saves/World/level.dat", + ] { + assert!(archive.by_name(path).is_err()); + } +} + +#[tokio::test] +async fn managed_export_holds_lock_and_reloads_loader_metadata() { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let mut options = options(); + let config = test_config(&options); + std::fs::create_dir_all( + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)).minecraft(), + ) + .unwrap(); + manager.save(&config).unwrap(); + std::fs::write( + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)) + .minecraft() + .join("options.txt"), + b"settings", + ) + .unwrap(); + options.loader = ModLoader::Vanilla; + options.loader_version = None; + let plan = prepare_instance_export(&manager, &config.name, &options) + .await + .unwrap(); + assert_eq!(plan.options.loader, config.loader); + assert_eq!(plan.options.loader_version, config.loader_version); + assert!(super::super::runtime::lock_instance(&manager.instances_dir, &config.name).is_err()); + drop(plan); + assert!(super::super::runtime::lock_instance(&manager.instances_dir, &config.name).is_ok()); +} + +#[tokio::test] +async fn archive_rejects_changed_files_and_output_inside_game_folder() { + for format in PackFormat::ALL { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let mut options = options(); + options.format = format; + assert!( + export_pack(&root, &root.join("config/pack.zip"), &options) + .await + .is_err() + ); + let plan = prepare_export(&root, &options).await.unwrap(); + std::fs::write(root.join("options.txt"), b"changed settings").unwrap(); + let output = temp.path().join("pack.zip"); + assert!( + write_export(plan, &output) + .await + .unwrap_err() + .to_string() + .contains("changed after preparation") + ); + assert!(!output.exists()); + } +} + +fn content_plan(root: &Path, format: PackFormat) -> ExportPlan { + let mut options = options(); + options.format = format; + options.include.clear(); + let progress = ProgressTask::start("Export test"); + inventory(root.to_owned(), options, None, &progress.handle()).unwrap() +} + +fn version(plan: &ExportPlan, path: &str) -> crate::net::modrinth::VersionInfo { + let hash = &plan.files[Path::new(path)]; + serde_json::from_value(serde_json::json!({ + "id": "9", "project_id": "7", "name": "Example", "version_number": "1", "game_versions": [], "loaders": [], + "files": [{"url": "https://cdn.modrinth.com/data/7/versions/9/example.jar", "filename": "example.jar", "size": hash.size, "primary": true, + "hashes": {"sha1": hash.hash("sha1"), "sha512": hash.hash("sha512")}}] + })).unwrap() +} + +#[tokio::test] +async fn archive_rejects_referenced_mod_changes_even_when_size_and_timestamp_are_preserved() { + for format in [PackFormat::CurseForge, PackFormat::Mrpack] { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::create_dir(root.join("mods")).unwrap(); + let path = root.join("mods/example.jar"); + std::fs::write(&path, b"old").unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + let mut plan = content_plan(&root, format); + let matched = version(&plan, "mods/example.jar"); + plan.archive = match format { + PackFormat::CurseForge => curseforge::manifest( + &plan, + &BTreeMap::from([( + plan.files[Path::new("mods/example.jar")] + .hash("curseforge") + .unwrap() + .parse() + .unwrap(), + matched, + )]), + ) + .unwrap(), + PackFormat::Mrpack => mrpack::manifest( + &plan, + &HashMap::from([( + plan.files[Path::new("mods/example.jar")] + .hash("sha512") + .unwrap() + .to_owned(), + matched, + )]), + ) + .unwrap(), + PackFormat::Mmc => unreachable!(), + }; + assert_eq!(plan.referenced_count(), 1); + std::fs::write(&path, b"new").unwrap(); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(modified) + .unwrap(); + let current = fingerprint_metadata(&path).unwrap(); + let expected = &plan.files[Path::new("mods/example.jar")]; + assert_eq!(current.size, expected.size); + assert_eq!(current.modified_ns, expected.modified_ns); + let output = temp.path().join(format!("pack.{}", format.extension())); + assert!( + write_export(plan, &output) + .await + .unwrap_err() + .to_string() + .contains("changed after preparation") + ); + assert!(!output.exists()); + } +} + +#[tokio::test] +async fn multimc_names_are_quoted_for_qt_and_round_trip_through_import() { + for (name, encoded) in [ + ("Plain Pack", r#""Plain Pack""#), + ("Pack, with commas", r#""Pack, with commas""#), + ("Pack; semicolon", r#""Pack; semicolon""#), + (r#"A "quoted" pack"#, r#""A \"quoted\" pack""#), + (r"C:\new\folder", r#""C:\\new\\folder""#), + ("@String(hello)", r#""@@String(hello)""#), + ("@@Pack", r#""@@@Pack""#), + (" 雪 ❄ ", "\" 雪 ❄ \""), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + let mut options = options(); + options.name = name.to_owned(); + options.format = PackFormat::Mmc; + let output = temp.path().join("pack.zip"); + export_pack(&root, &output, &options).await.unwrap(); + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let mut cfg = String::new(); + std::io::Read::read_to_string(&mut archive.by_name("instance.cfg").unwrap(), &mut cfg) + .unwrap(); + assert!( + cfg.lines().any(|line| line == format!("name={encoded}")), + "{cfg}" + ); + assert_eq!( + super::super::import::build_summary(&output).unwrap().name, + name + ); + } +} + +#[test] +fn export_scan_checks_cancellation_between_directory_entries() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("one.txt"), b"one").unwrap(); + std::fs::write(temp.path().join("two.txt"), b"two").unwrap(); + let progress = ProgressTask::start("Export cancellation test"); + let calls = std::cell::Cell::new(0); + let check = || { + calls.set(calls.get() + 1); + if calls.get() == 3 { + Err(std::io::Error::new( + std::io::ErrorKind::Interrupted, + "cancelled", + )) + } else { + Ok(()) + } + }; + let mut files = BTreeMap::new(); + let error = scan_directory( + temp.path(), + Path::new(""), + &mut files, + &mut Vec::new(), + &ContentManifest::default(), + &progress, + &check, + ) + .unwrap_err(); + assert_eq!( + error.downcast_ref::().unwrap().kind(), + std::io::ErrorKind::Interrupted + ); + assert_eq!(files.len(), 1); +} + +#[test] +fn export_fingerprints_reuse_only_complete_unchanged_cache_entries() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mod.jar"); + std::fs::write(&path, b"cached content").unwrap(); + let expected = super::super::content::manifest::fingerprint(&path).unwrap(); + let calls = std::cell::Cell::new(0); + let check = || { + calls.set(calls.get() + 1); + Ok(()) + }; + assert_eq!( + export_fingerprint(&path, Some(&expected), check).unwrap(), + expected + ); + assert_eq!( + calls.get(), + 1, + "A cache hit must not enter either hashing pass" + ); + let mut wrong_size = expected.clone(); + wrong_size.size += 1; + let mut wrong_time = expected.clone(); + wrong_time.modified_ns += 1; + let mut incomplete = expected.clone(); + incomplete.hashes.remove("sha512"); + for cached in [ + None, + Some(&wrong_size), + Some(&wrong_time), + Some(&incomplete), + ] { + calls.set(0); + assert_eq!(export_fingerprint(&path, cached, check).unwrap(), expected); + assert!(calls.get() > 1); + } + assert!( + export_fingerprint(&path, Some(&expected), || Err(std::io::Error::from( + std::io::ErrorKind::Interrupted + ))) + .is_err() + ); +} + +#[tokio::test] +async fn export_scan_rechecks_cached_hashes_and_recovers_on_retry() { + for managed in [true, false] { + for format in PackFormat::ALL { + if !managed && format == PackFormat::Mmc { + continue; + } + let temp = tempfile::tempdir().unwrap(); + let manager = + InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let mut options = options(); + options.format = format; + options.bundle_unmatched = true; + options.include.clear(); + let config = test_config(&options); + let paths = + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)); + std::fs::create_dir_all(paths.minecraft().join("mods")).unwrap(); + let path = paths.minecraft().join("mods/cached.jar"); + std::fs::write(&path, b"original").unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + let expected = super::super::content::manifest::fingerprint(&path).unwrap(); + manager.save(&config).unwrap(); + let manifest: ContentManifest = serde_json::from_value(serde_json::json!({ + "version": 1, + "files": [{ + "relative_path": "mods/cached.jar", + "kind": "mod", + "enabled": true, + "fingerprint": expected, + "resolution": {"status": "resolved", "project": { + "provider": "modrinth", + "project_id": "old-project", + "version_id": "old-version" + }}, + "automatic_dependency": true + }] + })) + .unwrap(); + manifest.save(&paths.content_manifest()).unwrap(); + std::fs::write(&path, b"replaced").unwrap(); + std::fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(modified) + .unwrap(); + let scan = if managed { + Some(scan_instance_export(&manager, &config.name).await.unwrap()) + } else { + None + }; + let references = ExportReferences::empty(format); + let progress = ProgressTask::start("Cached export inventory"); + let plan = if let Some(scan) = &scan { + scan.plan(&options, &scan.defaults, &references).unwrap() + } else { + inventory(paths.minecraft(), options.clone(), None, &progress.handle()).unwrap() + }; + assert_eq!(plan.files[Path::new("mods/cached.jar")], expected); + let output = temp.path().join("pack.zip"); + assert!( + write_export(plan, &output) + .await + .unwrap_err() + .to_string() + .contains("changed after preparation") + ); + assert!(!output.exists()); + let mut invalidated = manifest.clone(); + invalidated.files[0].fingerprint.modified_ns = u128::MAX; + assert_eq!( + ContentManifest::load(&paths.content_manifest()) + .unwrap() + .files, + invalidated.files, + "Invalidating the cache must preserve hashes and ownership" + ); + assert!( + super::super::content::local::validate_record( + paths.minecraft().as_path(), + &invalidated.files[0] + ) + .is_err() + ); + if managed { + assert!( + super::super::runtime::lock_content(&manager.instances_dir, &config.name) + .is_err() + ); + } + drop(scan); + + let progress = ProgressTask::start("Retry export inventory"); + let inventory = + inventory(paths.minecraft(), options.clone(), None, &progress.handle()).unwrap(); + let actual = super::super::content::manifest::fingerprint(&path).unwrap(); + if format != PackFormat::Mmc { + assert_eq!(inventory.files[Path::new("mods/cached.jar")], actual); + } + validate_inventory(&inventory).unwrap(); + drop(inventory); + + let retry = scan_instance_export(&manager, &config.name).await.unwrap(); + assert_eq!(retry.files[Path::new("mods/cached.jar")], actual); + let plan = retry.plan(&options, &retry.defaults, &references).unwrap(); + write_export(plan, &output).await.unwrap(); + let mut archive = zip::ZipArchive::new(std::fs::File::open(&output).unwrap()).unwrap(); + let prefix = if format == PackFormat::Mmc { + ".minecraft" + } else if format == PackFormat::Mrpack { + "client-overrides" + } else { + "overrides" + }; + let mut bytes = Vec::new(); + std::io::Read::read_to_end( + &mut archive + .by_name(&format!("{prefix}/mods/cached.jar")) + .unwrap(), + &mut bytes, + ) + .unwrap(); + assert_eq!(bytes, b"replaced"); + } + } +} + +#[test] +fn curseforge_references_require_exact_sha1_even_when_fingerprints_match() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::create_dir(root.join("mods")).unwrap(); + std::fs::write(root.join("mods/example.jar"), b"mod").unwrap(); + let plan = content_plan(&root, PackFormat::CurseForge); + let hash = plan.files[Path::new("mods/example.jar")] + .hash("curseforge") + .unwrap() + .parse() + .unwrap(); + let mut matched = version(&plan, "mods/example.jar"); + let archive = curseforge::manifest(&plan, &BTreeMap::from([(hash, matched.clone())])).unwrap(); + assert!(archive.referenced.contains(Path::new("mods/example.jar"))); + let manifest: serde_json::Value = serde_json::from_slice(&archive.manifests[0].1).unwrap(); + assert_eq!(manifest["files"][0]["projectID"], 7); + assert_eq!(manifest["files"][0]["fileID"], 9); + matched.files[0] + .hashes + .insert("sha1".to_owned(), "wrong".to_owned()); + assert!( + curseforge::manifest(&plan, &BTreeMap::from([(hash, matched)])) + .unwrap() + .referenced + .is_empty() + ); +} + +#[test] +fn mrpack_references_preserve_paths_hashes_sizes_and_exact_download_artifact() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::create_dir(root.join("resourcepacks")).unwrap(); + std::fs::write(root.join("resourcepacks/renamed.zip"), b"resource pack").unwrap(); + let plan = content_plan(&root, PackFormat::Mrpack); + let path = "resourcepacks/renamed.zip"; + let sha512 = plan.files[Path::new(path)] + .hash("sha512") + .unwrap() + .to_owned(); + let mut matched = version(&plan, path); + let mut wrong_primary = matched.files[0].clone(); + wrong_primary + .hashes + .insert("sha512".to_owned(), "wrong".to_owned()); + matched.files.insert(0, wrong_primary); + let archive = mrpack::manifest( + &plan, + &std::collections::HashMap::from([(sha512.clone(), matched.clone())]), + ) + .unwrap(); + let index: super::super::import::mrpack::MrpackIndex = + serde_json::from_slice(&archive.manifests[0].1).unwrap(); + assert_eq!(index.files[0].path, path); + assert_eq!(index.files[0].hashes["sha512"], sha512); + assert_eq!(index.files[0].file_size, 13); + assert_eq!(index.files[0].env["server"], "unsupported"); + matched.files[1].url = "https://untrusted.example/mod.jar".to_owned(); + assert!( + mrpack::manifest(&plan, &std::collections::HashMap::from([(sha512, matched)])) + .unwrap() + .referenced + .is_empty() + ); +} + +#[tokio::test] +async fn unmatched_content_needs_explicit_bundling_approval() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::create_dir(root.join("mods")).unwrap(); + std::fs::write(root.join("mods/custom.txt"), b"custom").unwrap(); + for format in [PackFormat::CurseForge, PackFormat::Mrpack] { + let mut options = options(); + options.format = format; + let output = temp + .path() + .join(format!("{}.{}", format.label(), format.extension())); + let mut plan = prepare_export(&root, &options).await.unwrap(); + assert_eq!(plan.unmatched, [PathBuf::from("mods/custom.txt")]); + assert!(write_export(plan, &output).await.is_err()); + assert!(!output.exists()); + plan = prepare_export(&root, &options).await.unwrap(); + plan.options.bundle_unmatched = true; + assert_eq!( + write_export(plan, &output).await.unwrap().unmatched, + [PathBuf::from("mods/custom.txt")] + ); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn export_rejects_symlinks_in_all_formats() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::write(temp.path().join("private"), b"private").unwrap(); + std::os::unix::fs::symlink(temp.path().join("private"), root.join("config/secret")).unwrap(); + for format in PackFormat::ALL { + let mut options = options(); + options.format = format; + assert!( + prepare_export(&root, &options) + .await + .err() + .unwrap() + .to_string() + .contains("symlinks") + ); + } +} + +#[tokio::test] +async fn export_scan_defaults_and_explicit_selection_are_independent() { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let mut options = options(); + options.format = PackFormat::Mmc; + let config = test_config(&options); + let root = + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)).minecraft(); + for (path, data) in [ + ("config/keep.txt", "keep"), + ("config/omit.txt", "omit"), + ("options.txt", "options"), + ("logs/latest.log", "private"), + ("saves/World/level.dat", "world"), + ("mods/off.jar.disabled", "off"), + ] { + std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap(); + std::fs::write(root.join(path), data).unwrap(); + } + manager.save(&config).unwrap(); + let scan = scan_instance_export(&manager, &config.name).await.unwrap(); + assert_eq!( + scan.defaults, + BTreeSet::from([ + PathBuf::from("config/keep.txt"), + PathBuf::from("config/omit.txt") + ]) + ); + assert!(scan.paths().any(|path| path == Path::new("options.txt"))); + assert!( + scan.paths() + .any(|path| path == Path::new("saves/World/level.dat")) + ); + assert!( + !scan + .paths() + .any(|path| path == Path::new("mods/off.jar.disabled")) + ); + let selected = BTreeSet::from([ + PathBuf::from("config/keep.txt"), + PathBuf::from("options.txt"), + ]); + let plan = scan + .plan(&options, &selected, &ExportReferences::Bundled) + .unwrap(); + std::fs::write(root.join("config/omit.txt"), b"changed unselected file").unwrap(); + std::fs::write(root.join("logs/new.log"), b"unselected new log").unwrap(); + let output = temp.path().join("pack.zip"); + assert_eq!(write_export(plan, &output).await.unwrap().bundled_count, 2); + let mut archive = zip::ZipArchive::new(std::fs::File::open(output).unwrap()).unwrap(); + assert!(archive.by_name(".minecraft/config/keep.txt").is_ok()); + assert!(archive.by_name(".minecraft/options.txt").is_ok()); + assert!(archive.by_name(".minecraft/config/omit.txt").is_err()); + let unknown = BTreeSet::from([PathBuf::from("../secret")]); + assert!( + scan.plan(&options, &unknown, &ExportReferences::Bundled) + .is_err() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn export_scan_skips_unsafe_and_symlinked_entries_without_following_them() { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let options = options(); + let config = test_config(&options); + let root = + crate::storage::InstancePaths::new(manager.instances_dir.join(&config.name)).minecraft(); + std::fs::create_dir_all(root.join("config")).unwrap(); + std::fs::write(root.join("config/safe.txt"), b"safe").unwrap(); + std::fs::write(root.join("config/bad?.txt"), b"unsafe").unwrap(); + std::os::unix::fs::symlink(temp.path().join("missing"), root.join("config/link")).unwrap(); + manager.save(&config).unwrap(); + let scan = scan_instance_export(&manager, &config.name).await.unwrap(); + assert_eq!( + scan.paths().cloned().collect::>(), + [PathBuf::from("config/safe.txt")] + ); + assert_eq!(scan.skipped.len(), 2); +} + +#[test] +fn curseforge_does_not_reference_resource_packs_from_a_shared_scan() { + let temp = tempfile::tempdir().unwrap(); + let root = game_folder(temp.path()); + std::fs::create_dir(root.join("resourcepacks")).unwrap(); + std::fs::write(root.join("resourcepacks/pack.zip"), b"pack").unwrap(); + let plan = content_plan(&root, PackFormat::Mrpack); + let path = "resourcepacks/pack.zip"; + let fingerprint = plan.files[Path::new(path)] + .hash("curseforge") + .unwrap() + .parse() + .unwrap(); + let archive = curseforge::manifest( + &plan, + &BTreeMap::from([(fingerprint, version(&plan, path))]), + ) + .unwrap(); + assert!(archive.referenced.is_empty()); +} diff --git a/src/instance/tests/manager.rs b/src/instance/tests/manager.rs index 8422d569..78430caa 100644 --- a/src/instance/tests/manager.rs +++ b/src/instance/tests/manager.rs @@ -212,8 +212,11 @@ fn instance_lock_child() { let Some(root) = std::env::var_os("RMCL_INSTANCE_LOCK_TEST") else { return; }; - let _lock = + let mut lock = crate::instance::runtime::lock_instance(std::path::Path::new(&root), "locked").unwrap(); + if std::env::var("RMCL_ALLOW_CONTENT_TEST").as_deref() == Ok("1") { + lock.allow_content_changes().unwrap(); + } println!("LOCKED"); std::io::stdout().flush().unwrap(); std::thread::sleep(std::time::Duration::from_secs(10)); @@ -232,6 +235,7 @@ fn another_process_protects_the_instance_from_mutation_and_duplicate_launch() { "--nocapture", ]) .env("RMCL_INSTANCE_LOCK_TEST", temp.path()) + .env("RMCL_ALLOW_CONTENT_TEST", "1") .stdout(std::process::Stdio::piped()) .spawn() .unwrap(); @@ -241,9 +245,12 @@ fn another_process_protects_the_instance_from_mutation_and_duplicate_launch() { let deletion = manager.delete("locked"); let rename = manager.rename("locked", "new"); let launch = crate::instance::runtime::lock_instance(&manager.instances_dir, "locked"); + let content_allowed = + crate::instance::runtime::lock_content(&manager.instances_dir, "locked").is_ok(); let _ = child.kill(); child.wait().unwrap(); assert!(ready); + assert!(content_allowed); assert!(matches!(deletion, Err(InstanceError::InstanceRunning(_)))); assert!(matches!(rename, Err(InstanceError::InstanceRunning(_)))); assert!(matches!(launch, Err(InstanceError::InstanceRunning(_)))); diff --git a/src/instance/tests/runtime.rs b/src/instance/tests/runtime.rs index 7fc709be..c65742d5 100644 --- a/src/instance/tests/runtime.rs +++ b/src/instance/tests/runtime.rs @@ -7,7 +7,7 @@ use super::*; fn dropping_an_instance_lock_releases_it_even_with_a_duplicated_descriptor() { let temp = tempfile::tempdir().unwrap(); let lock = lock_instance(temp.path(), "duplicated").unwrap(); - let duplicate = lock.0.try_clone().unwrap(); + let duplicate = lock.file.try_clone().unwrap(); drop(lock); let next = lock_instance(temp.path(), "duplicated"); drop(duplicate); @@ -17,6 +17,29 @@ fn dropping_an_instance_lock_releases_it_even_with_a_duplicated_descriptor() { ); } +#[test] +fn running_content_changes_remain_serialized() { + let temp = tempfile::tempdir().unwrap(); + let mut game = lock_instance(temp.path(), "game").unwrap(); + assert!(lock_content(temp.path(), "game").is_err()); + game.allow_content_changes().unwrap(); + let content = lock_content(temp.path(), "game").unwrap(); + assert!(lock_content(temp.path(), "game").is_err()); + assert!(lock_instance(temp.path(), "game").is_err()); + drop(content); + assert!(lock_content(temp.path(), "game").is_ok()); + assert!(lock_instance(temp.path(), "game").is_err()); + drop(game); + assert!(lock_instance(temp.path(), "game").is_ok()); +} + +#[test] +fn content_locks_do_not_collide_with_other_instance_names() { + let temp = tempfile::tempdir().unwrap(); + let _game = lock_instance(temp.path(), "game").unwrap(); + let _other = lock_instance(temp.path(), "game.content").unwrap(); +} + #[test] fn set_and_get_state() { set_state("run_test_1", RunState::Starting); diff --git a/src/net/tests/curseforge.rs b/src/net/tests/curseforge.rs index 9dab9144..30950acd 100644 --- a/src/net/tests/curseforge.rs +++ b/src/net/tests/curseforge.rs @@ -117,6 +117,36 @@ fn curseforge_file_maps_to_shared_version() { assert_eq!(version.files[0].hashes["sha1"], "abc"); } +#[tokio::test] +async fn fingerprint_resolution_uses_file_fingerprint_and_keeps_exact_hashes() { + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/fingerprints/432")) + .and(header("x-api-key", "test-key")) + .and(body_json(serde_json::json!({"fingerprints": [12345]}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": {"exactMatches": [{"id": 7, "file": { + "id": 9, "modId": 7, "displayName": "Example", "fileName": "example.jar", + "fileFingerprint": 12345, "hashes": [{"algo": 1, "value": "abc"}] + }}]} + }))) + .expect(1) + .mount(&server) + .await; + let resolved = + resolve_fingerprint_versions_from(&HttpClient::new(), "test-key", &server.uri(), &[12345]) + .await + .unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].0, 12345); + assert_eq!(resolved[0].1.project_id, "7"); + assert_eq!(resolved[0].1.id, "9"); + assert_eq!(resolved[0].1.files[0].hashes["sha1"], "abc"); +} + #[test] fn curseforge_library_category_maps_to_cleanup_metadata() { let project: Mod = serde_json::from_str( diff --git a/src/tui/app.rs b/src/tui/app.rs index 5bc009c4..5c4bf0bf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,6 +41,7 @@ pub struct App { pub(super) account_state: widgets::account::AccountState, pub(super) settings_state: widgets::settings::SettingsState, pub(super) instance_settings: Option, + pub(super) export_popup: Option, pub(super) pending_instance_settings_updates: HashSet, pub(super) global_settings: Option, pub(super) picker: ratatui_image::picker::Picker, @@ -229,6 +230,7 @@ impl App { account_state: widgets::account::AccountState::default(), settings_state, instance_settings: None, + export_popup: None, pending_instance_settings_updates: HashSet::new(), global_settings: None, screenshots_state: { diff --git a/src/tui/event.rs b/src/tui/event.rs index 001b7bd6..c2a29ee6 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -39,6 +39,9 @@ impl App { self.spawn_import(result); } import_modpack::drain(&self.picker); + if let Some(state) = self.export_popup.as_mut() { + state.drain(); + } self.dismiss_expired_errors(); @@ -229,6 +232,7 @@ impl App { .is_some_and(widgets::content::update::State::visible), ) + usize::from(self.modpack_update_popup.is_some()) + + usize::from(self.export_popup.is_some()) + usize::from(self.modpack_versions_state.is_some()) + usize::from(!matches!( &self.account_state.add_mode, @@ -681,6 +685,13 @@ impl App { } fn key_repeat_allowed(&self, key: &KeyEvent) -> bool { + if let Some(state) = &self.export_popup { + return (state.text_input_active() && repeatable_text_key(key)) + || matches!( + key.code, + KeyCode::Up | KeyCode::Down | KeyCode::Char('j' | 'k') + ); + } let vertical_navigation = matches!( key.code, KeyCode::Up | KeyCode::Down | KeyCode::Char('j' | 'k') diff --git a/src/tui/input.rs b/src/tui/input.rs index b410256a..571e25e9 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -24,6 +24,9 @@ impl App { } return; } + if self.export_popup.is_some() { + return; + } if self.handle_log_selection_mouse(event) { return; } @@ -100,6 +103,12 @@ impl App { } pub(super) fn handle_key_event(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> { + if let Some(state) = self.export_popup.as_mut() { + if state.handle_key(&key_event, &self.instance_manager) { + self.export_popup = None; + } + return Ok(()); + } if self.content_update_popup.as_ref().is_some_and(|update| { update.phase != widgets::content::update::Phase::Applying && self @@ -1227,6 +1236,13 @@ impl App { self.spawn_launch(instance, None); } } + KeyCode::Char('e') if self.focused == FocusedArea::Instances => { + if let Some(instance) = self.instances_state.selected_instance() { + let mut popup = widgets::popups::export_modpack::State::new(instance); + popup.start_scan(&self.instance_manager); + self.export_popup = Some(popup); + } + } KeyCode::Char('u') if self.focused == FocusedArea::Instances && !self.instances_state.search.active => diff --git a/src/tui/render.rs b/src/tui/render.rs index 82a080d1..c459d6c9 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -180,18 +180,6 @@ impl App { widgets::popups::global_settings::render(frame, area, state); } - let all_errors = error_buffer::peek_all_errors(); - self.sync_error_effects(&all_errors); - let mut next_y: u16 = 1; - for event in all_errors { - let elapsed_ms = event.pushed_at.elapsed().as_millis(); - if let Some(area) = popup_area(frame.area(), &event.message, next_y, elapsed_ms) { - next_y = next_y.saturating_add(area.height + 1); - frame.render_widget(ErrorPopup::new(event.clone()), area); - self.render_error_effect(frame, area, &event, elapsed_ms); - } - } - if self.instances_state.show_popup { let area = new_instance::popup_rect(frame.area()); new_instance::render(frame, area, self.focused); @@ -232,6 +220,20 @@ impl App { if let Some(update) = &self.modpack_update_popup { widgets::popups::modpack_update::render(frame, update); } + if let Some(state) = self.export_popup.as_mut() { + widgets::popups::export_modpack::render(frame, state); + } + let all_errors = error_buffer::peek_all_errors(); + self.sync_error_effects(&all_errors); + let mut next_y: u16 = 1; + for event in all_errors { + let elapsed_ms = event.pushed_at.elapsed().as_millis(); + if let Some(area) = popup_area(frame.area(), &event.message, next_y, elapsed_ms) { + next_y = next_y.saturating_add(area.height + 1); + frame.render_widget(ErrorPopup::new(event.clone()), area); + self.render_error_effect(frame, area, &event, elapsed_ms); + } + } } pub(super) fn overlay_filtered_lines(&self) -> Vec { diff --git a/src/tui/tests/harness.rs b/src/tui/tests/harness.rs index d29cce8b..377bd5f5 100644 --- a/src/tui/tests/harness.rs +++ b/src/tui/tests/harness.rs @@ -99,6 +99,7 @@ impl UiHarness { instance_manager.instances_dir.clone(), ), instance_settings: None, + export_popup: None, pending_instance_settings_updates: Default::default(), global_settings: None, picker, diff --git a/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__tabs__tests__content_confirmation_sections.snap b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__tabs__tests__content_confirmation_sections.snap new file mode 100644 index 00000000..680fec80 --- /dev/null +++ b/src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__tabs__tests__content_confirmation_sections.snap @@ -0,0 +1,28 @@ +--- +source: src/tui/tests/widgets/content/tabs.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" ╭Install Project───────────────────────────────────────── Modrinth ╮ " +" │ Release │ " +" │ Version 1.0 │ " +" │ Released Unknown │ " +" │ │ " +" │ Runtime │ " +" │ Minecraft 26.3 │ " +" │ Loader Fabric │ " +" │ │ " +" │ Dependencies │ " +" │ Also installs Dependency │ " +" │ Also changes Dependency -> 2.0 │ " +" ╰──────────────────────────[h] back [s] skip deps [Enter] install╯ " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/content/tabs.rs b/src/tui/tests/widgets/content/tabs.rs index 4dbe9ccc..6da51843 100644 --- a/src/tui/tests/widgets/content/tabs.rs +++ b/src/tui/tests/widgets/content/tabs.rs @@ -53,16 +53,6 @@ fn discovery_version_rows_only_show_the_version_number() { assert_eq!(discovery_version_label(&version), "3.2.4-fabric-26.1"); } -#[test] -fn discovery_confirmation_popup_fits_its_summary() { - assert_eq!( - version_popup_height(false, None, false, false, false), - VERSION_POPUP_HEIGHT - ); - assert_eq!(version_popup_height(true, None, false, false, false), 6); - assert_eq!(version_popup_height(true, None, false, true, false), 7); -} - #[test] fn install_summary_checks_selected_mod_release_not_picker_version() { use crate::instance::ContentKind; @@ -217,6 +207,11 @@ fn skipped_dependencies_render_as_skipped_in_confirmation() { let screen = format!("{}", terminal.backend()); assert!(screen.contains("Also changes")); assert!(screen.contains("skip deps")); + assert!(screen.contains("Release")); + assert!(screen.contains("Runtime")); + assert!(screen.contains("Dependencies")); + assert!(screen.contains("Dependency -> 2.0")); + insta::assert_snapshot!("content_confirmation_sections", terminal.backend()); state.version_popup.as_mut().unwrap().skip_dependencies = true; terminal diff --git a/src/tui/tests/widgets/content/update.rs b/src/tui/tests/widgets/content/update.rs index 7aa35c29..9389a44e 100644 --- a/src/tui/tests/widgets/content/update.rs +++ b/src/tui/tests/widgets/content/update.rs @@ -118,7 +118,24 @@ fn update_conflicts_explain_why_the_item_was_not_updated() { ); assert_eq!( state.list.entries[0].description, - "Other selected updates require different versions of Library.\nThis mod was left unchanged; update it separately with v." + "Other selected updates require different versions of Library" ); assert!(state.list.warning_descriptions); } + +#[test] +fn update_errors_only_show_the_cause() { + for (error, expected) in [ + ( + "Parse error: No compatible dependency version found for project 'library'", + "No compatible dependency version found for project 'library'", + ), + ( + "Parse error: Conflicting required versions for 'Library': '1.0' and '2.0'", + "Incompatible required versions of Library", + ), + ("Provider unavailable", "Provider unavailable"), + ] { + assert_eq!(user_conflict_reason(error), expected); + } +} diff --git a/src/tui/tests/widgets/popups/base.rs b/src/tui/tests/widgets/popups/base.rs index e438a106..cccf7fc1 100644 --- a/src/tui/tests/widgets/popups/base.rs +++ b/src/tui/tests/widgets/popups/base.rs @@ -5,6 +5,30 @@ use std::num::NonZeroU16; use super::*; +#[test] +fn summary_columns_align_and_preserve_badge_styles() { + let theme = crate::config::theme::THEME.as_ref(); + let badge = crate::tui::widgets::status_badge(".zip", theme.text_dim()); + let rows = [ + summary_row("Format", Line::from(badge)), + summary_row("Pack name", "Example"), + ]; + let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 3)); + use ratatui::widgets::Paragraph; + Paragraph::new(vec![ + summary_heading("Pack"), + rows[0].clone(), + rows[1].clone(), + ]) + .render(buffer.area, &mut buffer); + assert_eq!(buffer[(2, 0)].symbol(), "P"); + assert!(buffer[(2, 0)].modifier.contains(Modifier::BOLD)); + assert_eq!(buffer[(20, 2)].symbol(), "E"); + assert_eq!(buffer[(21, 1)].symbol(), "."); + assert_eq!(buffer[(21, 1)].bg, theme.text_dim()); + assert_eq!(buffer[(21, 1)].fg, theme.background()); +} + #[test] fn popup_cells_covering_terminal_images_are_always_redrawn() { let area = Rect::new(0, 0, 4, 3); diff --git a/src/tui/tests/widgets/popups/export_modpack.rs b/src/tui/tests/widgets/popups/export_modpack.rs new file mode 100644 index 00000000..0f894701 --- /dev/null +++ b/src/tui/tests/widgets/popups/export_modpack.rs @@ -0,0 +1,819 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use super::*; +use crate::tui::tests::harness::UiHarness; + +fn add_instance(ui: &mut UiHarness, name: &str) { + ui.add_instance(name); + std::fs::create_dir_all(ui.instance_path(name).join("minecraft")).unwrap(); +} + +#[test] +fn export_format_rotates_inline_and_only_multimc_has_a_bundled_badge() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + ui.key(KeyCode::Char('e')); + assert!(ui.app.export_popup.as_ref().unwrap().pending.is_some()); + assert!(!ui.app.export_popup.as_ref().unwrap().text_input_active()); + ui.key(KeyCode::Char('l')); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::Mrpack + ); + ui.draw(); + assert!(ui.screen().contains(".mrpack")); + assert!(!ui.screen().contains("Bundled")); + ui.key(KeyCode::Right); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::Mmc + ); + ui.draw(); + assert!(ui.screen().contains("Bundled")); + ui.key(KeyCode::Char('h')); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::Mrpack + ); + ui.key(KeyCode::Left); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::CurseForge + ); + ui.key(KeyCode::Char('h')); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::Mmc + ); + ui.key(KeyCode::Enter); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().format, + PackFormat::CurseForge + ); + assert!(Field::Author.supported(ui.app.export_popup.as_ref().unwrap().format)); + ui.key(KeyCode::Char('q')); + ui.key(KeyCode::Char('d')); + assert!(!ui.app.exit); + assert!(super::super::confirm::pending_target().is_none()); + ui.key(KeyCode::Esc); + assert!(ui.app.export_popup.is_none()); +} + +#[test] +fn export_form_uses_settings_layout_without_a_review_or_format_picker() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let mut state = State::new(ui.app.instances_state.selected_instance().unwrap()); + state.fields[2] = "Example Author".to_owned(); + state.fields[3] = "~/Downloads/example.zip".to_owned(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(90, 26)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_form", terminal.backend()); + state.rotate_format(false); + assert_eq!(state.fields[3], "~/Downloads/example.zip"); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_format_bundled", terminal.backend()); + assert!(terminal.backend().to_string().contains(".zip Bundled")); + assert!(!terminal.backend().to_string().contains("Review export")); +} + +#[test] +fn export_form_keeps_unsupported_rows_and_geometry_without_helper_text() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let mut state = State::new(ui.app.instances_state.selected_instance().unwrap()); + assert_eq!( + state.fields[2], + std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" }).unwrap_or_default() + ); + state.fields[1] = "2.3.4".to_owned(); + state.fields[2] = "Example Author".to_owned(); + state.fields[3] = "~/Downloads/example.zip".to_owned(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 28)).unwrap(); + let mut positions = None; + for format in PackFormat::ALL { + state.format = format; + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + let screen = terminal.backend().to_string(); + let current = [ + "Export Modpack ·", + "Pack version", + "Author", + "Output file", + "Included files", + "╰", + ] + .map(|label| { + screen + .lines() + .position(|line| line.contains(label)) + .unwrap() + }); + if let Some(expected) = positions { + assert_eq!(current, expected); + } else { + positions = Some(current); + } + assert!(!screen.contains("Cycle the format")); + assert!(!screen.contains("Choose files and folders")); + let cells = terminal.backend().buffer().content(); + let extension = cells + .windows(2) + .position(|pair| { + pair[0].symbol() == "." + && pair[1].symbol() + == if format == PackFormat::Mrpack { + "m" + } else { + "z" + } + }) + .unwrap(); + assert_eq!(cells[extension].bg, THEME.as_ref().text_dim()); + assert_eq!(cells[extension].fg, THEME.as_ref().background()); + if format == PackFormat::Mmc { + assert!(!screen.contains("2.3.4")); + state.selected = Field::Version; + state.begin_edit(&ui.app.instance_manager); + assert!(state.editing.is_none()); + state.selected = Field::Name; + state.move_selection(true); + assert_eq!(state.selected, Field::Output); + state.selected = Field::Format; + } else { + assert!(screen.contains("2.3.4")); + } + assert_eq!(state.fields[1], "2.3.4"); + } +} + +#[test] +fn file_browser_folder_navigation_stops_at_root_and_only_escape_goes_back() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + ui.key(KeyCode::Char('e')); + let state = ui.app.export_popup.as_mut().unwrap(); + state.phase = Phase::Files; + state.browser.directory = PathBuf::from("config/nested"); + ui.key(KeyCode::Char('h')); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().browser.directory, + PathBuf::from("config") + ); + ui.key(KeyCode::Left); + ui.app.export_popup.as_mut().unwrap().browser.selected = 0; + for key in [ + KeyCode::Char('h'), + KeyCode::Left, + KeyCode::Char('h'), + KeyCode::Left, + ] { + ui.key(key); + let state = ui.app.export_popup.as_ref().unwrap(); + assert_eq!(state.phase, Phase::Files); + assert!(state.browser.directory.as_os_str().is_empty()); + assert_eq!(state.browser.selected, 0); + } + ui.key(KeyCode::Esc); + assert_eq!(ui.app.export_popup.as_ref().unwrap().phase, Phase::Form); +} + +#[test] +fn export_edits_only_after_enter_and_escape_discards_the_edit() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + ui.key(KeyCode::Char('e')); + ui.key(KeyCode::Down); + ui.key(KeyCode::Enter); + ui.key(KeyCode::Char('q')); + assert!( + ui.app + .export_popup + .as_ref() + .unwrap() + .editing + .as_ref() + .unwrap() + .lines()[0] + .ends_with('q') + ); + assert!(!ui.app.exit); + ui.key(KeyCode::Esc); + assert_eq!(ui.app.export_popup.as_ref().unwrap().fields[0], "Example"); + ui.key(KeyCode::Enter); + ui.key(KeyCode::Char('q')); + ui.key(KeyCode::Enter); + assert_eq!(ui.app.export_popup.as_ref().unwrap().fields[0], "Exampleq"); + ui.app.export_popup.as_mut().unwrap().selected = Field::Include; + for key in [KeyCode::Enter, KeyCode::Right] { + ui.key(key); + let state = ui.app.export_popup.as_ref().unwrap(); + assert_eq!(state.phase, Phase::Form); + assert!(state.pending.is_some()); + assert!(!keybinds(state).iter().any(|(key, _)| *key == "Enter")); + } + ui.key(KeyCode::Esc); + assert!(ui.app.export_popup.is_none()); +} + +#[test] +fn export_navigation_repeat_and_mouse_wheel_stay_in_the_popup() { + use crossterm::event::{KeyEventKind, KeyModifiers, MouseEventKind}; + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + ui.key(KeyCode::Char('e')); + let selected_instance = ui.app.instances_state.list_state.selected; + ui.mouse(MouseEventKind::ScrollDown); + assert_eq!(ui.app.export_popup.as_ref().unwrap().selected, Field::Name); + assert_eq!( + ui.app.instances_state.list_state.selected, + selected_instance + ); + let mut key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + key.kind = KeyEventKind::Repeat; + assert!(ui.key_event(key)); + assert_eq!( + ui.app.export_popup.as_ref().unwrap().selected, + Field::Version + ); + key.code = KeyCode::Enter; + assert!(!ui.key_event(key)); + assert!(!ui.app.export_popup.as_ref().unwrap().text_input_active()); +} + +#[test] +fn export_drain_reports_task_failure_without_discarding_edits() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let mut state = State::new(ui.app.instances_state.selected_instance().unwrap()); + for _ in 0..2 { + let (sender, receiver) = oneshot::channel(); + state.pending = Some(receiver); + drop(sender); + state.drain(); + assert!( + crate::feedback::errors::peek_all_errors()[0] + .message + .contains("stopped unexpectedly") + ); + assert_eq!(state.phase, Phase::Form); + } + let (sender, receiver) = oneshot::channel(); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + state.resolving.insert( + state.format, + Resolving { + receiver, + task: runtime.spawn(async {}), + }, + ); + assert!(sender.send(Err("Provider unavailable".to_owned())).is_ok()); + state.drain(); + assert!( + crate::feedback::errors::peek_all_errors()[0] + .message + .contains("Provider unavailable") + ); + let count = crate::feedback::errors::peek_all_errors().len(); + state.drain(); + assert_eq!(crate::feedback::errors::peek_all_errors().len(), count); + assert_eq!(state.fields[0], "Example"); +} + +#[test] +fn empty_export_field_uses_a_toast_and_keeps_the_editor_open() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let mut state = State::new(ui.app.instances_state.selected_instance().unwrap()); + state.selected = Field::Name; + state.editing = Some(settings_text_area(vec![String::new()])); + state.commit_edit(); + assert!(state.text_input_active()); + assert_eq!(state.fields[0], "Example"); + assert_eq!( + crate::feedback::errors::peek_all_errors()[0].message, + "Pack name must not be empty" + ); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 28)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + assert!(!terminal.backend().to_string().contains("must not be empty")); +} + +#[test] +fn export_popup_does_not_cover_stacked_toasts() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let mut state = State::new(ui.app.instances_state.selected_instance().unwrap()); + state.phase = Phase::Files; + ui.app.export_popup = Some(state); + for message in [ + "Export toast stays above the file browser", + "Another export error", + ] { + crate::feedback::errors::push_message(tracing::Level::ERROR, message); + } + for event in crate::feedback::errors::ERROR_EVENTS + .lock() + .unwrap() + .iter_mut() + { + event.pushed_at -= std::time::Duration::from_millis(500); + ui.app + .error_effects + .insert(event.id, crate::tui::app::ErrorEffectState::Idle); + } + ui.draw(); + let screen = ui.screen(); + assert!( + screen.contains("Export toast stays above the file browser"), + "{screen}" + ); + assert!(screen.contains("Another export error")); +} + +#[test] +fn export_closes_after_confirmation_and_only_prompts_again_for_overwrites() { + let mut ui = UiHarness::new(); + add_instance(&mut ui, "Example"); + let instance = ui.app.instances_state.selected_instance().unwrap().clone(); + let root = ui.instance_path("Example").join("minecraft"); + std::fs::create_dir_all(root.join("config")).unwrap(); + std::fs::write(root.join("config/settings.txt"), b"settings").unwrap(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + for scenario in ["new", "overwrite", "race"] { + let output = ui.instance_path("Example").join(format!("{scenario}.zip")); + if scenario == "overwrite" { + std::fs::write(&output, b"keep until approved").unwrap(); + } + let mut state = State::new(&instance); + state.format = PackFormat::Mmc; + state.fields[3] = output.display().to_string(); + state.plan = Some( + export::prepare_instance_export( + &ui.app.instance_manager, + "Example", + &state.options(), + ) + .await + .unwrap(), + ); + state.phase = Phase::Confirm; + ui.app.export_popup = Some(state); + let enter = KeyEvent::new(KeyCode::Enter, crossterm::event::KeyModifiers::NONE); + ui.app.handle_key_event(enter).unwrap(); + if scenario == "overwrite" { + assert_eq!( + ui.app.export_popup.as_ref().unwrap().phase, + Phase::Overwrite + ); + assert_eq!(std::fs::read(&output).unwrap(), b"keep until approved"); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); + terminal + .draw(|frame| render(frame, ui.app.export_popup.as_mut().unwrap())) + .unwrap(); + assert!(terminal.backend().to_string().contains("Overwrite file?")); + assert!( + terminal + .backend() + .to_string() + .contains("This will overwrite the existing file") + ); + assert!(!terminal.backend().to_string().contains("existing file.")); + let warning = "This will overwrite the existing file"; + let cells = terminal.backend().buffer().content(); + let start = cells + .windows(warning.len()) + .position(|cells| { + cells.iter().map(|cell| cell.symbol()).collect::() == warning + }) + .unwrap(); + assert!( + cells[start..start + warning.len()] + .iter() + .all(|cell| cell.fg == THEME.as_ref().warning()) + ); + assert!(!terminal.backend().to_string().contains("Runtime")); + ui.app + .handle_key_event(KeyEvent::new( + KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + assert_eq!(ui.app.export_popup.as_ref().unwrap().phase, Phase::Confirm); + assert_eq!(std::fs::read(&output).unwrap(), b"keep until approved"); + ui.app.handle_key_event(enter).unwrap(); + ui.app.handle_key_event(enter).unwrap(); + } + assert!(ui.app.export_popup.is_none()); + assert!(crate::feedback::progress::is_active()); + ui.draw(); + assert!(!ui.screen().contains("Export Complete")); + if scenario == "race" { + std::fs::write(&output, b"created after confirmation").unwrap(); + } + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let finished = if scenario == "race" { + crate::feedback::errors::peek_all_errors() + .iter() + .any(|event| event.message.contains("Output already exists")) + } else { + output.exists() + }; + if finished + && crate::instance::runtime::lock_instance( + &ui.app.instance_manager.instances_dir, + "Example", + ) + .is_ok() + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(ui.app.export_popup.is_none()); + if scenario == "race" { + assert_eq!( + std::fs::read(&output).unwrap(), + b"created after confirmation" + ); + assert!( + crate::feedback::errors::peek_all_errors()[0] + .message + .contains("Output already exists") + ); + } else { + assert_eq!( + crate::instance::import::build_summary(&output) + .unwrap() + .format, + PackFormat::Mmc + ); + assert!( + crate::feedback::errors::peek_all_errors().is_empty(), + "No completion toast should be shown" + ); + } + } + }); +} + +#[test] +fn file_browser_selects_defaults_and_exports_only_chosen_files_in_each_format() { + use crossterm::event::KeyModifiers; + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + for format in PackFormat::ALL { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let instance: InstanceConfig = serde_json::from_value(serde_json::json!({"name":"Example","game_version":"1.21.1","loader":"vanilla","created":"2026-01-01T00:00:00Z"})).unwrap(); + let root = crate::storage::InstancePaths::new(manager.instances_dir.join(&instance.name)).minecraft(); + for (path, data) in [("config/keep.txt", "keep"), ("config/omit.txt", "omit"), ("mods/custom.txt", "custom"), ("options.txt", "options"), ("saves/World/level.dat", "world")] { + std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap(); + std::fs::write(root.join(path), data).unwrap(); + } + manager.save(&instance).unwrap(); + let mut state = State::new(&instance); + state.fields[2] = "Example Author".to_owned(); + state.format = format; + let output = temp.path().join(format!("pack.{}", format.extension())); + state.fields[3] = output.display().to_string(); + state.start_scan(&manager); + state.selected = Field::Include; + state.begin_edit(&manager); + assert_eq!(state.phase, Phase::Form); + state.task.take().unwrap().await.unwrap(); + state.drain(); + if let Some(pending) = state.resolving.remove(&format) { + pending.task.await.unwrap(); + let references = pending.receiver.await.unwrap().unwrap(); + let plan = state.scan.as_ref().unwrap().plan(&state.options(), &state.scan.as_ref().unwrap().paths().cloned().collect(), &references).unwrap(); + state.resolved.insert(format, Ok(Resolved { references, paths: plan.referenced_paths().clone() })); + } + assert!(state.selected_files.contains(std::path::Path::new("config/keep.txt"))); + assert!(!state.selected_files.contains(std::path::Path::new("options.txt"))); + assert!(!state.selected_files.contains(std::path::Path::new("saves/World/level.dat"))); + state.begin_edit(&manager); + assert_eq!(state.phase, Phase::Files); + state.fields[3] = "exports/example.zip".to_owned(); + if format == PackFormat::CurseForge { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_file_browser", terminal.backend()); + let mut compact = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); + compact.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_file_browser_compact", compact.backend()); + } + state.fields[3] = output.display().to_string(); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("config")).unwrap(); + state.enter_directory(); + assert_eq!(state.browser.directory, PathBuf::from("config")); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("config/omit.txt")).unwrap(); + state.toggle_file(); + assert!(!state.selected_files.contains(std::path::Path::new("config/omit.txt"))); + state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &manager); + assert!(state.browser.directory.as_os_str().is_empty()); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("config")).unwrap(); + assert!(matches!(&state.file_entries()[state.browser.selected], files::Entry { total: 2, included: 1, .. })); + state.toggle_file(); + assert!(state.selected_files.contains(std::path::Path::new("config/omit.txt"))); + state.toggle_file(); + assert!(!state.selected_files.contains(std::path::Path::new("config/keep.txt"))); + state.toggle_file(); + state.enter_directory(); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("config/omit.txt")).unwrap(); + state.toggle_file(); + state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &manager); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("options.txt")).unwrap(); + state.toggle_file(); + assert!(state.selected_files.contains(std::path::Path::new("options.txt"))); + if format != PackFormat::Mmc { + state.confirm(&manager); + assert_eq!(state.phase, Phase::Files); + assert!(crate::feedback::errors::peek_all_errors()[0].message.contains("Approve copying")); + assert!(!file_summary(&state, false).iter().any(|line| line.to_string().contains("Approve copying"))); + state.handle_key(&KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), &manager); + assert!(!state.bundle_unmatched); + for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] { + state.handle_key(&KeyEvent::new(KeyCode::Char('c'), modifiers), &manager); + assert!(!state.bundle_unmatched); + } + state.handle_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), &manager); + assert!(state.bundle_unmatched); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("mods")).unwrap(); + state.toggle_file(); + assert!(state.bundle_unmatched, "Removing unmatched files must preserve approval"); + assert_eq!(state.file_entries()[state.browser.selected].path, std::path::Path::new("mods")); + state.toggle_file(); + assert!(!state.bundle_unmatched, "Adding unmatched files must require approval again"); + assert_eq!(state.file_entries()[state.browser.selected].path, std::path::Path::new("mods")); + state.handle_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), &manager); + } + for (width, height) in [(40, 12), (20, 8)] { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + } + state.confirm(&manager); + assert_eq!(state.phase, Phase::Confirm, "{:?}", crate::feedback::errors::peek_all_errors()); + assert!(!output.exists()); + if format == PackFormat::CurseForge { + state.fields[3] = "exports/example.zip".to_owned(); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(90, 26)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_confirmation", terminal.backend()); + let cells = terminal.backend().buffer().content(); + let name = cells.windows(9).position(|cells| cells.iter().map(|cell| cell.symbol()).collect::() == "Pack name").unwrap(); + assert_eq!(cells[name].fg, THEME.as_ref().text_dim()); + assert_eq!(cells[name + 18].fg, THEME.as_ref().text()); + let extension = cells.windows(4).position(|cells| cells.iter().map(|cell| cell.symbol()).collect::() == ".zip").unwrap(); + assert_eq!(cells[extension].bg, THEME.as_ref().text_dim()); + assert_eq!(cells[extension].fg, THEME.as_ref().background()); + for title in ["Pack", "Runtime", "Files"] { assert!(state.confirmation_lines().iter().any(|line| line.to_string() == format!(" {title}"))); } + assert!(!terminal.backend().to_string().contains("Links download on import")); + let long_output = format!("exports/{}TAIL_END.zip", "long-directory/".repeat(12)); + state.fields[3] = long_output.clone(); + let mut wrapped = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 32)).unwrap(); + wrapped.draw(|frame| render(frame, &mut state)).unwrap(); + let visible = wrapped.backend().buffer().content().iter().map(|cell| cell.symbol()).collect::() + .chars().filter(|ch| !ch.is_whitespace() && !matches!(ch, '│' | '─' | '╭' | '╮' | '╰' | '╯')).collect::(); + assert!(visible.contains(&long_output), "Long output paths must wrap without losing text"); + state.fields[3] = output.display().to_string(); + } + state.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &manager); + assert_eq!(state.phase, Phase::Files); + state.confirm(&manager); + state.write(false).unwrap().await.unwrap(); + let mut archive = zip::ZipArchive::new(std::fs::File::open(output).unwrap()).unwrap(); + let prefix = match format { PackFormat::CurseForge => "overrides", PackFormat::Mrpack => "client-overrides", PackFormat::Mmc => ".minecraft" }; + assert!(archive.by_name(&format!("{prefix}/config/keep.txt")).is_ok()); + assert!(archive.by_name(&format!("{prefix}/options.txt")).is_ok()); + assert!(archive.by_name(&format!("{prefix}/config/omit.txt")).is_err()); + assert!(archive.by_name(&format!("{prefix}/saves/World/level.dat")).is_err()); + drop(state); + assert!(crate::instance::runtime::lock_instance(&manager.instances_dir, &instance.name).is_ok()); + } + }); +} + +#[test] +fn file_summary_is_minimal_and_unmatched_names_keep_their_colour() { + let _guard = crate::tests::TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let instance: InstanceConfig = serde_json::from_value(serde_json::json!({"name":"Example","game_version":"1.21.1","loader":"vanilla","created":"2026-01-01T00:00:00Z"})).unwrap(); + let root = crate::storage::InstancePaths::new(manager.instances_dir.join(&instance.name)).minecraft(); + for path in ["config/settings.txt", "mods/matched.jar", "mods/custom.jar", "mods/nested/custom.txt", "shaderpacks/custom.zip"] { + std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap(); + std::fs::write(root.join(path), path.as_bytes()).unwrap(); + } + manager.save(&instance).unwrap(); + let scan = Arc::new(export::scan_instance_export(&manager, &instance.name).await.unwrap()); + let mut state = State::new(&instance); + state.selected_files = scan.defaults.clone(); + state.scan = Some(scan); + state.phase = Phase::Files; + + let pending = file_summary(&state, false).into_iter().map(|line| line.to_string()).collect::>().join("\n"); + assert!(pending.contains("Identifying CurseForge")); + assert!(pending.lines().any(|line| line.contains("Download links") && line.ends_with('…'))); + assert!(state.unmatched_files(&state.selected_files).is_empty()); + assert!(!pending.contains("Copy 5 unmatched")); + state.selected_files = BTreeSet::from([PathBuf::from("mods/nested/custom.txt")]); + state.bundle_unmatched = true; + state.browser.directory = PathBuf::from("mods"); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("mods/custom.jar")).unwrap(); + state.toggle_file(); + assert!(!state.bundle_unmatched, "New files awaiting identification must require fresh approval"); + state.selected_files = state.scan.as_ref().unwrap().defaults.clone(); + state.browser.directory = PathBuf::new(); + state.browser.selected = 0; + state.resolved.insert(PackFormat::CurseForge, Err("Provider unavailable".to_owned())); + assert!(state.unmatched_files(&state.selected_files).is_empty()); + assert!(!file_summary(&state, false).iter().any(|line| line.to_string().contains("Provider unavailable"))); + + for (format, copies, unmatched) in [(PackFormat::CurseForge, 2, 2), (PackFormat::Mrpack, 1, 3), (PackFormat::Mmc, 5, 0)] { + state.format = format; + let downloads = usize::from(format != PackFormat::Mmc); + state.resolved.insert(format, Ok(Resolved { + references: ExportReferences::empty(format), + paths: if downloads > 0 { BTreeSet::from([PathBuf::from("mods/matched.jar")]) } else { BTreeSet::new() }, + })); + let lines = file_summary(&state, false).into_iter().map(|line| line.to_string()).collect::>(); + let summary = lines.join("\n"); + assert_eq!(downloads + copies + unmatched, state.selected_files.len()); + assert!(!summary.contains("Bundled")); + for (label, count) in if format == PackFormat::Mmc { + vec![("Selected", 5), ("File copies", copies)] + } else { + vec![("Selected", 5), ("Download links", downloads), ("Other copies", copies), ("Unmatched", unmatched)] + } { + assert!(lines.iter().any(|line| line.trim_start().starts_with(label) && line.split_whitespace().last() == Some(count.to_string().as_str())), "{summary}"); + } + if format == PackFormat::Mmc { + assert!(!summary.contains("No MultiMC")); + assert!(!summary.contains("Download links")); + } else { + assert!(summary.contains(&format!("Copy {unmatched} unmatched"))); + assert!(!summary.contains("download match:")); + assert!(!summary.contains("custom.jar")); + assert!(!summary.contains("settings.txt")); + assert!(!summary.contains("config/")); + assert!(!summary.contains("shaderpacks/")); + assert!(!summary.contains("mods/")); + assert!(!summary.contains("Links download")); + assert!(!summary.contains("Copies go")); + assert!(summary.contains(" Check licences")); + assert!(!summary.contains("before sharing")); + assert!(lines.len() <= 10, "{summary}"); + state.bundle_unmatched = true; + let approved = file_summary(&state, false).into_iter().map(|line| line.to_string()).collect::>().join("\n"); + assert!(approved.contains(&format!("[✓] Copy {unmatched} unmatched"))); + state.bundle_unmatched = false; + } + if format == PackFormat::CurseForge { + for (directory, selected) in [("", "mods"), ("mods", "mods/nested"), ("mods", "mods/custom.jar"), ("mods/nested", "mods/nested/custom.txt")] { + state.browser.directory = PathBuf::from(directory); + let badge_row = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new(selected)).unwrap(); + for included in [true, false] { + if !included { state.browser.selected = badge_row; state.toggle_file(); } + for selection in [if badge_row == 0 { 1 } else { 0 }, badge_row] { + state.browser.selected = selection; + let area = Rect::new(0, 0, 70, 6); + let mut buffer = Buffer::empty(area); + buffer.set_style(area, Style::default().bg(THEME.as_ref().surface())); + files::render(&state, area, &mut buffer); + let text = buffer.content().iter().map(|cell| cell.symbol()).collect::(); + assert!(!text.contains("Download")); + assert!(!text.contains("Unmatched")); + let row = &buffer.content()[badge_row * 70..(badge_row + 1) * 70]; + let entry = &state.file_entries()[badge_row]; + let name = format!("{}{}", entry.path.file_name().unwrap().to_string_lossy(), if entry.directory { "/" } else { "" }); + let start = row.windows(name.len()).position(|cells| cells.iter().map(|cell| cell.symbol()).collect::() == name).unwrap(); + for cell in &row[start..start + name.len()] { + assert_eq!(cell.fg, THEME.as_ref().warning()); + assert_eq!(cell.bg, if selection == badge_row { THEME.as_ref().stripe() } else { THEME.as_ref().surface() }); + } + } + if !included { + assert!(state.unmatched_files(&state.selected_files).len() < unmatched); + state.browser.selected = badge_row; + state.toggle_file(); + } + } + } + state.browser.directory = PathBuf::new(); + state.browser.selected = 0; + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(130, 34)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_file_breakdown", terminal.backend()); + state.browser.directory = PathBuf::from("mods"); + state.browser.selected = state.file_entries().iter().position(|entry| entry.path == std::path::Path::new("mods/custom.jar")).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!("export_mods_unmatched", terminal.backend()); + state.browser.directory = PathBuf::new(); + state.browser.selected = 0; + } + } + state.format = PackFormat::Mrpack; + for count in [1648, 10000] { + state.selected_files = (0..count).map(|index| PathBuf::from(format!("mods/unmatched-{index}.jar"))).collect(); + for (width, height) in [(100, 28), (130, 34), (80, 24)] { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + let rows = terminal.backend().buffer().content().chunks(width as usize).collect::>(); + let copy = format!("[ ] Copy {count} unmatched"); + let (index, row) = rows.iter().enumerate().find(|(_, row)| row.iter().map(|cell| cell.symbol()).collect::().contains(©)).unwrap(); + let start = row.windows(copy.len()).position(|cells| cells.iter().map(|cell| cell.symbol()).collect::() == copy).unwrap(); + assert!(row[start + copy.len()..start + copy.len() + 2].iter().all(|cell| cell.symbol() == " "), "Copy count needs right padding"); + assert!(rows[index + 1].iter().map(|cell| cell.symbol()).collect::().contains("Check licences"), "Copy count must not wrap"); + } + } + }); +} + +#[tokio::test] +async fn unmatched_highlighting_distinguishes_neighbouring_paths() { + let temp = tempfile::tempdir().unwrap(); + let manager = InstanceManager::new(temp.path().join("instances"), temp.path().join("meta")); + let instance: InstanceConfig = serde_json::from_value(serde_json::json!({"name":"Example","game_version":"1.21.1","loader":"vanilla","created":"2026-01-01T00:00:00Z"})).unwrap(); + let root = + crate::storage::InstancePaths::new(manager.instances_dir.join(&instance.name)).minecraft(); + let matched = BTreeSet::from([ + PathBuf::from("mods/a/matched.jar"), + PathBuf::from("mods/a.jar"), + PathBuf::from("mods/z.jar"), + ]); + let unmatched = BTreeSet::from([ + PathBuf::from("mods/a-extra/custom.jar"), + PathBuf::from("mods/a.jar-extra/custom.jar"), + PathBuf::from("mods/custom.jar"), + ]); + for path in matched.iter().chain(&unmatched) { + std::fs::create_dir_all(root.join(path).parent().unwrap()).unwrap(); + std::fs::write(root.join(path), b"mod").unwrap(); + } + manager.save(&instance).unwrap(); + let mut state = State::new(&instance); + state.scan = Some(Arc::new( + export::scan_instance_export(&manager, &instance.name) + .await + .unwrap(), + )); + state.resolved.insert( + PackFormat::CurseForge, + Ok(Resolved { + references: ExportReferences::empty(PackFormat::CurseForge), + paths: matched, + }), + ); + for format in [PackFormat::CurseForge, PackFormat::Mmc] { + state.format = format; + for directory in ["", "mods", "mods/a", "mods/a-extra", "mods/a.jar-extra"] { + state.browser.directory = PathBuf::from(directory); + let area = Rect::new(0, 0, 80, 10); + let mut buffer = Buffer::empty(area); + files::render(&state, area, &mut buffer); + for (index, entry) in state.file_entries().iter().enumerate() { + let expected = if format != PackFormat::Mmc + && unmatched.iter().any(|file| file.starts_with(&entry.path)) + { + THEME.as_ref().warning() + } else if entry.directory { + THEME.as_ref().text() + } else { + THEME.as_ref().text_dim() + }; + assert_eq!( + buffer[(6, index as u16)].fg, + expected, + "{format:?}: {}", + entry.path.display() + ); + } + } + } +} diff --git a/src/tui/tests/widgets/popups/import_modpack/render.rs b/src/tui/tests/widgets/popups/import_modpack/render.rs index 2dedfefa..6aeab1d4 100644 --- a/src/tui/tests/widgets/popups/import_modpack/render.rs +++ b/src/tui/tests/widgets/popups/import_modpack/render.rs @@ -160,7 +160,7 @@ fn import_modpack_renders_confirm_step() { }); } - let backend = TestBackend::new(60, 14); + let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) @@ -173,7 +173,22 @@ fn modpack_confirmation_area_fits_its_summary() { let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_import_state(ImportStep::Confirm); - assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 8); + { + let mut state = IMPORT_STATE.lock().unwrap(); + state.summary = Some(crate::instance::import::ImportSummary { + name: "Pack".into(), + pack_version: "1.0".into(), + game_version: "1.21.1".into(), + loader: crate::instance::models::ModLoader::Vanilla, + loader_version: None, + mod_count: 1, + override_count: 2, + format: crate::instance::import::PackFormat::Mrpack, + archive_path: std::path::PathBuf::from("pack.mrpack"), + source: None, + }); + } + assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 13); } #[test] @@ -201,7 +216,7 @@ fn import_modpack_renders_confirm_step_without_loader_version() { }); } - let backend = TestBackend::new(60, 14); + let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render(f, f.area(), FocusedArea::ImportPopup)) diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap index 1f25b20a..f4d4f066 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step.snap @@ -2,17 +2,23 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Install Modpack───────────────────────────────────────────╮" -"│Name: Synthetic Pack │" -"│Pack Version: 1.0.0 │" -"│MC Version: 1.20.1 │" -"│Loader: Fabric 0.15.0 │" -"│Mods: 42 files │" -"│Overrides: 3 files │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"╰─────────────────────────────────[h] back [Enter] install╯" +"╭Install Modpack───────────────────────────────────────────────────────────────╮" +"│ Pack │" +"│ Pack name Synthetic Pack │" +"│ Pack version 1.0.0 │" +"│ │" +"│ Runtime │" +"│ Minecraft 1.20.1 │" +"│ Loader Fabric 0.15.0 │" +"│ │" +"│ Files │" +"│ Mods 42 files │" +"│ Overrides 3 files │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰─────────────────────────────────────────────────────[h] back [Enter] install╯" diff --git a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap index 2099ee4f..d868a71b 100644 --- a/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap +++ b/src/tui/tests/widgets/popups/import_modpack/snapshots/rmcl__tui__widgets__popups__import_modpack__render__tests__import_modpack_renders_confirm_step_without_loader_version.snap @@ -2,17 +2,23 @@ source: src/tui/tests/widgets/popups/import_modpack/render.rs expression: terminal.backend() --- -"╭Install Modpack───────────────────────────────────────────╮" -"│Name: Vanilla Pack │" -"│Pack Version: 2.0 │" -"│MC Version: 1.20.1 │" -"│Loader: Vanilla │" -"│Mods: 0 files │" -"│Overrides: 12 files │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"╰─────────────────────────────────[h] back [Enter] install╯" +"╭Install Modpack───────────────────────────────────────────────────────────────╮" +"│ Pack │" +"│ Pack name Vanilla Pack │" +"│ Pack version 2.0 │" +"│ │" +"│ Runtime │" +"│ Minecraft 1.20.1 │" +"│ Loader Vanilla │" +"│ │" +"│ Files │" +"│ Mods 0 files │" +"│ Overrides 12 files │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰─────────────────────────────────────────────────────[h] back [Enter] install╯" diff --git a/src/tui/tests/widgets/popups/modpack_update.rs b/src/tui/tests/widgets/popups/modpack_update.rs new file mode 100644 index 00000000..38daeb60 --- /dev/null +++ b/src/tui/tests/widgets/popups/modpack_update.rs @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use super::*; + +#[test] +fn review_groups_pack_runtime_and_files_without_losing_versions() { + let summary = crate::instance::import::ImportSummary { + name: "Example Pack".into(), + pack_version: "2.0".into(), + game_version: "1.21.1".into(), + loader: crate::instance::models::ModLoader::Fabric, + loader_version: Some("0.16.0".into()), + mod_count: 42, + override_count: 3, + format: crate::instance::import::PackFormat::Mrpack, + archive_path: "pack.mrpack".into(), + source: None, + }; + let lines = review_lines(&summary, "1.0", "2.0"); + assert_eq!(lines.len(), 11); + for (index, title) in [(0, "Pack"), (4, "Runtime"), (8, "Files")] { + assert_eq!(lines[index].to_string(), format!(" {title}")); + } + let text = lines + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + for value in [ + "Example Pack", + "1.0 → 2.0", + "1.21.1", + "Fabric 0.16.0", + "42", + "3", + ] { + assert!(text.contains(value)); + } + let summary = Paragraph::new(lines).wrap(Wrap { trim: false }); + assert_eq!(summary.line_count(66), 11); +} diff --git a/src/tui/tests/widgets/popups/new_instance/render.rs b/src/tui/tests/widgets/popups/new_instance/render.rs index acc6d319..b9bd0c06 100644 --- a/src/tui/tests/widgets/popups/new_instance/render.rs +++ b/src/tui/tests/widgets/popups/new_instance/render.rs @@ -122,7 +122,7 @@ fn new_instance_renders_confirm_step() { guard.name_state = TextState::new().with_value("MyPack"); } - let backend = TestBackend::new(60, 12); + let backend = TestBackend::new(80, 20); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|f| render(f, f.area(), FocusedArea::Popup)) @@ -135,5 +135,5 @@ fn new_instance_confirmation_area_fits_its_summary() { let _serial = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset_wizard_state(WizardStep::Confirm); - assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 6); + assert_eq!(popup_rect(Rect::new(0, 0, 100, 30)).height, 9); } diff --git a/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap index 7b832652..6b6258fb 100644 --- a/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap +++ b/src/tui/tests/widgets/popups/new_instance/snapshots/rmcl__tui__widgets__popups__new_instance__render__tests__new_instance_renders_confirm_step.snap @@ -2,15 +2,23 @@ source: src/tui/tests/widgets/popups/new_instance/render.rs expression: terminal.backend() --- -"╭New Instance──────────────────────────────────────────────╮" -"│Name: MyPack │" -"│MC: 1.20.1 │" -"│Loader: Fabric │" -"│Loader version: 0.15.0 │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"│ │" -"╰──────────────────────────────────[h] back [Enter] create╯" +"╭New Instance──────────────────────────────────────────────────────────────────╮" +"│ Instance │" +"│ Name MyPack │" +"│ │" +"│ Runtime │" +"│ Minecraft 1.20.1 │" +"│ Loader Fabric │" +"│ Loader version 0.15.0 │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"│ │" +"╰──────────────────────────────────────────────────────[h] back [Enter] create╯" diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_confirmation.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_confirmation.snap new file mode 100644 index 00000000..bea436c2 --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_confirmation.snap @@ -0,0 +1,30 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" ╭Export Modpack─────────────────────────────────────────────╮ " +" │ Pack │ " +" │ Format CurseForge .zip │ " +" │ Pack name Example │ " +" │ Pack version 1.0.0 │ " +" │ │ " +" │ Runtime │ " +" │ Minecraft 1.21.1 │ " +" │ Loader Vanilla │ " +" │ │ " +" │ Files │ " +" │ Output file exports/example.zip │ " +" │ Included files 3 selected │ " +" │ Download links 0 │ " +" │ File copies 3 (1 unmatched) │ " +" ╰─────────────────────────────────[Esc] back [Enter] export╯ " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_breakdown.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_breakdown.snap new file mode 100644 index 00000000..887da68c --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_breakdown.snap @@ -0,0 +1,38 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" ╭ Included Files · Example ──────────────────────────────────────────────────────────────────────────╮ " +" │minecraft/ │ " +" │▌ [✓] config/ 1/1 │ Selection │ " +" │ [✓] mods/ 3/3 │ Selected 5 │ " +" │ [✓] shaderpacks/ 1/1 │ Download links 1 │ " +" │ │ Other copies 2 │ " +" │ │ Unmatched 2 │ " +" │ │ Excluded 0 │ " +" │ │ │ " +" │ │ [ ] Copy 2 unmatched │ " +" │ │ Check licences │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" ╰───────────────────────────────[Space] toggle [Enter] continue [c] copy [Esc] back [h/l] folders╯ " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser.snap new file mode 100644 index 00000000..73d86193 --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser.snap @@ -0,0 +1,34 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" ╭ Included Files · Example ──────────────────────────────────────────────────────────────────╮ " +" │minecraft/ │ " +" │▌ [✓] config/ 2/2 │ Selection │ " +" │ [✓] mods/ 1/1 │ Selected 3 │ " +" │ [ ] saves/ 0/1 │ Download links 0 │ " +" │ [ ] options.txt │ Other copies 2 │ " +" │ │ Unmatched 1 │ " +" │ │ Excluded 0 │ " +" │ │ │ " +" │ │ [ ] Copy 1 unmatched │ " +" │ │ Check licences │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" ╰───────────────────────[Space] toggle [Enter] continue [c] copy [Esc] back [h/l] folders╯ " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser_compact.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser_compact.snap new file mode 100644 index 00000000..78df0971 --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser_compact.snap @@ -0,0 +1,28 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: compact.backend() +--- +" " +" " +" " +" " +" ╭ Included Files · Example ──────────────────────────────────╮ " +" │minecraft/ │ " +" │3 selected · 0 links · 2 copies · 1 unmatched │ " +" │[ ] Copy 1 unmatched │ " +" │Check licences │ " +" │▌ [✓] config/ 2/2│ " +" │ [✓] mods/ 1/1│ " +" │ [ ] saves/ 0/1│ " +" │ [ ] options.txt │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" │ │ " +" ╰──────[Space] toggle [Enter] continue [c] copy [Esc] back╯ " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_form.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_form.snap new file mode 100644 index 00000000..169bfa37 --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_form.snap @@ -0,0 +1,30 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" ╭ Export Modpack · Example ──────────────────────────────────╮ " +" │ Pack │ " +" │▌ Format CurseForge .zip │ " +" │ Pack name Example │ " +" │ Pack version 1.0.0 │ " +" │ Author Example Author │ " +" │ │ " +" │ Files │ " +" │ Output file ~/Downloads/example.zip │ " +" │ Included files Not scanned │ " +" ╰─────────────────────[h/l] adjust [Enter] next [Esc] close╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_format_bundled.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_format_bundled.snap new file mode 100644 index 00000000..45af9f9f --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_format_bundled.snap @@ -0,0 +1,30 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" " +" " +" ╭ Export Modpack · Example ──────────────────────────────────╮ " +" │ Pack │ " +" │▌ Format MultiMC/Prism .zip Bundled │ " +" │ Pack name Example │ " +" │ Pack version — │ " +" │ Author — │ " +" │ │ " +" │ Files │ " +" │ Output file ~/Downloads/example.zip │ " +" │ Included files Not scanned │ " +" ╰─────────────────────[h/l] adjust [Enter] next [Esc] close╯ " +" " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_mods_unmatched.snap b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_mods_unmatched.snap new file mode 100644 index 00000000..259afc1e --- /dev/null +++ b/src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_mods_unmatched.snap @@ -0,0 +1,38 @@ +--- +source: src/tui/tests/widgets/popups/export_modpack.rs +expression: terminal.backend() +--- +" " +" " +" " +" " +" " +" " +" ╭ Included Files · Example ──────────────────────────────────────────────────────────────────────────╮ " +" │minecraft/mods/ │ " +" │ [✓] nested/ 1/1 │ Selection │ " +" │▌ [✓] custom.jar │ Selected 5 │ " +" │ [✓] matched.jar │ Download links 1 │ " +" │ │ Other copies 2 │ " +" │ │ Unmatched 2 │ " +" │ │ Excluded 0 │ " +" │ │ │ " +" │ │ [ ] Copy 2 unmatched │ " +" │ │ Check licences │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" │ │ │ " +" ╰───────────────────────────────[Space] toggle [Enter] continue [c] copy [Esc] back [h/l] folders╯ " +" " +" " +" " +" " +" " +" " diff --git a/src/tui/widgets/content/tabs.rs b/src/tui/widgets/content/tabs.rs index 1e4ad5e9..2a6a38c7 100644 --- a/src/tui/widgets/content/tabs.rs +++ b/src/tui/widgets/content/tabs.rs @@ -483,6 +483,7 @@ pub fn render( ("Esc", " kill"), ("a", " add"), ("m", " modpacks"), + ("e", " export"), ("u", " update"), ("d", " delete"), ("r", " rename"), @@ -1406,19 +1407,6 @@ pub(crate) fn render_version_popup( .dependency_plan .as_ref() .is_some_and(|plan| plan.has_dependency_changes()); - let popup_area = area.centered( - Constraint::Percentage(50), - Constraint::Length( - version_popup_height( - popup.confirming, - popup.dependency_plan.as_ref(), - popup.target_world.is_some(), - compatibility_warning.is_some(), - skip_dependencies && dependencies_changeable, - ) - .min(area.height.saturating_sub(2)), - ), - ); let theme = THEME.as_ref(); let title = popup.title(); let loading = popup.loading; @@ -1474,6 +1462,59 @@ pub(crate) fn render_version_popup( .as_ref() .map(|plan| plan.optional_dependencies) .unwrap_or_default(); + use crate::tui::widgets::popups::base::{summary_heading, summary_row}; + let mut summary = vec![ + summary_heading("Release"), + summary_row("Version", selected_version.clone()), + summary_row("Released", release_date), + Line::default(), + summary_heading("Runtime"), + summary_row("Minecraft", minecraft_versions), + summary_row("Loader", loaders), + ]; + if let Some(world) = &target_world { + summary.push(summary_row("World", world.clone())); + } + if skip_dependencies && dependencies_changeable + || !dependency_installs.is_empty() + || !dependency_replacements.is_empty() + || optional_dependencies > 0 + { + summary.extend([Line::default(), summary_heading("Dependencies")]); + if skip_dependencies && dependencies_changeable { + summary.push(summary_row("Required", "Skipped")); + } else { + if !dependency_installs.is_empty() { + summary.push(summary_row("Also installs", dependency_installs)); + } + if !dependency_replacements.is_empty() { + summary.push(summary_row("Also changes", dependency_replacements)); + } + if optional_dependencies > 0 { + summary.push(summary_row( + "Optional skipped", + optional_dependencies.to_string(), + )); + } + } + } + if let Some(warning) = compatibility_warning { + summary.extend([ + Line::default(), + Line::styled(warning, Style::default().fg(theme.warning())), + ]); + } + let summary = Paragraph::new(summary).wrap(Wrap { trim: false }); + let width = area.width * if confirming { 68 } else { 50 } / 100; + let height = if confirming { + summary.line_count(width.saturating_sub(2)) as u16 + 2 + } else { + VERSION_POPUP_HEIGHT + }; + let popup_area = area.centered( + Constraint::Length(width), + Constraint::Length(height.min(area.height.saturating_sub(2))), + ); let can_switch_provider = popup.sources.len() > 1; let current_version_id = popup.current_version_id.clone(); let items = if selecting_minecraft_version { @@ -1574,44 +1615,7 @@ pub(crate) fn render_version_popup( .wrap(Wrap { trim: true }) .render(area, buffer); } else if confirming { - let mut rows = vec![ - ("Version", selected_version.as_str()), - ("Minecraft", minecraft_versions.as_str()), - ("Loader", loaders.as_str()), - ("Released", release_date.as_str()), - ]; - if let Some(world) = target_world.as_deref() { - rows.push(("World", world)); - } - let optional_text; - if skip_dependencies && dependencies_changeable { - rows.push(("Dependencies", "Skipped")); - } else { - if !dependency_installs.is_empty() { - rows.push(("Also installs", dependency_installs.as_str())); - } - if !dependency_replacements.is_empty() { - rows.push(("Also changes", dependency_replacements.as_str())); - } - if optional_dependencies > 0 { - optional_text = optional_dependencies.to_string(); - rows.push(("Optional not installed", optional_text.as_str())); - } - } - crate::tui::widgets::popups::base::render_summary(&rows, area, buffer); - if let Some(warning) = compatibility_warning.as_deref() { - let summary_height = rows.len() as u16; - Paragraph::new(warning) - .style(Style::default().fg(THEME.as_ref().warning())) - .render( - Rect { - y: area.y.saturating_add(summary_height), - height: area.height.saturating_sub(summary_height).min(1), - ..area - }, - buffer, - ); - } + summary.clone().render(area, buffer); } else if items.is_empty() { Paragraph::new(if selecting_minecraft_version { "No compatible Minecraft versions found." @@ -1692,30 +1696,6 @@ fn world_picker_keybinds(has_worlds: bool) -> &'static [(&'static str, &'static } } -fn version_popup_height( - confirming: bool, - plan: Option<&crate::instance::content::dependencies::DependencyPlan>, - has_world: bool, - has_warning: bool, - skipped: bool, -) -> u16 { - if !confirming { - return VERSION_POPUP_HEIGHT; - } - let Some(plan) = plan else { - return 6 + u16::from(has_world) + u16::from(has_warning); - }; - 6 + u16::from(has_world) - + u16::from(has_warning) - + if skipped { - 1 - } else { - u16::from(plan.dependency_installs().next().is_some()) - + u16::from(plan.dependency_replacements().next().is_some()) - + u16::from(plan.optional_dependencies > 0) - } -} - fn confirmation_values(values: &[String]) -> String { if values.is_empty() { "Unknown".to_owned() diff --git a/src/tui/widgets/content/update.rs b/src/tui/widgets/content/update.rs index d7a8a5c7..44668b88 100644 --- a/src/tui/widgets/content/update.rs +++ b/src/tui/widgets/content/update.rs @@ -321,20 +321,14 @@ fn user_conflict_reason(reason: &str) -> String { if let Some(details) = reason.strip_prefix("Conflicting selected versions for '") && let Some((dependency, _)) = details.split_once("': '") { - return format!( - "Other selected updates require different versions of {dependency}.\nThis mod was left unchanged; update it separately with v." - ); + return format!("Other selected updates require different versions of {dependency}"); } if let Some(details) = reason.strip_prefix("Conflicting required versions for '") && let Some((dependency, _)) = details.split_once("': '") { - return format!( - "This update requires incompatible versions of {dependency}.\nThis mod was left unchanged; choose another version with v." - ); + return format!("Incompatible required versions of {dependency}"); } - format!( - "This update could not be prepared: {reason}. The installed version was left unchanged; try updating it separately with v." - ) + reason.to_owned() } #[cfg(test)] diff --git a/src/tui/widgets/popups/base.rs b/src/tui/widgets/popups/base.rs index a54771cc..409f9034 100644 --- a/src/tui/widgets/popups/base.rs +++ b/src/tui/widgets/popups/base.rs @@ -4,9 +4,9 @@ use ratatui::{ buffer::{Buffer, CellDiffOption}, layout::{Alignment, Rect}, - style::{Color, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Clear, Paragraph, Widget}, + widgets::{Block, Clear, Widget}, }; use crate::config::{settings::ShortcutHintScope, theme::BORDER_STYLE}; @@ -76,19 +76,24 @@ impl<'a> Widget for PopupFrame<'a> { } } -pub fn render_summary(rows: &[(&str, &str)], area: Rect, buf: &mut Buffer) { +pub fn summary_heading(title: &str) -> Line<'static> { let theme = crate::config::theme::THEME.as_ref(); - let label_style = Style::default().fg(theme.text_dim()); - let lines = rows - .iter() - .map(|(label, value)| { - Line::from(vec![ - Span::styled(format!("{label}: "), label_style), - Span::styled(*value, Style::default().fg(theme.text())), - ]) - }) - .collect::>(); - Paragraph::new(lines).render(area, buf); + Line::styled( + format!(" {title}"), + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ) +} + +pub fn summary_row<'a>(label: &str, value: impl Into>) -> Line<'a> { + let theme = crate::config::theme::THEME.as_ref(); + let mut spans = vec![Span::styled( + format!(" {label:<18}"), + Style::default().fg(theme.text_dim()), + )]; + spans.extend(value.into().spans); + Line::from(spans).style(Style::default().fg(theme.text())) } #[cfg(test)] diff --git a/src/tui/widgets/popups/export_modpack.rs b/src/tui/widgets/popups/export_modpack.rs new file mode 100644 index 00000000..d39cafca --- /dev/null +++ b/src/tui/widgets/popups/export_modpack.rs @@ -0,0 +1,979 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +mod files; + +use std::collections::{BTreeSet, HashMap}; +use std::path::PathBuf; +use std::sync::Arc; + +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + buffer::Buffer, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Widget, Wrap}, +}; +use ratatui_textarea::TextArea; +use tokio::{sync::oneshot, task::JoinHandle}; + +use super::base::{PopupFrame, summary_heading, summary_row}; +use super::settings_controls::{bundled_label, handle_text_area_input, settings_text_area}; +use crate::config::theme::{BORDER_STYLE, THEME}; +use crate::instance::export::{ + self, ExportOptions, ExportPlan, ExportReferences, ExportScan, PackFormat, +}; +use crate::instance::{InstanceConfig, InstanceManager}; +use crate::tui::widgets::status_badge; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Phase { + Form, + Files, + Confirm, + Overwrite, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Field { + Format, + Name, + Version, + Author, + Output, + Include, +} + +impl Field { + const ALL: [Self; 6] = [ + Self::Format, + Self::Name, + Self::Version, + Self::Author, + Self::Output, + Self::Include, + ]; + + fn supported(self, format: PackFormat) -> bool { + match self { + Self::Version => format != PackFormat::Mmc, + Self::Author => format == PackFormat::CurseForge, + _ => true, + } + } + + fn text_index(self) -> Option { + match self { + Self::Name => Some(0), + Self::Version => Some(1), + Self::Author => Some(2), + Self::Output => Some(3), + _ => None, + } + } + fn label(self) -> &'static str { + match self { + Self::Format => "Format", + Self::Name => "Pack name", + Self::Version => "Pack version", + Self::Author => "Author", + Self::Output => "Output file", + Self::Include => "Included files", + } + } +} + +struct Resolved { + references: ExportReferences, + paths: BTreeSet, +} + +struct Resolving { + receiver: oneshot::Receiver>, + task: JoinHandle<()>, +} + +pub struct State { + instance: InstanceConfig, + format: PackFormat, + fields: [String; 4], + selected: Field, + editing: Option>, + phase: Phase, + scan: Option>, + selected_files: BTreeSet, + browser: files::Browser, + resolved: HashMap>, + resolving: HashMap, + bundle_unmatched: bool, + plan: Option, + pending: Option, String>>>, + task: Option>, +} + +impl Drop for State { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + for pending in self.resolving.values() { + pending.task.abort(); + } + } +} + +impl State { + pub fn new(instance: &InstanceConfig) -> Self { + let directory = dirs::download_dir() + .or_else(dirs::home_dir) + .unwrap_or_default(); + let output = directory.join(format!("{}-1.0.0.zip", instance.name)); + Self { + instance: instance.clone(), + format: PackFormat::CurseForge, + fields: [ + instance.name.clone(), + "1.0.0".to_owned(), + std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" }).unwrap_or_default(), + output.display().to_string(), + ], + selected: Field::Format, + editing: None, + phase: Phase::Form, + scan: None, + selected_files: BTreeSet::new(), + browser: files::Browser::default(), + resolved: HashMap::new(), + resolving: HashMap::new(), + bundle_unmatched: false, + plan: None, + pending: None, + task: None, + } + } + + pub fn start_scan(&mut self, manager: &InstanceManager) { + let manager = InstanceManager::new(manager.instances_dir.clone(), manager.meta_dir.clone()); + let name = self.instance.name.clone(); + let (sender, receiver) = oneshot::channel(); + self.pending = Some(receiver); + self.task = Some(tokio::spawn(async move { + let result = export::scan_instance_export(&manager, &name) + .await + .map(Arc::new) + .map_err(|error| error.to_string()); + let _ = sender.send(result); + crate::feedback::request_redraw(); + })); + } + + fn identify(&mut self) { + if self.resolved.contains_key(&self.format) || self.resolving.contains_key(&self.format) { + return; + } + let Some(scan) = self.scan.clone() else { + return; + }; + if self.format == PackFormat::Mmc { + self.resolved.insert( + self.format, + Ok(Resolved { + references: ExportReferences::Bundled, + paths: BTreeSet::new(), + }), + ); + return; + } + let format = self.format; + let (sender, receiver) = oneshot::channel(); + let task = tokio::spawn(async move { + let result = export::resolve_export_files(&scan, format) + .await + .map_err(|error| error.to_string()); + let _ = sender.send(result); + crate::feedback::request_redraw(); + }); + self.resolving.insert(format, Resolving { receiver, task }); + } + + pub fn text_input_active(&self) -> bool { + self.editing.is_some() + } + + pub fn drain(&mut self) { + let result = match self.pending.as_mut().map(oneshot::Receiver::try_recv) { + Some(Ok(result)) => Some(result), + Some(Err(oneshot::error::TryRecvError::Closed)) => { + Some(Err("Export task stopped unexpectedly".to_owned())) + } + _ => None, + }; + if let Some(result) = result { + self.pending = None; + self.task = None; + match result { + Ok(scan) => { + self.selected_files = scan.defaults.clone(); + self.scan = Some(scan); + self.identify(); + } + Err(error) => { + crate::feedback::errors::push_message(tracing::Level::ERROR, error); + } + } + } + for format in PackFormat::ALL { + let result = match self + .resolving + .get_mut(&format) + .map(|pending| pending.receiver.try_recv()) + { + Some(Ok(result)) => Some(result), + Some(Err(oneshot::error::TryRecvError::Closed)) => Some(Err( + "Content identification task stopped unexpectedly".to_owned(), + )), + _ => None, + }; + if let Some(result) = result { + self.resolving.remove(&format); + let resolved = result.and_then(|references| { + let scan = self.scan.as_ref().ok_or("Export scan is unavailable")?; + let mut options = ExportOptions::for_instance(&self.instance); + options.format = format; + let all = scan.paths().cloned().collect(); + let plan = scan + .plan(&options, &all, &references) + .map_err(|error| error.to_string())?; + Ok(Resolved { + references, + paths: plan.referenced_paths().clone(), + }) + }); + if let Err(error) = &resolved { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("{}: {error}", format.label()), + ); + } + self.resolved.insert(format, resolved); + } + } + } + + fn referenced_paths(&self) -> Option<&BTreeSet> { + self.resolved + .get(&self.format) + .and_then(|result| result.as_ref().ok()) + .map(|resolved| &resolved.paths) + } + + fn unmatched_files(&self, selected: &BTreeSet) -> BTreeSet { + let empty = BTreeSet::new(); + self.scan.as_ref().map_or_else(BTreeSet::new, |scan| { + if scan.needs_references(self.format, selected) && self.referenced_paths().is_none() { + return BTreeSet::new(); + } + scan.unmatched( + self.format, + selected, + self.referenced_paths().unwrap_or(&empty), + ) + .into_iter() + .collect() + }) + } + + fn options(&self) -> ExportOptions { + let mut options = ExportOptions::for_instance(&self.instance); + options.format = self.format; + options.name = self.fields[0].clone(); + options.version = self.fields[1].clone(); + options.author = self.fields[2].clone(); + options.bundle_unmatched = self.bundle_unmatched; + options + } + + fn output(&self) -> PathBuf { + crate::config::settings::resolve_path(&self.fields[3]) + } + + fn confirmation_lines(&self) -> Vec> { + let Some(plan) = &self.plan else { + return Vec::new(); + }; + let theme = THEME.as_ref(); + let mut format = vec![ + Span::raw(self.format.label()), + Span::raw(" "), + status_badge(format!(".{}", self.format.extension()), theme.text_dim()), + ]; + if self.format == PackFormat::Mmc { + format.extend([Span::raw(" "), bundled_label()]); + } + let mut rows = vec![ + summary_heading("Pack"), + summary_row("Format", Line::from(format)), + summary_row("Pack name", plan.options.name.clone()), + ]; + if Field::Version.supported(self.format) { + rows.push(summary_row("Pack version", plan.options.version.clone())); + } + rows.extend([ + Line::default(), + summary_heading("Runtime"), + summary_row("Minecraft", plan.options.game_version.clone()), + summary_row( + "Loader", + format!( + "{} {}", + plan.options.loader, + plan.options.loader_version.as_deref().unwrap_or("") + ) + .trim_end() + .to_owned(), + ), + Line::default(), + summary_heading("Files"), + summary_row("Output file", self.output().display().to_string()), + summary_row("Included files", format!("{} selected", plan.file_count())), + ]); + let copies = plan.file_count() - plan.referenced_count(); + let mut contents = copies.to_string(); + if !plan.unmatched.is_empty() { + contents.push_str(&format!(" ({} unmatched)", plan.unmatched.len())); + } + if self.format != PackFormat::Mmc { + rows.push(summary_row( + "Download links", + plan.referenced_count().to_string(), + )); + } + rows.push(summary_row("File copies", contents)); + rows + } + + fn confirm(&mut self, manager: &InstanceManager) { + let result = (|| -> export::ExportResult { + let scan = self.scan.as_ref().ok_or("Files are still being scanned")?; + let root = + crate::storage::InstancePaths::new(manager.instances_dir.join(&self.instance.name)) + .minecraft(); + export::validate_output_path(&root, &self.output())?; + let empty = ExportReferences::empty(self.format); + let references = match self.resolved.get(&self.format) { + Some(Ok(resolved)) => &resolved.references, + _ if !scan.needs_references(self.format, &self.selected_files) => &empty, + Some(Err(error)) => return Err(error.clone().into()), + _ => return Err("Content is still being identified; wait before continuing".into()), + }; + let plan = scan.plan(&self.options(), &self.selected_files, references)?; + if !plan.unmatched.is_empty() && !self.bundle_unmatched { + return Err("Approve copying unmatched files with c, or deselect them".into()); + } + Ok(plan) + })(); + match result { + Ok(plan) => { + self.plan = Some(plan); + self.phase = Phase::Confirm; + } + Err(error) => { + crate::feedback::errors::push_message(tracing::Level::ERROR, error.to_string()) + } + } + } + + fn write(&mut self, overwrite: bool) -> Option> { + let plan = self.plan.take()?; + let output = self.output(); + let progress = crate::feedback::progress::ProgressTask::start("Exporting modpack"); + Some(tokio::spawn(async move { + let _progress = progress; + if let Err(error) = export::write_export_with_overwrite(plan, &output, overwrite).await + { + crate::feedback::errors::push_message(tracing::Level::ERROR, error.to_string()); + } + crate::feedback::request_redraw(); + })) + } + + fn rotate_format(&mut self, forward: bool) { + let index = PackFormat::ALL + .iter() + .position(|format| *format == self.format) + .unwrap(); + self.format = + PackFormat::ALL[(index + if forward { 1 } else { 2 }) % PackFormat::ALL.len()]; + self.fields[3] = PathBuf::from(&self.fields[3]) + .with_extension(self.format.extension()) + .display() + .to_string(); + self.bundle_unmatched = false; + self.identify(); + } + + fn begin_edit(&mut self, manager: &InstanceManager) { + if !self.selected.supported(self.format) { + return; + } + match self.selected { + Field::Format => self.rotate_format(true), + Field::Include => { + if self.scan.is_none() { + if self.pending.is_none() { + self.start_scan(manager); + } + } else { + self.phase = Phase::Files; + } + } + field => { + self.editing = field + .text_index() + .map(|index| settings_text_area(vec![self.fields[index].clone()])) + } + } + } + + fn commit_edit(&mut self) { + let Some(editor) = self.editing.as_ref() else { + return; + }; + let value = editor.lines().join(""); + if let Some(index) = self.selected.text_index() { + if self.selected != Field::Author && value.trim().is_empty() { + crate::feedback::errors::push_message( + tracing::Level::ERROR, + format!("{} must not be empty", self.selected.label()), + ); + return; + } + self.fields[index] = value; + } + self.editing = None; + } + + fn move_selection(&mut self, forward: bool) { + let fields = Field::ALL + .into_iter() + .filter(|field| field.supported(self.format)) + .collect::>(); + let index = fields + .iter() + .position(|field| *field == self.selected) + .unwrap_or(0); + self.selected = fields[(index + if forward { 1 } else { fields.len() - 1 }) % fields.len()]; + } + + pub fn handle_key(&mut self, key: &KeyEvent, manager: &InstanceManager) -> bool { + self.drain(); + match self.phase { + Phase::Overwrite => { + match key.code { + KeyCode::Esc => self.phase = Phase::Confirm, + KeyCode::Enter => return self.write(true).is_some(), + _ => {} + } + return false; + } + Phase::Confirm => { + match key.code { + KeyCode::Esc => { + self.plan = None; + self.phase = Phase::Files; + } + KeyCode::Enter => match self.output().try_exists() { + Ok(true) => self.phase = Phase::Overwrite, + Ok(false) => return self.write(false).is_some(), + Err(error) => crate::feedback::errors::push_message( + tracing::Level::ERROR, + error.to_string(), + ), + }, + _ => {} + } + return false; + } + Phase::Files => { + match key.code { + KeyCode::Esc => { + self.phase = Phase::Form; + } + KeyCode::Char('h') | KeyCode::Left => { + if self.browser.directory.pop() { + self.browser.selected = 0; + } + } + KeyCode::Char('l') | KeyCode::Right => self.enter_directory(), + KeyCode::Char('j') | KeyCode::Down => { + self.browser.selected = (self.browser.selected + 1) + .min(self.file_entries().len().saturating_sub(1)) + } + KeyCode::Char('k') | KeyCode::Up => { + self.browser.selected = self.browser.selected.saturating_sub(1) + } + KeyCode::Char(' ') => self.toggle_file(), + KeyCode::Char('c') + if key.modifiers.is_empty() + && !self.unmatched_files(&self.selected_files).is_empty() => + { + self.bundle_unmatched = !self.bundle_unmatched; + } + KeyCode::Enter => self.confirm(manager), + _ => {} + } + return false; + } + Phase::Form => {} + } + if let Some(editor) = self.editing.as_mut() { + match key.code { + KeyCode::Enter => self.commit_edit(), + KeyCode::Esc => { + self.editing = None; + } + _ => handle_text_area_input(editor, key), + } + return false; + } + match key.code { + KeyCode::Esc => return true, + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => self.move_selection(true), + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => self.move_selection(false), + KeyCode::Left | KeyCode::Char('h') if self.selected == Field::Format => { + self.rotate_format(false) + } + KeyCode::Right | KeyCode::Char('l') if self.selected == Field::Format => { + self.rotate_format(true) + } + KeyCode::Enter | KeyCode::Right => self.begin_edit(manager), + _ => {} + } + false + } +} + +fn keybinds(state: &State) -> Vec<(&'static str, &'static str)> { + if state.editing.is_some() { + return vec![("Enter", " apply"), ("Esc", " cancel")]; + } + match state.phase { + Phase::Form if state.selected == Field::Format => { + vec![("h/l", " adjust"), ("Enter", " next"), ("Esc", " close")] + } + Phase::Form + if state.selected == Field::Include + && state.scan.is_none() + && state.pending.is_some() => + { + vec![("j/k", ""), ("Esc", " close")] + } + Phase::Form => vec![ + ("j/k", ""), + ( + "Enter", + if state.selected == Field::Include { + " open files" + } else { + " edit" + }, + ), + ("Esc", " close"), + ], + Phase::Files => { + let mut keys = vec![("Space", " toggle"), ("Enter", " continue")]; + if !state.unmatched_files(&state.selected_files).is_empty() { + keys.push(("c", " copy")); + } + keys.extend([("Esc", " back"), ("h/l", " folders")]); + keys + } + Phase::Confirm => vec![("Enter", " export"), ("Esc", " cancel")], + Phase::Overwrite => vec![("Esc", " back"), ("Enter", " overwrite")], + } +} + +pub fn render(frame: &mut Frame, state: &mut State) { + if state.phase == Phase::Overwrite { + let theme = THEME.as_ref(); + let message = Paragraph::new(vec![ + Line::styled( + "This will overwrite the existing file", + Style::default().fg(theme.warning()), + ), + Line::from(state.output().display().to_string()), + ]) + .style(Style::default().fg(theme.text())) + .wrap(Wrap { trim: true }); + let width = 64.min(frame.area().width.saturating_sub(4)); + let height = (message.line_count(width.saturating_sub(2)) as u16 + 2) + .min(frame.area().height.saturating_sub(4)); + let area = frame + .area() + .centered(Constraint::Length(width), Constraint::Length(height)); + frame.render_widget( + PopupFrame { + title: crate::tui::widgets::styled_title("Overwrite file?", false), + border_color: theme.text_dim(), + bg: Some(theme.surface()), + keybinds: Some(super::keybind_line(&keybinds(state))), + search_line: None, + content: Box::new(|area, buffer| message.clone().render(area, buffer)), + }, + area, + ); + return; + } + if state.phase == Phase::Confirm { + let theme = THEME.as_ref(); + let lines = state.confirmation_lines(); + let summary = Paragraph::new(lines).wrap(Wrap { trim: false }); + let width = frame.area().width * 68 / 100; + let height = (summary.line_count(width.saturating_sub(2)) as u16 + 2) + .min(frame.area().height.saturating_sub(4)); + let area = frame + .area() + .centered(Constraint::Length(width), Constraint::Length(height)); + frame.render_widget( + PopupFrame { + title: crate::tui::widgets::styled_title("Export Modpack", false), + border_color: theme.text_dim(), + bg: Some(theme.surface()), + keybinds: Some(super::keybind_line(&[ + ("Esc", " back"), + ("Enter", " export"), + ])), + search_line: None, + content: Box::new(|area, buffer| summary.clone().render(area, buffer)), + }, + area, + ); + return; + } + let theme = THEME.as_ref(); + let height = match state.phase { + Phase::Form => 11, + Phase::Files => (frame.area().height * 2 / 3).clamp(12, 24), + _ => unreachable!(), + }; + let area = frame.area().centered( + Constraint::Percentage(if state.phase == Phase::Files { 78 } else { 68 }), + Constraint::Length(height.min(frame.area().height.saturating_sub(4))), + ); + let title = match state.phase { + Phase::Files => "Included Files", + _ => "Export Modpack", + }; + frame.render_widget( + PopupFrame { + title: Line::from(format!(" {title} · {} ", state.instance.name)), + border_color: theme.text_dim(), + bg: Some(theme.surface()), + keybinds: Some(super::keybind_line_fitted( + crate::config::settings::ShortcutHintScope::Popups, + &keybinds(state), + area.width.saturating_sub(2), + )), + search_line: None, + content: Box::new(|area, buffer| match state.phase { + Phase::Form => render_form(state, area, buffer), + Phase::Files => render_files(state, area, buffer), + _ => unreachable!(), + }), + }, + area, + ); +} + +fn render_form(state: &State, area: Rect, buffer: &mut Buffer) { + let theme = THEME.as_ref(); + let form = area; + let mut rows = Vec::new(); + for field in Field::ALL { + if matches!(field, Field::Format | Field::Output) { + if field == Field::Output { + rows.push((None, Line::default())); + } + rows.push(( + None, + Line::from(Span::styled( + if field == Field::Format { + " Pack" + } else { + " Files" + }, + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + )), + )); + } + let selected = state.selected == field; + let supported = field.supported(state.format); + let scanning = field == Field::Include && state.scan.is_none() && state.pending.is_some(); + let value = if selected && state.editing.is_some() { + String::new() + } else { + match field { + _ if !supported => "—".to_owned(), + Field::Format => state.format.label().to_owned(), + Field::Include if state.scan.is_none() => if state.pending.is_some() { + "Scanning…" + } else { + "Not scanned" + } + .to_owned(), + Field::Include => format!("{} selected ›", state.selected_files.len()), + Field::Author if state.fields[2].is_empty() => "not set".to_owned(), + _ => field + .text_index() + .map_or_else(String::new, |index| state.fields[index].clone()), + } + }; + let mut spans = vec![ + Span::styled( + if selected { "▌ " } else { " " }, + Style::default().fg(theme.accent()), + ), + Span::styled( + format!("{:<18}", field.label()), + Style::default().fg(theme.text_dim()), + ), + Span::styled( + value, + Style::default() + .fg(if !supported || scanning { + theme.text_dim() + } else if selected { + theme.accent() + } else { + theme.text() + }) + .add_modifier(if selected { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]; + if field == Field::Format { + spans.extend([ + Span::raw(" "), + status_badge(format!(".{}", state.format.extension()), theme.text_dim()), + ]); + if state.format == PackFormat::Mmc { + spans.extend([Span::raw(" "), bundled_label()]); + } + } + rows.push((Some(field), Line::from(spans))); + } + let selected = rows + .iter() + .position(|(field, _)| *field == Some(state.selected)) + .unwrap_or(0); + let scroll = (selected + 1).saturating_sub(form.height as usize); + for (row, (field, line)) in rows + .into_iter() + .skip(scroll) + .take(form.height as usize) + .enumerate() + { + let row_area = Rect { + y: form.y + row as u16, + height: 1, + ..form + }; + Paragraph::new(line) + .style(Style::default().bg(if field == Some(state.selected) { + theme.stripe() + } else { + theme.surface() + })) + .render(row_area, buffer); + if field == Some(state.selected) + && let Some(editor) = &state.editing + { + editor.render( + Rect { + x: form.x.saturating_add(20).min(form.right()), + width: form.width.saturating_sub(20), + ..row_area + }, + buffer, + ); + } + } +} + +fn render_files(state: &State, area: Rect, buffer: &mut Buffer) { + let theme = THEME.as_ref(); + let [heading, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + let path = if state.browser.directory.as_os_str().is_empty() { + "minecraft/".to_owned() + } else { + format!("minecraft/{}/", state.browser.directory.display()) + }; + Paragraph::new(path) + .style( + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ) + .render(heading, buffer); + let compact = body.width < 76; + let summary = file_summary(state, compact); + let (list, panel) = if compact { + let height = summary + .iter() + .map(|line| super::word_wrap_size(&line.to_string(), body.width as usize).1 as u16) + .sum::(); + let [panel, list] = Layout::vertical([ + Constraint::Length(height.min(body.height.saturating_sub(1))), + Constraint::Min(1), + ]) + .areas(body); + (list, panel) + } else { + let [list, panel] = + Layout::horizontal([Constraint::Min(1), Constraint::Length(32)]).areas(body); + let block = Block::default() + .borders(Borders::LEFT) + .border_type(BORDER_STYLE.to_border_type()) + .border_style(Style::default().fg(theme.border())); + let panel_inner = block.inner(panel); + block.render(panel, buffer); + ( + Rect { + width: list.width.saturating_sub(1), + ..list + }, + Rect { + width: panel_inner.width.saturating_sub(2), + ..panel_inner + }, + ) + }; + files::render(state, list, buffer); + Paragraph::new(summary) + .wrap(Wrap { trim: false }) + .render(panel, buffer); +} + +fn file_summary(state: &State, compact: bool) -> Vec> { + let theme = THEME.as_ref(); + let downloads = state + .referenced_paths() + .map_or(0, |paths| state.selected_files.intersection(paths).count()); + let needs_references = state + .scan + .as_ref() + .is_some_and(|scan| scan.needs_references(state.format, &state.selected_files)); + let identified = !needs_references || state.referenced_paths().is_some(); + let status = if state.scan.is_none() && state.pending.is_some() { + Some(("Scanning files…".to_owned(), theme.text_dim())) + } else if needs_references && !state.resolved.contains_key(&state.format) { + Some(( + format!("Identifying {} downloads…", state.format.label()), + theme.text_dim(), + )) + } else { + None + }; + let included = state.selected_files.len(); + let excluded = state.scan.as_ref().map_or(0, |scan| scan.skipped.len()); + let unmatched = state.unmatched_files(&state.selected_files); + let copies = included - downloads - unmatched.len(); + let count = |value: usize| { + if identified { + value.to_string() + } else { + "…".to_owned() + } + }; + let mut lines = if compact { + vec![Line::styled( + if state.format == PackFormat::Mmc { + format!("{included} selected · {included} file copies") + } else { + format!( + "{included} selected · {} links · {} copies · {} unmatched", + count(downloads), + count(copies), + count(unmatched.len()) + ) + }, + Style::default().fg(theme.text_dim()), + )] + } else { + let mut lines = vec![Line::styled( + " Selection", + Style::default() + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + )]; + let mut counts = vec![("Selected", included.to_string())]; + if state.format == PackFormat::Mmc { + counts.push(("File copies", included.to_string())); + } else { + counts.extend([ + ("Download links", count(downloads)), + ("Other copies", count(copies)), + ("Unmatched", count(unmatched.len())), + ]); + } + counts.push(("Excluded", excluded.to_string())); + for (label, value) in counts { + lines.push(Line::from(vec![ + Span::styled( + format!(" {label:<16}"), + Style::default().fg(theme.text_dim()), + ), + Span::styled(value, Style::default().fg(theme.text())), + ])); + } + lines.push(Line::default()); + lines + }; + let indent = if compact { "" } else { " " }; + if !unmatched.is_empty() { + lines.push(Line::from(vec![ + Span::styled( + format!( + "{indent}[{}] ", + if state.bundle_unmatched { "✓" } else { " " } + ), + Style::default().fg(if state.bundle_unmatched { + theme.success() + } else { + theme.warning() + }), + ), + Span::styled( + format!("Copy {} unmatched", unmatched.len()), + Style::default().fg(theme.text()), + ), + ])); + } + if identified && (copies > 0 || !unmatched.is_empty()) { + lines.push(Line::styled( + format!("{indent}Check licences"), + Style::default().fg(theme.text_dim()), + )); + } + if compact && excluded > 0 { + lines.push(Line::styled( + format!("{excluded} excluded"), + Style::default().fg(theme.text_dim()), + )); + } + if let Some((message, color)) = status { + lines.insert(0, Line::styled(message, Style::default().fg(color))); + } + lines +} + +#[cfg(test)] +#[path = "../../tests/widgets/popups/export_modpack.rs"] +mod tests; diff --git a/src/tui/widgets/popups/export_modpack/files.rs b/src/tui/widgets/popups/export_modpack/files.rs new file mode 100644 index 00000000..afa8d0c4 --- /dev/null +++ b/src/tui/widgets/popups/export_modpack/files.rs @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2026 Constantin Bauer +// SPDX-License-Identifier: GPL-3.0-only + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, ListState, StatefulWidget}, +}; + +use super::State; +use crate::config::theme::THEME; + +#[derive(Default)] +pub(super) struct Browser { + pub directory: PathBuf, + pub selected: usize, +} + +pub(super) struct Entry { + pub path: PathBuf, + pub directory: bool, + pub total: usize, + pub included: usize, +} + +impl State { + pub(super) fn file_entries(&self) -> Vec { + let Some(scan) = self.scan.as_ref() else { + return Vec::new(); + }; + let mut children = BTreeMap::new(); + for path in scan.paths() { + let Ok(relative) = path.strip_prefix(&self.browser.directory) else { + continue; + }; + let Some(component) = relative.components().next() else { + continue; + }; + let child = self.browser.directory.join(component.as_os_str()); + let counts = children + .entry(child.clone()) + .or_insert((path != &child, 0, 0)); + counts.1 += 1; + counts.2 += usize::from(self.selected_files.contains(path)); + } + let mut entries = children + .into_iter() + .map(|(path, (directory, total, included))| Entry { + path, + directory, + total, + included, + }) + .collect::>(); + entries.sort_by(|left, right| { + right + .directory + .cmp(&left.directory) + .then_with(|| left.path.cmp(&right.path)) + }); + entries + } + + pub(super) fn toggle_file(&mut self) { + let before = self.unmatched_files(&self.selected_files); + let entries = self.file_entries(); + let adding = entries + .get(self.browser.selected) + .is_some_and(|entry| entry.included < entry.total); + if let Some(Entry { + path, + total, + included, + .. + }) = entries.get(self.browser.selected) + { + let all_selected = total == included; + if let Some(scan) = self.scan.as_ref() { + for file in scan.paths().filter(|file| file.starts_with(path)) { + if all_selected { + self.selected_files.remove(file); + } else { + self.selected_files.insert(file.clone()); + } + } + } + } + let unidentified = self.referenced_paths().is_none() + && self + .scan + .as_ref() + .is_some_and(|scan| scan.needs_references(self.format, &self.selected_files)); + if !self + .unmatched_files(&self.selected_files) + .is_subset(&before) + || adding && unidentified + { + self.bundle_unmatched = false; + } + } + + pub(super) fn enter_directory(&mut self) { + if let Some(Entry { + path, + directory: true, + .. + }) = self.file_entries().get(self.browser.selected) + { + self.browser.directory = path.clone(); + self.browser.selected = 0; + } + } +} + +pub(super) fn render(state: &State, area: Rect, buffer: &mut Buffer) { + let theme = THEME.as_ref(); + let all = state + .scan + .as_ref() + .map_or_else(Default::default, |scan| scan.paths().cloned().collect()); + let unmatched = state.unmatched_files(&all); + let entries = state + .file_entries() + .into_iter() + .enumerate() + .map(|(index, entry)| { + let Entry { + path, + directory, + total, + included, + } = entry; + let check = if included == total { + "✓" + } else if included == 0 { + " " + } else { + "−" + }; + let name = format!( + "{}{suffix}", + path.file_name().unwrap_or_default().to_string_lossy(), + suffix = if directory { "/" } else { "" } + ); + let has_unmatched = unmatched + .range(path.clone()..) + .next() + .is_some_and(|file| file.starts_with(&path)); + let mut spans = vec![ + Span::styled( + format!("[{check}] "), + Style::default().fg(if included > 0 { + theme.success() + } else { + theme.text_dim() + }), + ), + Span::styled( + name, + Style::default() + .fg(if has_unmatched { + theme.warning() + } else if included > 0 || directory { + theme.text() + } else { + theme.text_dim() + }) + .add_modifier(if directory { + Modifier::BOLD + } else { + Modifier::empty() + }), + ), + ]; + if directory { + let count = Span::styled( + format!("{included}/{total}"), + Style::default().fg(theme.text_dim()), + ); + let width = spans.iter().map(Span::width).sum::(); + let gap = (area.width.saturating_sub(2) as usize) + .saturating_sub(width + count.width()) + .max(2); + spans.push(Span::raw(" ".repeat(gap))); + spans.push(count); + } + let item = ListItem::new(Line::from(spans)); + if index == state.browser.selected { + item.style(Style::default().bg(theme.stripe())) + } else { + item + } + }) + .collect::>(); + let list = List::new(entries) + .style(Style::default().fg(theme.text())) + .highlight_symbol(Span::styled("▌ ", Style::default().fg(theme.accent()))) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + let mut selected = ListState::default().with_selected(Some(state.browser.selected)); + StatefulWidget::render(list, area, buffer, &mut selected); +} diff --git a/src/tui/widgets/popups/import_modpack/render.rs b/src/tui/widgets/popups/import_modpack/render.rs index 423eac56..19055be4 100644 --- a/src/tui/widgets/popups/import_modpack/render.rs +++ b/src/tui/widgets/popups/import_modpack/render.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use super::super::LoadState; -use super::super::base::PopupFrame; +use super::super::base::{PopupFrame, summary_heading, summary_row}; use super::state::{DISCOVERY_STATE, IMPORT_STATE, ImportStep, ImportWizardState}; use crate::config::theme::{BORDER_STYLE, THEME}; use crate::tui::app::FocusedArea; @@ -108,8 +108,17 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } ImportStep::Confirm => { - let h = 8u16.min(frame_area.height.saturating_sub(4)); - frame_area.centered(w, Constraint::Length(h)) + let width = frame_area.width * 68 / 100; + let height = IMPORT_STATE + .lock() + .map(|state| { + confirmation_summary(&state).line_count(width.saturating_sub(2)) as u16 + 2 + }) + .unwrap_or(13); + frame_area.centered( + Constraint::Length(width), + Constraint::Length(height.min(frame_area.height.saturating_sub(4))), + ) } } } @@ -276,19 +285,19 @@ fn render_version_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: } fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { + confirmation_summary(state).render(area, buf); +} + +fn confirmation_summary(state: &ImportWizardState) -> Paragraph<'static> { let theme = THEME.as_ref(); let summary = match &state.summary { Some(s) => s, None => { - Paragraph::new("No summary available") - .style(Style::default().fg(theme.text_dim())) - .render(area, buf); - return; + return Paragraph::new("No summary available") + .style(Style::default().fg(theme.text_dim())); } }; - let label_style = Style::default().fg(theme.text_dim()); - let loader_display = if let Some(ref lv) = summary.loader_version { format!("{} {}", summary.loader, lv) } else { @@ -296,34 +305,20 @@ fn render_confirm_step(state: &ImportWizardState, area: Rect, buf: &mut ratatui: }; Paragraph::new(vec![ - Line::from(vec![ - Span::styled("Name: ", label_style), - Span::raw(summary.name.clone()), - ]), - Line::from(vec![ - Span::styled("Pack Version: ", label_style), - Span::raw(summary.pack_version.clone()), - ]), - Line::from(vec![ - Span::styled("MC Version: ", label_style), - Span::raw(summary.game_version.clone()), - ]), - Line::from(vec![ - Span::styled("Loader: ", label_style), - Span::raw(loader_display), - ]), - Line::from(vec![ - Span::styled("Mods: ", label_style), - Span::raw(format!("{} files", summary.mod_count)), - ]), - Line::from(vec![ - Span::styled("Overrides: ", label_style), - Span::raw(format!("{} files", summary.override_count)), - ]), + summary_heading("Pack"), + summary_row("Pack name", summary.name.clone()), + summary_row("Pack version", summary.pack_version.clone()), + Line::default(), + summary_heading("Runtime"), + summary_row("Minecraft", summary.game_version.clone()), + summary_row("Loader", loader_display), + Line::default(), + summary_heading("Files"), + summary_row("Mods", format!("{} files", summary.mod_count)), + summary_row("Overrides", format!("{} files", summary.override_count)), ]) .style(Style::default().fg(theme.text())) - .wrap(Wrap { trim: true }) - .render(area, buf); + .wrap(Wrap { trim: false }) } fn wizard_title(state: &ImportWizardState) -> Line<'static> { diff --git a/src/tui/widgets/popups/mod.rs b/src/tui/widgets/popups/mod.rs index a55b83ee..0e319be5 100644 --- a/src/tui/widgets/popups/mod.rs +++ b/src/tui/widgets/popups/mod.rs @@ -4,6 +4,7 @@ pub mod base; pub mod confirm; pub mod error; +pub mod export_modpack; pub mod global_settings; pub mod import_modpack; pub mod instance_settings; diff --git a/src/tui/widgets/popups/modpack_update.rs b/src/tui/widgets/popups/modpack_update.rs index 81f3dab0..32193233 100644 --- a/src/tui/widgets/popups/modpack_update.rs +++ b/src/tui/widgets/popups/modpack_update.rs @@ -10,9 +10,10 @@ use ratatui::{ layout::{Constraint, Flex, Layout}, style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; +use super::base::{summary_heading, summary_row}; use crate::config::theme::{BORDER_STYLE, THEME}; use crate::instance::import::refresh::RefreshPlan; @@ -113,10 +114,25 @@ pub fn render(frame: &mut Frame, state: &State) { .plan .as_ref() .map_or(1, |plan| plan.conflicts.len().max(5)); - let [area] = Layout::vertical([Constraint::Length((count as u16 + 5).clamp(8, 20))]) - .flex(Flex::Center) - .areas(frame.area()); - let [area] = Layout::horizontal([Constraint::Percentage(62)]) + let mut lines = state.plan.as_ref().map_or_else(Vec::new, |plan| { + review_lines(&plan.summary, &plan.current_version, &plan.target_version) + }); + if let Some(error) = &state.error { + lines.push(Line::from(error.clone()).style(Style::default().fg(theme.error()))); + } + let summary = Paragraph::new(lines).wrap(Wrap { trim: false }); + let width = frame.area().width * if state.phase == Phase::Review { 68 } else { 62 } / 100; + let height = if state.phase == Phase::Review { + summary.line_count(width.saturating_sub(2)) as u16 + 2 + } else { + (count as u16 + 5).clamp(8, 20) + }; + let [area] = Layout::vertical([Constraint::Length( + height.min(frame.area().height.saturating_sub(4)), + )]) + .flex(Flex::Center) + .areas(frame.area()); + let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(area); frame.render_widget(Clear, area); @@ -200,32 +216,40 @@ pub fn render(frame: &mut Frame, state: &State) { ); } Phase::Review => { - let mut lines = state.plan.as_ref().map_or_else(Vec::new, |plan| { - vec![ - Line::from(vec![ - Span::styled( - plan.summary.name.clone(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(format!( - " {} → {}", - plan.current_version, plan.target_version - )), - ]), - Line::from(format!("Minecraft: {}", plan.summary.game_version)), - Line::from(format!( - "Loader: {} {}", - plan.summary.loader, - plan.summary.loader_version.as_deref().unwrap_or("") - )), - Line::from(format!("Pack files: {}", plan.summary.mod_count)), - Line::from(format!("Overrides: {}", plan.summary.override_count)), - ] - }); - if let Some(error) = &state.error { - lines.push(Line::from(error.clone()).style(Style::default().fg(theme.error()))); - } - frame.render_widget(Paragraph::new(lines).block(block), area); + frame.render_widget(summary.block(block), area); } } } + +fn review_lines( + summary: &crate::instance::import::ImportSummary, + current: &str, + target: &str, +) -> Vec> { + vec![ + summary_heading("Pack"), + summary_row("Pack name", summary.name.clone()), + summary_row("Pack version", format!("{current} → {target}")), + Line::default(), + summary_heading("Runtime"), + summary_row("Minecraft", summary.game_version.clone()), + summary_row( + "Loader", + format!( + "{} {}", + summary.loader, + summary.loader_version.as_deref().unwrap_or("") + ) + .trim_end() + .to_owned(), + ), + Line::default(), + summary_heading("Files"), + summary_row("Pack files", summary.mod_count.to_string()), + summary_row("Overrides", summary.override_count.to_string()), + ] +} + +#[cfg(test)] +#[path = "../../tests/widgets/popups/modpack_update.rs"] +mod tests; diff --git a/src/tui/widgets/popups/new_instance/render.rs b/src/tui/widgets/popups/new_instance/render.rs index 45236730..76825a2c 100644 --- a/src/tui/widgets/popups/new_instance/render.rs +++ b/src/tui/widgets/popups/new_instance/render.rs @@ -9,7 +9,7 @@ use super::state::{ use crate::config::theme::THEME; use crate::instance::models::ModLoader; use crate::tui::app::FocusedArea; -use crate::tui::widgets::popups::base::{PopupFrame, render_summary}; +use crate::tui::widgets::popups::base::{PopupFrame, summary_heading, summary_row}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, @@ -107,8 +107,17 @@ pub fn popup_rect(frame_area: Rect) -> Rect { frame_area.centered(w, Constraint::Length(h)) } WizardStep::Confirm => { - let h = 6u16.min(frame_area.height.saturating_sub(4)); - frame_area.centered(w, Constraint::Length(h)) + let width = frame_area.width * 68 / 100; + let height = WIZARD_STATE + .lock() + .map(|state| { + confirmation_summary(&state).line_count(width.saturating_sub(2)) as u16 + 2 + }) + .unwrap_or(9); + frame_area.centered( + Constraint::Length(width), + Constraint::Length(height.min(frame_area.height.saturating_sub(4))), + ) } } } @@ -249,6 +258,10 @@ fn render_loader_version_step(state: &WizardState, area: Rect, buf: &mut ratatui } fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffer::Buffer) { + confirmation_summary(state).render(area, buf); +} + +fn confirmation_summary(state: &WizardState) -> Paragraph<'static> { let game_version = state .selected_version() .map(|version| version.id.as_str()) @@ -262,16 +275,16 @@ fn render_confirm_step(state: &WizardState, area: Rect, buf: &mut ratatui::buffe .unwrap_or_else(|| "".to_string()) }; let loader = loader.to_string(); - render_summary( - &[ - ("Name", state.name_state.value()), - ("MC", game_version), - ("Loader", &loader), - ("Loader version", &loader_version), - ], - area, - buf, - ); + Paragraph::new(vec![ + summary_heading("Instance"), + summary_row("Name", state.name_state.value().to_owned()), + Line::default(), + summary_heading("Runtime"), + summary_row("Minecraft", game_version.to_owned()), + summary_row("Loader", loader), + summary_row("Loader version", loader_version), + ]) + .wrap(Wrap { trim: false }) } #[cfg(test)]