From 538c37f8532f33b340957905fa093ae1cbdaca8f Mon Sep 17 00:00:00 2001 From: objz Date: Sun, 25 Oct 2026 19:12:29 +0100 Subject: [PATCH] feat: export modpacks in CurseForge, Modrinth and MultiMC formats Add shared export planning, cached scans, exact file selection, safe overwrite confirmation and background TUI progress. Refine sectioned confirmations and allow ordinary content changes while games run. Invalidate stale export fingerprints on validation failure, preserve ownership and mutation checks, and use ordered lookups for unmatched-file highlighting. Add export, UI, locking and cache-recovery regression tests. --- README.md | 62 ++ src/cli/export.rs | 65 +- src/cli/tests/arguments.rs | 26 + src/instance/content/local.rs | 17 +- src/instance/content/manifest.rs | 65 +- src/instance/export.rs | 731 ++++++++++--- src/instance/export/curseforge.rs | 116 +++ src/instance/export/mmc.rs | 37 + src/instance/export/mrpack.rs | 121 +++ src/instance/import/mmc.rs | 8 +- src/instance/import/mod.rs | 7 +- src/instance/import/mrpack.rs | 6 +- src/instance/launch/mod.rs | 6 +- src/instance/mod.rs | 1 + src/instance/pack.rs | 28 + src/instance/runtime.rs | 52 +- src/instance/tests/content/dependencies.rs | 13 +- src/instance/tests/content/local.rs | 44 + src/instance/tests/content/manifest.rs | 41 + src/instance/tests/export.rs | 779 +++++++++++++- src/instance/tests/manager.rs | 9 +- src/instance/tests/runtime.rs | 25 +- src/net/tests/curseforge.rs | 30 + src/tui/app.rs | 2 + src/tui/event.rs | 11 + src/tui/input.rs | 16 + src/tui/render.rs | 26 +- src/tui/tests/harness.rs | 1 + ..._tests__content_confirmation_sections.snap | 28 + src/tui/tests/widgets/content/tabs.rs | 15 +- src/tui/tests/widgets/content/update.rs | 19 +- src/tui/tests/widgets/popups/base.rs | 24 + .../tests/widgets/popups/export_modpack.rs | 819 +++++++++++++++ .../widgets/popups/import_modpack/render.rs | 21 +- ...__import_modpack_renders_confirm_step.snap | 34 +- ...s_confirm_step_without_loader_version.snap | 34 +- .../tests/widgets/popups/modpack_update.rs | 42 + .../widgets/popups/new_instance/render.rs | 4 +- ...ts__new_instance_renders_confirm_step.snap | 32 +- ...t_modpack__tests__export_confirmation.snap | 30 + ...modpack__tests__export_file_breakdown.snap | 38 + ...t_modpack__tests__export_file_browser.snap | 34 + ...k__tests__export_file_browser_compact.snap | 28 + ...s__export_modpack__tests__export_form.snap | 30 + ...modpack__tests__export_format_bundled.snap | 30 + ...modpack__tests__export_mods_unmatched.snap | 38 + src/tui/widgets/content/tabs.rs | 130 +-- src/tui/widgets/content/update.rs | 12 +- src/tui/widgets/popups/base.rs | 33 +- src/tui/widgets/popups/export_modpack.rs | 979 ++++++++++++++++++ .../widgets/popups/export_modpack/files.rs | 206 ++++ .../widgets/popups/import_modpack/render.rs | 65 +- src/tui/widgets/popups/mod.rs | 1 + src/tui/widgets/popups/modpack_update.rs | 86 +- src/tui/widgets/popups/new_instance/render.rs | 39 +- 55 files changed, 4747 insertions(+), 449 deletions(-) create mode 100644 src/instance/export/curseforge.rs create mode 100644 src/instance/export/mmc.rs create mode 100644 src/instance/export/mrpack.rs create mode 100644 src/instance/pack.rs create mode 100644 src/tui/tests/widgets/content/snapshots/rmcl__tui__widgets__content__tabs__tests__content_confirmation_sections.snap create mode 100644 src/tui/tests/widgets/popups/export_modpack.rs create mode 100644 src/tui/tests/widgets/popups/modpack_update.rs create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_confirmation.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_breakdown.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_file_browser_compact.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_form.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_format_bundled.snap create mode 100644 src/tui/tests/widgets/popups/snapshots/rmcl__tui__widgets__popups__export_modpack__tests__export_mods_unmatched.snap create mode 100644 src/tui/widgets/popups/export_modpack.rs create mode 100644 src/tui/widgets/popups/export_modpack/files.rs 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)]